diff --git a/web/src/hq-client.ts b/web/src/hq-client.ts index 23a95b67..b9cfd1d0 100644 --- a/web/src/hq-client.ts +++ b/web/src/hq-client.ts @@ -5,26 +5,18 @@ export class HQClient { private readonly hqUrl: string; private readonly remoteId: string; private readonly remoteName: string; - private token: string; - private heartbeatInterval: NodeJS.Timeout | null = null; + private readonly token: string; + private readonly password: string; private registrationRetryTimeout: NodeJS.Timeout | null = null; constructor(hqUrl: string, password: string) { this.hqUrl = hqUrl; this.remoteId = uuidv4(); this.remoteName = `${os.hostname()}-${process.pid}`; - this.token = this.generateToken(); - - // Store password for future use + this.token = uuidv4(); this.password = password; } - private password: string; - - private generateToken(): string { - return uuidv4(); - } - async register(): Promise { try { const response = await fetch(`${this.hqUrl}/api/remotes/register`, { @@ -46,7 +38,8 @@ export class HQClient { } console.log(`Successfully registered with HQ at ${this.hqUrl}`); - this.startHeartbeat(); + console.log(`Remote ID: ${this.remoteId}`); + console.log(`Token: ${this.token}`); } catch (error) { console.error('Failed to register with HQ:', error); // Retry registration after 5 seconds @@ -54,70 +47,7 @@ export class HQClient { } } - private async sendHeartbeat(): Promise { - try { - // Get session count from the session manager - const sessionCount = await this.getSessionCount(); - - const response = await fetch(`${this.hqUrl}/api/remotes/${this.remoteId}/heartbeat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.token}`, - }, - body: JSON.stringify({ - sessionCount, - }), - }); - - if (!response.ok) { - console.error('Heartbeat failed:', response.statusText); - // Re-register if heartbeat fails - this.stopHeartbeat(); - await this.register(); - } - } catch (error) { - console.error('Failed to send heartbeat:', error); - } - } - - private startHeartbeat(): void { - // Send heartbeat every 15 seconds - this.heartbeatInterval = setInterval(() => { - this.sendHeartbeat(); - }, 15000); - - // Send first heartbeat immediately - this.sendHeartbeat(); - } - - private stopHeartbeat(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - } - - private async getSessionCount(): Promise { - try { - const response = await fetch(`http://localhost:${process.env.PORT || 4020}/api/sessions`, { - headers: { - Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`, - }, - }); - - if (response.ok) { - const sessions = await response.json(); - return Array.isArray(sessions) ? sessions.length : 0; - } - } catch { - // Ignore errors - } - return 0; - } - destroy(): void { - this.stopHeartbeat(); if (this.registrationRetryTimeout) { clearTimeout(this.registrationRetryTimeout); } diff --git a/web/src/remote-registry.ts b/web/src/remote-registry.ts index b7adeffc..0c1b67b7 100644 --- a/web/src/remote-registry.ts +++ b/web/src/remote-registry.ts @@ -11,11 +11,14 @@ export interface RemoteServer { export class RemoteRegistry { private remotes: Map = new Map(); - private heartbeatInterval: NodeJS.Timeout | null = null; - private readonly HEARTBEAT_TIMEOUT = 30000; // 30 seconds + private healthCheckInterval: NodeJS.Timeout | null = null; + private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds + private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check + private password: string | null; - constructor() { - this.startHeartbeatChecker(); + constructor(password: string | null = null) { + this.password = password; + this.startHealthChecker(); } register(remote: Omit): RemoteServer { @@ -30,6 +33,9 @@ export class RemoteRegistry { this.remotes.set(remote.id, registeredRemote); console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`); + // Immediately check health of new remote + this.checkRemoteHealth(registeredRemote); + return registeredRemote; } @@ -42,17 +48,6 @@ export class RemoteRegistry { return false; } - updateHeartbeat(remoteId: string, sessionCount: number): boolean { - const remote = this.remotes.get(remoteId); - if (remote) { - remote.lastHeartbeat = new Date(); - remote.sessionCount = sessionCount; - remote.status = 'online'; - return true; - } - return false; - } - getRemote(remoteId: string): RemoteServer | undefined { return this.remotes.get(remoteId); } @@ -69,24 +64,60 @@ export class RemoteRegistry { return this.getAllRemotes().filter((r) => r.status === 'online'); } - private startHeartbeatChecker() { - this.heartbeatInterval = setInterval(() => { - const now = Date.now(); + private async checkRemoteHealth(remote: RemoteServer): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.HEALTH_CHECK_TIMEOUT); - for (const remote of this.remotes.values()) { - const timeSinceLastHeartbeat = now - remote.lastHeartbeat.getTime(); - - if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT && remote.status === 'online') { - remote.status = 'offline'; - console.log(`Remote went offline: ${remote.name} (${remote.id})`); - } + const headers: Record = {}; + if (this.password) { + headers['Authorization'] = + `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`; } - }, 10000); // Check every 10 seconds + + const response = await fetch(`${remote.url}/api/sessions`, { + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const sessions = await response.json(); + remote.lastHeartbeat = new Date(); + remote.sessionCount = Array.isArray(sessions) ? sessions.length : 0; + + if (remote.status !== 'online') { + remote.status = 'online'; + console.log(`Remote came online: ${remote.name} (${remote.id})`); + } + } else { + throw new Error(`HTTP ${response.status}`); + } + } catch (error) { + if (remote.status !== 'offline') { + remote.status = 'offline'; + console.log(`Remote went offline: ${remote.name} (${remote.id}) - ${error}`); + } + } + } + + private startHealthChecker() { + this.healthCheckInterval = setInterval(() => { + // Check all remotes in parallel + const healthChecks = Array.from(this.remotes.values()).map((remote) => + this.checkRemoteHealth(remote) + ); + + Promise.all(healthChecks).catch((err) => { + console.error('Error in health checks:', err); + }); + }, this.HEALTH_CHECK_INTERVAL); } destroy() { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); + if (this.healthCheckInterval) { + clearInterval(this.healthCheckInterval); } } } diff --git a/web/src/server.ts b/web/src/server.ts index 18719607..cb612213 100644 --- a/web/src/server.ts +++ b/web/src/server.ts @@ -114,7 +114,7 @@ let remoteRegistry: RemoteRegistry | null = null; let hqClient: HQClient | null = null; if (isHQMode) { - remoteRegistry = new RemoteRegistry(); + remoteRegistry = new RemoteRegistry(basicAuthPassword); console.log(`${GREEN}Running in HQ mode${RESET}`); } @@ -448,40 +448,6 @@ app.post('/api/remotes/register', (req, res) => { res.json({ success: true, remote: { id: remote.id, name: remote.name } }); }); -// Heartbeat from remote server -app.post('/api/remotes/:remoteId/heartbeat', (req, res) => { - if (!isHQMode || !remoteRegistry) { - return res.status(404).json({ error: 'HQ mode not enabled' }); - } - - const { remoteId } = req.params; - const { sessionCount } = req.body; - const authHeader = req.headers.authorization; - - // Verify token - const remote = remoteRegistry.getRemote(remoteId); - if (!remote) { - return res.status(404).json({ error: 'Remote not found' }); - } - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ error: 'Missing authorization' }); - } - - const token = authHeader.slice(7); - if (token !== remote.token) { - return res.status(401).json({ error: 'Invalid token' }); - } - - const success = remoteRegistry.updateHeartbeat(remoteId, sessionCount || 0); - - if (success) { - res.json({ success: true }); - } else { - res.status(404).json({ error: 'Remote not found' }); - } -}); - // Unregister remote app.delete('/api/remotes/:remoteId', (req, res) => { if (!isHQMode || !remoteRegistry) {