bugfix event

This commit is contained in:
Pierre
2026-03-07 14:44:17 +01:00
parent d7cd0f05ff
commit eee9773f23
7 changed files with 236 additions and 441 deletions
+6 -2
View File
@@ -27,13 +27,17 @@ RUN npm run build
# Stage 2: Build Backend using pnpm workspace # Stage 2: Build Backend using pnpm workspace
FROM node:21-alpine AS backend-builder FROM node:21-alpine AS backend-builder
# Install pnpm # Install pnpm via corepack (version from package.json packageManager field)
RUN npm install -g pnpm ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN npm install -g corepack@latest && corepack enable
WORKDIR /build WORKDIR /build
# Copy workspace configuration # Copy workspace configuration
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN corepack install
# Copy all packages for the workspace # Copy all packages for the workspace
COPY packages/shared/ ./packages/shared/ COPY packages/shared/ ./packages/shared/
@@ -201,10 +201,7 @@ export class UnifiedAPIServer {
this.setupStatsRoutes(); this.setupStatsRoutes();
} }
// Service discovery routes (under /api/services) // Note: /api/services is handled by nginx proxy to meta-core (centralized service discovery)
if (this.kvManager) {
this.setupServicesRoutes();
}
// Plugin management routes (under /api/plugins) // Plugin management routes (under /api/plugins)
// Always register - routes check for plugin manager at request time // Always register - routes check for plugin manager at request time
@@ -864,61 +861,6 @@ export class UnifiedAPIServer {
}); });
} }
/**
* Setup Services Discovery API routes
* Returns list of discovered services for dashboard navigation
*/
private setupServicesRoutes(): void {
if (!this.kvManager) {
return;
}
// Get discovered services (for inter-service navigation)
this.app.get('/api/services', async (request, reply) => {
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) {
console.error('[Services API] Error discovering services:', error);
}
// Get leader info
const leaderInfo = this.kvManager!.getLeaderInfo();
return {
services,
current: 'meta-sort',
leader: leaderInfo ? {
hostname: leaderInfo.hostname,
baseUrl: leaderInfo.baseUrl,
apiUrl: leaderInfo.apiUrl,
redisUrl: leaderInfo.redisUrl,
webdavUrl: leaderInfo.webdavUrl,
} : null,
};
});
}
/** /**
* Setup Plugin Management API routes * Setup Plugin Management API routes
*/ */
+20 -25
View File
@@ -28,7 +28,7 @@
import { hostname } from 'os'; import { hostname } from 'os';
import { LeaderClient } from './LeaderClient.js'; import { LeaderClient } from './LeaderClient.js';
import { ServiceDiscovery } from './ServiceDiscovery.js'; import { ServiceRegistration } from './ServiceRegistration.js';
import { RedisKVClient } from './RedisClient.js'; import { RedisKVClient } from './RedisClient.js';
import type { IKVClient, LeaderLockInfo } from './IKVClient.js'; import type { IKVClient, LeaderLockInfo } from './IKVClient.js';
import { configure as configureWebdav } from '../webdav/WebdavClient.js'; import { configure as configureWebdav } from '../webdav/WebdavClient.js';
@@ -59,7 +59,7 @@ interface KVManagerConfig {
export class KVManager { export class KVManager {
private config: KVManagerConfig; private config: KVManagerConfig;
private leaderClient: LeaderClient | null = null; private leaderClient: LeaderClient | null = null;
private serviceDiscovery: ServiceDiscovery | null = null; private serviceRegistration: ServiceRegistration | null = null;
private kvClient: IKVClient | null = null; private kvClient: IKVClient | null = null;
private isStarted = false; private isStarted = false;
private isShuttingDown = false; private isShuttingDown = false;
@@ -124,9 +124,10 @@ export class KVManager {
console.log(`[KVManager] Connecting to Redis at ${leaderInfo.redisUrl}...`); console.log(`[KVManager] Connecting to Redis at ${leaderInfo.redisUrl}...`);
this.kvClient = await this.createRedisClient(leaderInfo.redisUrl); this.kvClient = await this.createRedisClient(leaderInfo.redisUrl);
// Configure WebDAV client with leader's WebDAV URL // Configure WebDAV client with leader's WebDAV URL (prefer internal for container-to-container)
if (leaderInfo.webdavUrl) { const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl;
configureWebdav(leaderInfo.webdavUrl); if (webdavUrl) {
configureWebdav(webdavUrl);
} }
} catch (error) { } catch (error) {
console.error('[KVManager] Failed to connect to leader:', error); console.error('[KVManager] Failed to connect to leader:', error);
@@ -136,15 +137,15 @@ export class KVManager {
// Start watching for leader changes // Start watching for leader changes
this.leaderClient.startWatching(); this.leaderClient.startWatching();
// 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}`; const apiUrl = this.config.baseUrl || `http://${hostname()}:${this.config.apiPort}`;
this.serviceDiscovery = new ServiceDiscovery({ this.serviceRegistration = new ServiceRegistration({
metaCorePath: this.config.metaCorePath, metaCorePath: this.config.metaCorePath,
serviceName: this.config.serviceName, serviceName: this.config.serviceName,
version: '1.0.0', version: '1.0.0',
apiUrl apiUrl
}); });
await this.serviceDiscovery.start(); await this.serviceRegistration.start();
this.isStarted = true; this.isStarted = true;
this.notifyReady(); this.notifyReady();
@@ -170,9 +171,10 @@ export class KVManager {
console.log(`[KVManager] Reconnecting to Redis at ${leaderInfo.redisUrl}...`); console.log(`[KVManager] Reconnecting to Redis at ${leaderInfo.redisUrl}...`);
this.kvClient = await this.createRedisClient(leaderInfo.redisUrl); this.kvClient = await this.createRedisClient(leaderInfo.redisUrl);
// Reconfigure WebDAV client with new leader's WebDAV URL // Reconfigure WebDAV client with new leader's WebDAV URL (prefer internal for container-to-container)
if (leaderInfo.webdavUrl) { const webdavUrl = leaderInfo.webdavUrlInternal ?? leaderInfo.webdavUrl;
configureWebdav(leaderInfo.webdavUrl); if (webdavUrl) {
configureWebdav(webdavUrl);
} }
// Notify reconnect callbacks // Notify reconnect callbacks
@@ -209,10 +211,10 @@ export class KVManager {
this.kvClient = null; this.kvClient = null;
} }
// Service discovery stop (if it was started) // Service registration stop (if it was started)
if (this.serviceDiscovery) { if (this.serviceRegistration) {
await this.serviceDiscovery.stop(); await this.serviceRegistration.stop();
this.serviceDiscovery = null; this.serviceRegistration = null;
} }
this.isStarted = false; this.isStarted = false;
@@ -308,17 +310,10 @@ export class KVManager {
} }
/** /**
* Get service discovery instance * Get service registration instance
*/ */
getServiceDiscovery(): ServiceDiscovery | null { getServiceRegistration(): ServiceRegistration | null {
return this.serviceDiscovery; return this.serviceRegistration;
}
/**
* Discover another service
*/
async discoverService(name: string) {
return this.serviceDiscovery?.discoverService(name) ?? null;
} }
/** /**
@@ -1,341 +0,0 @@
/**
* Service Discovery 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.
*
* Each service must call start() to register itself and begin heartbeat updates.
* meta-core runs a cleanup process that removes services with stale heartbeats.
*/
import { promises as fs } from 'fs';
import { hostname } from 'os';
import { join, basename } from 'path';
import type { ServiceInfo } from './IKVClient.js';
import { isNodeError } from '../types/ExtendedInterfaces.js';
interface ServiceDiscoveryConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Service name */
serviceName: string;
/** Service version (optional, not used in simplified format) */
version?: string;
/** Base URL for the service */
apiUrl: string;
/** Heartbeat interval in ms (default: 30000) */
heartbeatInterval?: number;
/** Stale threshold in ms (default: 60000) */
staleThreshold?: number;
}
export class ServiceDiscovery {
private config: ServiceDiscoveryConfig;
private servicesDir: string;
private serviceFilePath: string;
private heartbeatTimer: NodeJS.Timeout | null = null;
private isShuttingDown = false;
private currentHostname: string;
constructor(config: ServiceDiscoveryConfig) {
this.config = {
heartbeatInterval: 30000,
staleThreshold: 60000,
...config
};
this.currentHostname = hostname();
this.servicesDir = `${this.config.metaCorePath}/services`;
// Use hostname-based file naming
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.json`;
}
/**
* Ensure services directory exists
*/
private async ensureServicesDir(): Promise<void> {
await fs.mkdir(this.servicesDir, { recursive: true });
}
/**
* Build service info for this service
*/
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
return {
name: this.config.serviceName,
hostname: this.currentHostname,
baseUrl: this.config.apiUrl,
status,
lastHeartbeat: new Date().toISOString()
};
}
/**
* Register this service by writing service info to the shared filesystem.
* meta-core's cleanup process monitors these files and removes stale entries.
*/
async register(): Promise<void> {
await this.ensureServicesDir();
const info = this.buildServiceInfo('running');
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
console.log(`[ServiceDiscovery] Registered ${this.config.serviceName}-${this.currentHostname}`);
}
/**
* Update service status
*/
async updateStatus(status: ServiceInfo['status']): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.status = status;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
console.error('[ServiceDiscovery] Failed to update status:', error);
}
}
/**
* Send heartbeat (update lastHeartbeat timestamp)
*/
async heartbeat(): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
// If file was deleted, re-register
if (isNodeError(error) && error.code === 'ENOENT') {
await this.register();
} else {
console.error('[ServiceDiscovery] Heartbeat failed:', error);
}
}
}
/**
* Start heartbeat loop
*/
startHeartbeat(): void {
this.heartbeatTimer = setInterval(async () => {
if (this.isShuttingDown) return;
await this.heartbeat();
}, this.config.heartbeatInterval);
}
/**
* Stop heartbeat loop
*/
stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Unregister this service (on shutdown)
*/
async unregister(): Promise<void> {
this.isShuttingDown = true;
this.stopHeartbeat();
try {
// Remove the service file
await fs.unlink(this.serviceFilePath);
console.log(`[ServiceDiscovery] Unregistered ${this.config.serviceName}-${this.currentHostname}`);
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
console.error('[ServiceDiscovery] Failed to unregister:', error);
}
}
}
/**
* 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) {
if (isNodeError(error) && 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) {
console.error(`[ServiceDiscovery] Failed to read ${file}:`, error);
}
}
return services;
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
return []; // Services directory doesn't exist yet
}
throw error;
}
}
/**
* Wait for a service to become available
*/
async waitForService(
name: string,
timeoutMs: number = 30000,
pollInterval: number = 1000
): Promise<ServiceInfo> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const service = await this.discoverService(name);
if (service && service.status === 'running') {
return service;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Service ${name} not available after ${timeoutMs}ms`);
}
/**
* 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
// ========================================================================
/**
* Full startup sequence: register service and start heartbeat loop.
*/
async start(): Promise<void> {
await this.register();
this.startHeartbeat();
}
/**
* Full shutdown sequence
*/
async stop(): Promise<void> {
await this.unregister();
}
// ========================================================================
// Getters
// ========================================================================
getServiceFilePath(): string {
return this.serviceFilePath;
}
getServicesDir(): string {
return this.servicesDir;
}
}
@@ -0,0 +1,199 @@
/**
* 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.
*
* 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.
*/
import { promises as fs } from 'fs';
import { hostname } from 'os';
import type { ServiceInfo } from './IKVClient.js';
import { isNodeError } from '../types/ExtendedInterfaces.js';
interface ServiceRegistrationConfig {
/** Path to META_CORE_VOLUME (e.g., /meta-core) */
metaCorePath: string;
/** Service name */
serviceName: string;
/** Service version (optional, not used in simplified format) */
version?: string;
/** Base URL for the service */
apiUrl: string;
/** Heartbeat interval in ms (default: 30000) */
heartbeatInterval?: number;
/** Stale threshold in ms (default: 60000) */
staleThreshold?: number;
}
export class ServiceRegistration {
private config: ServiceRegistrationConfig;
private servicesDir: string;
private serviceFilePath: string;
private heartbeatTimer: NodeJS.Timeout | null = null;
private isShuttingDown = false;
private currentHostname: string;
constructor(config: ServiceRegistrationConfig) {
this.config = {
heartbeatInterval: 30000,
staleThreshold: 60000,
...config
};
this.currentHostname = hostname();
this.servicesDir = `${this.config.metaCorePath}/services`;
// Use hostname-based file naming
this.serviceFilePath = `${this.servicesDir}/${this.config.serviceName}-${this.currentHostname}.json`;
}
/**
* Ensure services directory exists
*/
private async ensureServicesDir(): Promise<void> {
await fs.mkdir(this.servicesDir, { recursive: true });
}
/**
* Build service info for this service
*/
private buildServiceInfo(status: ServiceInfo['status']): ServiceInfo {
return {
name: this.config.serviceName,
hostname: this.currentHostname,
baseUrl: this.config.apiUrl,
status,
lastHeartbeat: new Date().toISOString()
};
}
/**
* Register this service by writing service info to the shared filesystem.
* meta-core's cleanup process monitors these files and removes stale entries.
*/
async register(): Promise<void> {
await this.ensureServicesDir();
const info = this.buildServiceInfo('running');
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
console.log(`[ServiceRegistration] Registered ${this.config.serviceName}-${this.currentHostname}`);
}
/**
* Update service status
*/
async updateStatus(status: ServiceInfo['status']): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.status = status;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
console.error('[ServiceRegistration] Failed to update status:', error);
}
}
/**
* Send heartbeat (update lastHeartbeat timestamp)
*/
async heartbeat(): Promise<void> {
try {
const content = await fs.readFile(this.serviceFilePath, 'utf-8');
const info = JSON.parse(content) as ServiceInfo;
info.lastHeartbeat = new Date().toISOString();
await fs.writeFile(this.serviceFilePath, JSON.stringify(info, null, 2));
} catch (error) {
// If file was deleted, re-register
if (isNodeError(error) && error.code === 'ENOENT') {
await this.register();
} else {
console.error('[ServiceRegistration] Heartbeat failed:', error);
}
}
}
/**
* Start heartbeat loop
*/
startHeartbeat(): void {
this.heartbeatTimer = setInterval(async () => {
if (this.isShuttingDown) return;
await this.heartbeat();
}, this.config.heartbeatInterval);
}
/**
* Stop heartbeat loop
*/
stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* Unregister this service (on shutdown)
*/
async unregister(): Promise<void> {
this.isShuttingDown = true;
this.stopHeartbeat();
try {
// Remove the service file
await fs.unlink(this.serviceFilePath);
console.log(`[ServiceRegistration] Unregistered ${this.config.serviceName}-${this.currentHostname}`);
} catch (error) {
if (!isNodeError(error) || error.code !== 'ENOENT') {
console.error('[ServiceRegistration] Failed to unregister:', error);
}
}
}
// ========================================================================
// Lifecycle
// ========================================================================
/**
* Full startup sequence: register service and start heartbeat loop.
*/
async start(): Promise<void> {
await this.register();
this.startHeartbeat();
}
/**
* Full shutdown sequence
*/
async stop(): Promise<void> {
await this.unregister();
}
// ========================================================================
// Getters
// ========================================================================
getServiceFilePath(): string {
return this.serviceFilePath;
}
getServicesDir(): string {
return this.servicesDir;
}
}
+2 -2
View File
@@ -5,7 +5,7 @@
* - IKVClient interface for KV storage abstraction * - IKVClient interface for KV storage abstraction
* - RedisKVClient for Redis-based storage * - RedisKVClient for Redis-based storage
* - LeaderClient for reading leader info from meta-core * - LeaderClient for reading leader info from meta-core
* - ServiceDiscovery for service registration and discovery * - ServiceRegistration for service registration (discovery is centralized in meta-core)
* - KVManager for unified management of all components * - KVManager for unified management of all components
* - MetadataUtils for flattening/reconstructing nested metadata * - MetadataUtils for flattening/reconstructing nested metadata
*/ */
@@ -13,6 +13,6 @@
export * from './IKVClient.js'; export * from './IKVClient.js';
export * from './RedisClient.js'; export * from './RedisClient.js';
export * from './LeaderClient.js'; export * from './LeaderClient.js';
export * from './ServiceDiscovery.js'; export * from './ServiceRegistration.js';
export * from './KVManager.js'; export * from './KVManager.js';
export * from './MetadataUtils.js'; export * from './MetadataUtils.js';
@@ -2,12 +2,10 @@ import { useState, useEffect } from 'react';
interface ServiceInfo { interface ServiceInfo {
name: string; name: string;
url: string; hostname: string;
api: string; baseUrl: string;
status: string; status: string;
capabilities: string[]; lastHeartbeat: string;
version: string;
role?: string; // "leader", "follower", or undefined (for non-meta-core services)
} }
interface ServicesResponse { interface ServicesResponse {
@@ -19,6 +17,8 @@ const serviceIcons: Record<string, string> = {
'meta-sort': '📁', 'meta-sort': '📁',
'meta-fuse': '🗂️', 'meta-fuse': '🗂️',
'meta-stremio': '🎬', 'meta-stremio': '🎬',
'meta-core': '⚙️',
'meta-dup': '🔍',
'meta-orbit': '🌐', 'meta-orbit': '🌐',
'default': '📦' 'default': '📦'
}; };
@@ -56,11 +56,7 @@ function ServiceNav() {
if (loading) return null; if (loading) return null;
// Filter out follower meta-core instances (only show leader) const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
const filteredServices = services.filter(
s => s.name !== 'meta-core' || s.role === 'leader'
);
const sortedServices = [...filteredServices].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null; if (sortedServices.length === 0) return null;
@@ -74,12 +70,12 @@ function ServiceNav() {
return ( return (
<a <a
key={service.name} key={service.name}
href={isActive ? '#' : service.url} href={isActive ? '#' : service.baseUrl}
className={`service-link${isActive ? ' active' : ''}`} className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
if (!isActive) { if (!isActive) {
window.location.href = service.url; window.location.href = service.baseUrl;
} }
}} }}
> >