bugfix event
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
node_modules
|
||||
packages/meta-dup-core/dist
|
||||
packages/meta-dup-ui/dist
|
||||
packages/meta-dup-ui/node_modules
|
||||
|
||||
@@ -153,40 +153,7 @@ export class APIServer {
|
||||
};
|
||||
});
|
||||
|
||||
// Service discovery (for dashboard navigation)
|
||||
this.app.get('/api/services', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const services: Array<{
|
||||
name: string;
|
||||
url: string;
|
||||
api: string;
|
||||
status: string;
|
||||
role?: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
const serviceDiscovery = this.kvManager.getServiceDiscovery();
|
||||
if (serviceDiscovery) {
|
||||
const allServices = await serviceDiscovery.discoverAllServices();
|
||||
|
||||
for (const svc of allServices) {
|
||||
services.push({
|
||||
name: svc.name || 'Unknown',
|
||||
url: svc.baseUrl || '',
|
||||
api: svc.baseUrl || '',
|
||||
status: svc.status || 'unknown',
|
||||
role: svc.role,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error discovering services:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
services,
|
||||
current: 'meta-dup',
|
||||
};
|
||||
});
|
||||
// Note: /api/services is handled by nginx proxy to meta-core (centralized service discovery)
|
||||
|
||||
// Catch-all for SPA routing (serve index.html for non-API routes)
|
||||
this.app.setNotFoundHandler(async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import { hostname } from 'os';
|
||||
import { Logger } from 'tslog';
|
||||
import { LeaderClient } from './LeaderClient.js';
|
||||
import { ServiceDiscovery } from './ServiceDiscovery.js';
|
||||
import { ServiceRegistration } from './ServiceRegistration.js';
|
||||
import { RedisClient } from './RedisClient.js';
|
||||
import type { LeaderLockInfo } from './IKVClient.js';
|
||||
|
||||
@@ -48,7 +48,7 @@ interface KVManagerConfig {
|
||||
export class KVManager {
|
||||
private config: Required<KVManagerConfig>;
|
||||
private leaderClient: LeaderClient | null = null;
|
||||
private serviceDiscovery: ServiceDiscovery | null = null;
|
||||
private serviceRegistration: ServiceRegistration | null = null;
|
||||
private redisClient: RedisClient | null = null;
|
||||
private isStarted = false;
|
||||
private isShuttingDown = false;
|
||||
@@ -80,14 +80,14 @@ export class KVManager {
|
||||
|
||||
logger.info(`Starting KVManager for ${this.config.serviceName}...`);
|
||||
|
||||
// Initialize and start service discovery for registration and heartbeat
|
||||
// Initialize and start service registration for heartbeat
|
||||
const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`;
|
||||
this.serviceDiscovery = new ServiceDiscovery({
|
||||
this.serviceRegistration = new ServiceRegistration({
|
||||
metaCorePath: this.config.metaCorePath,
|
||||
serviceName: this.config.serviceName,
|
||||
apiUrl
|
||||
});
|
||||
await this.serviceDiscovery.start();
|
||||
await this.serviceRegistration.start();
|
||||
|
||||
// If direct Redis URL provided, skip leader discovery
|
||||
if (this.config.redisUrl) {
|
||||
@@ -162,10 +162,10 @@ export class KVManager {
|
||||
this.leaderClient = null;
|
||||
}
|
||||
|
||||
// Stop service discovery (if it was started)
|
||||
if (this.serviceDiscovery) {
|
||||
await this.serviceDiscovery.stop();
|
||||
this.serviceDiscovery = null;
|
||||
// Stop service registration (if it was started)
|
||||
if (this.serviceRegistration) {
|
||||
await this.serviceRegistration.stop();
|
||||
this.serviceRegistration = null;
|
||||
}
|
||||
|
||||
// Disconnect Redis
|
||||
@@ -341,10 +341,10 @@ export class KVManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service discovery (for inter-service navigation)
|
||||
* Get service registration instance
|
||||
*/
|
||||
getServiceDiscovery(): ServiceDiscovery | null {
|
||||
return this.serviceDiscovery;
|
||||
getServiceRegistration(): ServiceRegistration | null {
|
||||
return this.serviceRegistration;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+8
-128
@@ -1,11 +1,11 @@
|
||||
/**
|
||||
* Service Discovery using shared filesystem
|
||||
* Service Registration using shared filesystem
|
||||
*
|
||||
* Each service registers itself in /meta-core/services/{service-name}-{hostname}.json
|
||||
* with its base URL, status, and heartbeat timestamp.
|
||||
*
|
||||
* Services can discover each other by reading these JSON files.
|
||||
* Stale detection: if lastHeartbeat > 60 seconds ago, service is considered stale.
|
||||
* Service discovery is centralized in meta-core - this class only handles registration.
|
||||
* For service discovery, use the /api/services endpoint (proxied to meta-core via nginx).
|
||||
*
|
||||
* Each service must call start() to register itself and begin heartbeat updates.
|
||||
* meta-core runs a cleanup process that removes services with stale heartbeats.
|
||||
@@ -13,13 +13,12 @@
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { hostname } from 'os';
|
||||
import { join } from 'path';
|
||||
import { Logger } from 'tslog';
|
||||
import type { ServiceInfo } from './IKVClient.js';
|
||||
|
||||
const logger = new Logger({ name: 'ServiceDiscovery' });
|
||||
const logger = new Logger({ name: 'ServiceRegistration' });
|
||||
|
||||
interface ServiceDiscoveryConfig {
|
||||
interface ServiceRegistrationConfig {
|
||||
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
|
||||
metaCorePath: string;
|
||||
|
||||
@@ -36,8 +35,8 @@ interface ServiceDiscoveryConfig {
|
||||
staleThreshold?: number;
|
||||
}
|
||||
|
||||
export class ServiceDiscovery {
|
||||
private config: ServiceDiscoveryConfig;
|
||||
export class ServiceRegistration {
|
||||
private config: ServiceRegistrationConfig;
|
||||
private servicesDir: string;
|
||||
private serviceFilePath: string;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
@@ -45,7 +44,7 @@ export class ServiceDiscovery {
|
||||
private isStarted = false;
|
||||
private currentHostname: string;
|
||||
|
||||
constructor(config: ServiceDiscoveryConfig) {
|
||||
constructor(config: ServiceRegistrationConfig) {
|
||||
this.config = {
|
||||
heartbeatInterval: 30000,
|
||||
staleThreshold: 60000,
|
||||
@@ -167,125 +166,6 @@ export class ServiceDiscovery {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service info is stale
|
||||
*/
|
||||
private isStale(info: ServiceInfo): boolean {
|
||||
const lastHeartbeat = new Date(info.lastHeartbeat).getTime();
|
||||
return Date.now() - lastHeartbeat > this.config.staleThreshold!;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Discovery Methods
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* Discover a service by name
|
||||
* Looks for files matching pattern: {name}-*.json (hostname-based naming)
|
||||
*/
|
||||
async discoverService(name: string): Promise<ServiceInfo | null> {
|
||||
try {
|
||||
// First try exact match for backward compatibility
|
||||
const exactPath = join(this.servicesDir, `${name}.json`);
|
||||
try {
|
||||
const content = await fs.readFile(exactPath, 'utf-8');
|
||||
const service = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
if (this.isStale(service)) {
|
||||
service.status = 'stale';
|
||||
}
|
||||
|
||||
return service.status === 'running' ? service : null;
|
||||
} catch {
|
||||
// Try hostname-based files
|
||||
}
|
||||
|
||||
// Search for hostname-based files: name-*.json
|
||||
const files = await fs.readdir(this.servicesDir);
|
||||
const matchingFiles = files.filter(f =>
|
||||
f.startsWith(`${name}-`) && f.endsWith('.json')
|
||||
);
|
||||
|
||||
// Return first valid (non-stale) service
|
||||
for (const file of matchingFiles) {
|
||||
const filePath = join(this.servicesDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const service = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
if (this.isStale(service)) {
|
||||
service.status = 'stale';
|
||||
continue; // Skip stale services
|
||||
}
|
||||
|
||||
if (service.status === 'running') {
|
||||
return service;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null; // Service not registered
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all registered services
|
||||
*/
|
||||
async discoverAllServices(): Promise<ServiceInfo[]> {
|
||||
try {
|
||||
const files = await fs.readdir(this.servicesDir);
|
||||
const services: ServiceInfo[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const filePath = join(this.servicesDir, file);
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const service = JSON.parse(content) as ServiceInfo;
|
||||
|
||||
if (this.isStale(service)) {
|
||||
service.status = 'stale';
|
||||
}
|
||||
|
||||
services.push(service);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to read ${file}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return services;
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return []; // Services directory doesn't exist yet
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a service is healthy
|
||||
*/
|
||||
async isServiceHealthy(name: string): Promise<boolean> {
|
||||
const service = await this.discoverService(name);
|
||||
if (!service) return false;
|
||||
|
||||
// Try to ping the service's health endpoint
|
||||
const url = `${service.baseUrl}/health`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Lifecycle
|
||||
// ========================================================================
|
||||
-660
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>meta-dup - Duplicate Detection</title>
|
||||
<script type="module" crossorigin src="/assets/index-GT6exvk4.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,12 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
|
||||
interface ServiceInfo {
|
||||
name: string;
|
||||
url: string;
|
||||
api: string;
|
||||
hostname: string;
|
||||
baseUrl: string;
|
||||
status: string;
|
||||
capabilities: string[];
|
||||
version: string;
|
||||
role?: string; // "leader", "follower", or undefined (for non-meta-core services)
|
||||
lastHeartbeat: string;
|
||||
}
|
||||
|
||||
interface ServicesResponse {
|
||||
@@ -19,6 +17,7 @@ const serviceIcons: Record<string, string> = {
|
||||
'meta-sort': '📁',
|
||||
'meta-fuse': '🗂️',
|
||||
'meta-stremio': '🎬',
|
||||
'meta-core': '⚙️',
|
||||
'meta-dup': '🔍',
|
||||
'meta-orbit': '🌐',
|
||||
'default': '📦'
|
||||
@@ -57,11 +56,7 @@ function ServiceNav() {
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
// Filter out follower meta-core instances (only show leader)
|
||||
const filteredServices = services.filter(
|
||||
s => s.name !== 'meta-core' || s.role === 'leader'
|
||||
);
|
||||
const sortedServices = [...filteredServices].sort((a, b) => a.name.localeCompare(b.name));
|
||||
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
if (sortedServices.length === 0) return null;
|
||||
|
||||
@@ -75,12 +70,12 @@ function ServiceNav() {
|
||||
return (
|
||||
<a
|
||||
key={service.name}
|
||||
href={isActive ? '#' : service.url}
|
||||
href={isActive ? '#' : service.baseUrl}
|
||||
className={`service-link${isActive ? ' active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!isActive) {
|
||||
window.location.href = service.url;
|
||||
window.location.href = service.baseUrl;
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user