From 9e683eba22b699281bca3585e5b474d1fe656f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:43:41 +0100 Subject: [PATCH] Replace polling with real-time SSE stream and eliminate Python API dependency - Add single /api/events SSE endpoint replacing 5 separate polling intervals - Query Prometheus directly for system stats (replaces Python API on port 9876) - Query Coolify PostgreSQL directly for deployments (replaces SSH/tinker approach) - Add EventManager singleton for server-side polling + client broadcast - Add useEventStream hook with exponential backoff reconnection - Add live deployment log streaming via SSE for in-progress builds - Add redeploy button and live duration counter in deployments table - Add SSE connection indicator in header (green=live, red=offline) - Externalize all hardcoded 192.168.1.3 references to env vars via config.ts - Reduce API route code by ~400 lines through shared library modules Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 160 ++++++++++++++++ package.json | 2 + src/app/api/deployments/[uuid]/route.ts | 97 +--------- src/app/api/deployments/route.ts | 140 +------------- src/app/api/discover/route.ts | 95 +--------- src/app/api/events/route.ts | 41 ++++ src/app/api/health/route.ts | 17 +- src/app/api/metrics/route.ts | 74 +------- src/app/api/stats/route.ts | 39 +--- src/app/page.tsx | 33 ++-- src/components/DeploymentLogs.tsx | 114 ++++------- src/components/DeploymentsTable.tsx | 98 +++++----- src/components/Header.tsx | 30 +-- src/components/SystemTrends.tsx | 58 ++---- src/lib/PortalContext.tsx | 208 ++++++-------------- src/lib/coolify-db.ts | 128 +++++++++++++ src/lib/event-manager.ts | 241 ++++++++++++++++++++++++ src/lib/prometheus.ts | 136 +++++++++++++ src/lib/services.ts | 36 ++-- src/lib/useEventStream.ts | 112 +++++++++++ 20 files changed, 1064 insertions(+), 795 deletions(-) create mode 100644 src/app/api/events/route.ts create mode 100644 src/lib/coolify-db.ts create mode 100644 src/lib/event-manager.ts create mode 100644 src/lib/prometheus.ts create mode 100644 src/lib/useEventStream.ts diff --git a/package-lock.json b/package-lock.json index 0b90ee7..64b4540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-table": "^8.21.3", "next": "16.1.6", + "pg": "^8.18.0", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0" @@ -17,6 +18,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -1701,6 +1703,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", @@ -5590,6 +5604,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5648,6 +5751,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6208,6 +6350,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6924,6 +7075,15 @@ "node": ">=0.10.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 212b49c..9182762 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dependencies": { "@tanstack/react-table": "^8.21.3", "next": "16.1.6", + "pg": "^8.18.0", "react": "19.2.3", "react-dom": "19.2.3", "recharts": "^3.7.0" @@ -19,6 +20,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.16.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/src/app/api/deployments/[uuid]/route.ts b/src/app/api/deployments/[uuid]/route.ts index ff5cc25..7e7561c 100644 --- a/src/app/api/deployments/[uuid]/route.ts +++ b/src/app/api/deployments/[uuid]/route.ts @@ -1,104 +1,19 @@ import { NextResponse } from 'next/server'; - -const IS_PRODUCTION = process.env.NODE_ENV === 'production'; -// Internal API endpoint for production (served by Python script on NUC host) -const DEPLOYMENTS_API_URL = 'http://192.168.1.3:9876/deployments'; +import { fetchDeploymentDetail } from '@/lib/coolify-db'; export async function GET( - request: Request, + _request: Request, { params }: { params: Promise<{ uuid: string }> } ) { const { uuid } = await params; try { - let deployment: Record; - - if (IS_PRODUCTION) { - // In production, use internal HTTP API served by coolify-api.py on NUC host - const response = await fetch(`${DEPLOYMENTS_API_URL}/${uuid}`, { - cache: 'no-store', - signal: AbortSignal.timeout(30000), - }); - - if (!response.ok) { - throw new Error(`Deployments API error: ${response.status}`); - } - - deployment = await response.json(); - } else { - // In development, use SSH to call docker exec on NUC - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - - // PHP code to fetch single deployment with logs - const phpCode = ` -$d = \\App\\Models\\ApplicationDeploymentQueue::with('application') - ->where('deployment_uuid', '${uuid}') - ->first(); - -if (!$d) { - echo json_encode(['error' => 'Not found']); - exit; -} - -echo json_encode([ - 'deployment_uuid' => $d->deployment_uuid, - 'application_uuid' => $d->application?->uuid ?? 'unknown', - 'application_name' => $d->application?->name ?? 'Unknown', - 'status' => $d->status, - 'created_at' => $d->created_at->toIso8601String(), - 'updated_at' => $d->updated_at->toIso8601String(), - 'git_branch' => $d->application?->git_branch ?? 'main', - 'git_commit_sha' => $d->commit ?? null, - 'commit_message' => $d->commit_message ?? null, - 'is_webhook' => $d->is_webhook ?? false, - 'logs' => $d->logs ?? null, -]); -`; - - const base64Code = Buffer.from(phpCode).toString('base64'); - const command = `ssh nuc "echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker"`; - - const { stdout } = await execAsync(command, { - maxBuffer: 10 * 1024 * 1024, - timeout: 30000, - }); - - // Parse output - find JSON object in tinker output - const lines = stdout.split('\n'); - let jsonStr = ''; - - for (const line of lines) { - let cleaned = line; - if (cleaned.startsWith('. ')) { - cleaned = cleaned.substring(2); - } else if (cleaned.startsWith('> ')) { - continue; - } - - const trimmed = cleaned.trim(); - if (trimmed.startsWith('{')) { - jsonStr = trimmed; - break; - } - } - - if (!jsonStr) { - throw new Error('No JSON output found'); - } - - deployment = JSON.parse(jsonStr); + const deployment = await fetchDeploymentDetail(uuid); + if (!deployment) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); } - - if (deployment.error) { - return NextResponse.json({ error: deployment.error }, { status: 404 }); - } - return NextResponse.json(deployment, { - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - }, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, }); } catch (error) { console.error('Error fetching deployment:', error); diff --git a/src/app/api/deployments/route.ts b/src/app/api/deployments/route.ts index 1e4fcd9..665ebc0 100644 --- a/src/app/api/deployments/route.ts +++ b/src/app/api/deployments/route.ts @@ -1,145 +1,11 @@ import { NextResponse } from 'next/server'; -import type { Deployment, DeploymentStatus } from '@/lib/deployments'; - -const IS_PRODUCTION = process.env.NODE_ENV === 'production'; -// Internal API endpoint for production (served by Python script on NUC host) -const DEPLOYMENTS_API_URL = 'http://192.168.1.3:9876/deployments'; - -async function fetchDeploymentsFromCoolify(): Promise { - let rawDeployments: Array>; - - if (IS_PRODUCTION) { - // In production, use internal HTTP API served by coolify-api.py on NUC host - const response = await fetch(DEPLOYMENTS_API_URL, { - cache: 'no-store', - signal: AbortSignal.timeout(30000), - }); - - if (!response.ok) { - throw new Error(`Deployments API error: ${response.status}`); - } - - rawDeployments = await response.json(); - } else { - // In development, use SSH to call docker exec on NUC - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - - const phpCode = ` -$deployments = \\App\\Models\\ApplicationDeploymentQueue::with('application') - ->orderBy('created_at', 'desc') - ->limit(50) - ->get(); - -$result = $deployments->map(function($d) { - return [ - 'deployment_uuid' => $d->deployment_uuid, - 'application_uuid' => $d->application?->uuid ?? 'unknown', - 'application_name' => $d->application?->name ?? 'Unknown', - 'status' => $d->status, - 'created_at' => $d->created_at->toIso8601String(), - 'updated_at' => $d->updated_at->toIso8601String(), - 'git_branch' => $d->application?->git_branch ?? 'main', - 'git_commit_sha' => $d->commit ?? null, - 'commit_message' => $d->commit_message ?? null, - 'is_webhook' => $d->is_webhook ?? false, - ]; -}); - -echo json_encode($result->toArray()); -`; - - const base64Code = Buffer.from(phpCode).toString('base64'); - const command = `ssh nuc "echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker"`; - - const { stdout } = await execAsync(command, { - maxBuffer: 10 * 1024 * 1024, - timeout: 30000, - }); - - // Parse tinker output to find JSON - const lines = stdout.split('\n'); - let jsonStr = ''; - - for (const line of lines) { - let cleaned = line; - if (cleaned.startsWith('. ')) { - cleaned = cleaned.substring(2); - } else if (cleaned.startsWith('> ')) { - continue; - } - - const trimmed = cleaned.trim(); - if (trimmed.startsWith('[{') || trimmed.startsWith('[{"') || trimmed === '[]') { - jsonStr = trimmed; - break; - } - } - - if (!jsonStr) { - console.error('Raw output:', stdout.substring(0, 1000)); - throw new Error('No JSON output found in tinker response'); - } - - rawDeployments = JSON.parse(jsonStr); - } - - // Track latest deployment per application for "Current" badge - const latestByApp = new Map(); - - // First pass: find latest finished deployment per app - for (const d of rawDeployments) { - const appUuid = d.application_uuid as string; - const deployUuid = d.deployment_uuid as string; - if (d.status === 'finished' && !latestByApp.has(appUuid)) { - latestByApp.set(appUuid, deployUuid); - } - } - - // Transform to our Deployment type - const deployments: Deployment[] = rawDeployments.map((d: Record) => { - // Calculate duration - let duration: number | undefined; - if (d.created_at && d.updated_at) { - const start = new Date(d.created_at as string).getTime(); - const end = new Date(d.updated_at as string).getTime(); - duration = Math.floor((end - start) / 1000); - } - - // Map Coolify statuses to our enum - let status: DeploymentStatus = d.status as DeploymentStatus; - if ((status as string) === 'failed') status = 'error'; - if ((status as string) === 'cancelled-by-user') status = 'cancelled'; - - return { - deployment_uuid: d.deployment_uuid as string, - application_uuid: d.application_uuid as string, - application_name: (d.application_name as string) || 'Unknown App', - application_fqdn: d.application_fqdn as string | undefined, - status, - created_at: d.created_at as string, - updated_at: d.updated_at as string, - git_branch: (d.git_branch as string) || 'main', - git_commit_sha: d.git_commit_sha as string | undefined, - commit_message: d.commit_message as string | undefined, - is_webhook: d.is_webhook as boolean | undefined, - duration, - is_current: latestByApp.get(d.application_uuid as string) === d.deployment_uuid, - }; - }); - - return deployments; -} +import { fetchDeployments } from '@/lib/coolify-db'; export async function GET() { try { - const deployments = await fetchDeploymentsFromCoolify(); - + const deployments = await fetchDeployments(); return NextResponse.json(deployments, { - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - }, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, }); } catch (error) { console.error('Error fetching deployments:', error); diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts index 355064d..01b91f8 100644 --- a/src/app/api/discover/route.ts +++ b/src/app/api/discover/route.ts @@ -1,48 +1,10 @@ import { NextResponse } from 'next/server'; import type { DiscoveredService, ServiceCategory } from '@/lib/services'; import { lookupService, lookupDatabase } from '@/lib/service-registry'; +import { fetchResources, fetchAppDetail, fetchServiceDetail } from '@/lib/coolify'; +import { serverConfig } from '@/lib/config'; -const COOLIFY_API = 'http://192.168.1.3:8000/api/v1'; -const COOLIFY_TOKEN = process.env.COOLIFY_API_TOKEN || ''; -const SERVER_UUID = 'qk84w0goo4w48g4ggsoo0oss'; -const NUC_HOST = '192.168.1.3'; - -interface CoolifyResource { - id: number; - uuid: string; - name: string; - type: string; - status: string; - created_at: string; - updated_at: string; -} - -interface CoolifyAppDetail { - uuid: string; - name: string; - fqdn: string | null; - ports_exposes: string | null; - ports_mappings: string | null; - status: string; - description: string | null; -} - -interface CoolifyServiceDetail { - uuid: string; - name: string; - applications?: Array<{ - name: string; - human_name: string | null; - fqdn: string | null; - ports: string | null; - status: string; - image: string | null; - }>; - databases?: Array<{ - name: string; - status: string; - }>; -} +const { nucHost } = serverConfig; function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' { if (status.startsWith('running')) return 'running'; @@ -51,80 +13,52 @@ function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' { } function extractPort(fqdn: string | null, portsExposes: string | null, portsMappings: string | null): number { - // Try to extract port from FQDN (e.g., http://service.nuc.lan:3000) if (fqdn) { try { const url = new URL(fqdn); if (url.port) return parseInt(url.port, 10); } catch { /* ignore */ } } - - // Try ports_mappings first (host:container format like "3030:3000") if (portsMappings) { const first = portsMappings.split(',')[0].trim(); const hostPort = first.split(':')[0]; if (hostPort) return parseInt(hostPort, 10); } - - // Fall back to ports_exposes (container port) if (portsExposes) { const first = portsExposes.split(',')[0].trim(); return parseInt(first, 10); } - return 0; } function extractPortFromServicePorts(ports: string | null): number { if (!ports) return 0; - // Service ports can be "22222:22" or "3030:3000" const first = ports.split(',')[0].trim(); const parts = first.split(':'); return parseInt(parts[0], 10) || 0; } function cleanServiceName(name: string): string { - // Remove Coolify-style suffixes like "-ho0cwgcwos88cwc48g84c0g8" return name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, ''); } function buildUrl(fqdn: string | null, port: number): string { if (fqdn) { - // Use the FQDN as-is if it looks like a proper URL try { const url = new URL(fqdn); - // If it's an sslip.io address, replace with nuc.lan if (url.hostname.includes('sslip.io')) { - return `http://${NUC_HOST}:${port || url.port || 80}`; + return `http://${nucHost}:${port || url.port || 80}`; } return fqdn; } catch { /* fall through */ } } - if (port > 0) { - return `http://${NUC_HOST}:${port}`; - } - return `http://${NUC_HOST}`; -} - -async function fetchJson(url: string): Promise { - try { - const res = await fetch(url, { - headers: { Authorization: `Bearer ${COOLIFY_TOKEN}`, Accept: 'application/json' }, - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) return null; - return await res.json() as T; - } catch { - return null; - } + if (port > 0) return `http://${nucHost}:${port}`; + return `http://${nucHost}`; } export async function GET() { try { - // Step 1: Get all resources from the server - const resources = await fetchJson( - `${COOLIFY_API}/servers/${SERVER_UUID}/resources` - ); + const resources = await fetchResources(); if (!resources) { return NextResponse.json( @@ -133,14 +67,9 @@ export async function GET() { ); } - // Step 2: Fetch details for each resource type in parallel const detailPromises = resources.map(async (resource): Promise => { - const healthStatus = mapCoolifyStatus(resource.status); - if (resource.type === 'application') { - const detail = await fetchJson( - `${COOLIFY_API}/applications/${resource.uuid}` - ); + const detail = await fetchAppDetail(resource.uuid); if (!detail) return null; const port = extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings); @@ -163,12 +92,9 @@ export async function GET() { } if (resource.type === 'service') { - const detail = await fetchJson( - `${COOLIFY_API}/services/${resource.uuid}` - ); + const detail = await fetchServiceDetail(resource.uuid); if (!detail) return null; - // A Coolify "service" can contain multiple applications. Use the primary one. const app = detail.applications?.[0]; const cleanName = cleanServiceName(resource.name); const meta = lookupService(cleanName); @@ -202,7 +128,7 @@ export async function GET() { const meta = lookupDatabase(resource.type, resource.name); return { name: resource.name, - url: `http://${NUC_HOST}:8000`, + url: `http://${nucHost}:8000`, port: 0, icon: meta.icon, category: meta.category as ServiceCategory, @@ -226,7 +152,6 @@ export async function GET() { } } - // Sort: running first, then by name discovered.sort((a, b) => { const aRunning = a.coolifyStatus.startsWith('running') ? 0 : 1; const bRunning = b.coolifyStatus.startsWith('running') ? 0 : 1; diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..5243a88 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,41 @@ +import { eventManager } from '@/lib/event-manager'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request) { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const client = { + write: (data: string) => { + try { + controller.enqueue(encoder.encode(data)); + } catch { + eventManager.removeClient(client); + } + }, + close: () => { + try { controller.close(); } catch { /* already closed */ } + }, + }; + + eventManager.addClient(client); + + // Clean up on disconnect + request.signal.addEventListener('abort', () => { + eventManager.removeClient(client); + client.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 68c7fa0..9837551 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,22 +1,18 @@ import { NextResponse } from 'next/server'; import { services } from '@/lib/services'; +import { serverConfig } from '@/lib/config'; -const NUC_HOST = '192.168.1.3'; +const { nucHost } = serverConfig; -// Check if a service is reachable by attempting a TCP connection async function checkServiceHealth(port: number, timeout = 3000): Promise<'running' | 'stopped'> { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); - - const response = await fetch(`http://${NUC_HOST}:${port}`, { + const response = await fetch(`http://${nucHost}:${port}`, { method: 'HEAD', signal: controller.signal, }).catch(() => null); - clearTimeout(timeoutId); - - // If we get any response (even 404, 403, etc.), the service is running return response ? 'running' : 'stopped'; } catch { return 'stopped'; @@ -26,7 +22,6 @@ async function checkServiceHealth(port: number, timeout = 3000): Promise<'runnin export async function GET() { const healthStatus: Record = {}; - // Check all services in parallel const results = await Promise.allSettled( services.map(async (service) => { const status = await checkServiceHealth(service.port); @@ -34,19 +29,15 @@ export async function GET() { }) ); - // Process results results.forEach((result) => { if (result.status === 'fulfilled') { healthStatus[result.value.name] = result.value.status; } else { - // If promise rejected, mark as unknown healthStatus[(result.reason as { name: string })?.name] = 'unknown'; } }); return NextResponse.json(healthStatus, { - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - }, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, }); } diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts index 321b57e..61a2f5e 100644 --- a/src/app/api/metrics/route.ts +++ b/src/app/api/metrics/route.ts @@ -1,76 +1,12 @@ import { NextResponse } from 'next/server'; - -const PROMETHEUS_URL = 'http://192.168.1.3:9091'; -const INSTANCE = '192.168.1.3:9100'; -const NIC = 'eno1'; - -interface PrometheusResult { - data: { - result: Array<{ - values: Array<[number, string]>; - }>; - }; -} - -async function queryRange(query: string, start: number, end: number, step: number): Promise> { - const params = new URLSearchParams({ - query, - start: String(start), - end: String(end), - step: String(step), - }); - - const res = await fetch(`${PROMETHEUS_URL}/api/v1/query_range?${params}`, { - signal: AbortSignal.timeout(5000), - }); - - if (!res.ok) return []; - - const data: PrometheusResult = await res.json(); - const result = data.data?.result?.[0]; - if (!result) return []; - - return result.values.map(([ts, val]) => [ts, parseFloat(val)]); -} +import { fetchRangeMetrics } from '@/lib/prometheus'; export async function GET() { try { - const end = Math.floor(Date.now() / 1000); - const start = end - 6 * 3600; // 6 hours - const step = 120; // 2-minute resolution - - const [cpu, ram, netRx, netTx, temp] = await Promise.all([ - // CPU usage percent - queryRange( - `100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${INSTANCE}"}[2m])) * 100)`, - start, end, step - ), - // RAM usage percent - queryRange( - `(1 - node_memory_MemAvailable_bytes{instance="${INSTANCE}"} / node_memory_MemTotal_bytes{instance="${INSTANCE}"}) * 100`, - start, end, step - ), - // Network receive bytes/sec - queryRange( - `rate(node_network_receive_bytes_total{instance="${INSTANCE}",device="${NIC}"}[2m])`, - start, end, step - ), - // Network transmit bytes/sec - queryRange( - `rate(node_network_transmit_bytes_total{instance="${INSTANCE}",device="${NIC}"}[2m])`, - start, end, step - ), - // CPU temperature (max across cores) - queryRange( - `max(node_hwmon_temp_celsius{instance="${INSTANCE}",chip="platform_coretemp_0"})`, - start, end, step - ), - ]); - - return NextResponse.json( - { cpu, ram, netRx, netTx, temp }, - { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } } - ); + const metrics = await fetchRangeMetrics(); + return NextResponse.json(metrics, { + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, + }); } catch (error) { console.error('Metrics error:', error); return NextResponse.json( diff --git a/src/app/api/stats/route.ts b/src/app/api/stats/route.ts index 655be8e..bb7fb37 100644 --- a/src/app/api/stats/route.ts +++ b/src/app/api/stats/route.ts @@ -1,44 +1,11 @@ import { NextResponse } from 'next/server'; -import type { SystemStats } from '@/lib/stats'; - -const IS_PRODUCTION = process.env.NODE_ENV === 'production'; -const STATS_API_URL = 'http://192.168.1.3:9876/stats'; - -async function fetchStats(): Promise { - if (IS_PRODUCTION) { - const response = await fetch(STATS_API_URL, { - cache: 'no-store', - signal: AbortSignal.timeout(5000), - }); - - if (!response.ok) { - throw new Error(`Stats API error: ${response.status}`); - } - - return await response.json(); - } else { - // Development: use SSH to read /proc on NUC - const { exec } = await import('child_process'); - const { promisify } = await import('util'); - const execAsync = promisify(exec); - - const { stdout } = await execAsync( - 'ssh nuc "curl -s http://localhost:9876/stats"', - { timeout: 10000 } - ); - - return JSON.parse(stdout.trim()); - } -} +import { fetchInstantStats } from '@/lib/prometheus'; export async function GET() { try { - const stats = await fetchStats(); - + const stats = await fetchInstantStats(); return NextResponse.json(stats, { - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - }, + headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' }, }); } catch (error) { console.error('Error fetching stats:', error); diff --git a/src/app/page.tsx b/src/app/page.tsx index ffa9dbd..5f38230 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,6 +2,7 @@ import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable, OverviewTab } from '@/components'; import { usePortal } from '@/lib/PortalContext'; +import { clientConfig } from '@/lib/config'; import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services'; type TabId = 'overview' | 'services' | 'bookmarks' | 'ai' | 'deployments' | 'settings'; @@ -44,6 +45,8 @@ export default function Home() { discoveredServices, discoveryLoading, discoveryError, + triggerDeploy, + connected, } = usePortal(); // Group services by category @@ -80,12 +83,10 @@ export default function Home() { case 'services': return ( <> - {/* Search */}
- {/* Status summary */}
@@ -110,7 +111,6 @@ export default function Home() { )}
- {/* No results message */} {noResults && (

@@ -119,7 +119,6 @@ export default function Home() {

)} - {/* Services */} {hasServices && (
{Object.entries(servicesByCategory).map(([category, services]) => ( @@ -146,12 +145,10 @@ export default function Home() { case 'bookmarks': return ( <> - {/* Search */}
- {/* No results message */} {searchQuery && !hasBookmarks && (

@@ -160,7 +157,6 @@ export default function Home() {

)} - {/* Bookmarks */} {hasBookmarks && (
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => ( @@ -228,12 +224,19 @@ export default function Home() {

All deployments across Coolify applications + {connected && ( + + + Live + + )}

); @@ -246,7 +249,6 @@ export default function Home() { Appearance - {/* Dark Mode Toggle */}

Dark Mode

@@ -279,7 +281,7 @@ export default function Home() {
Server IP - 192.168.1.3 + {clientConfig.nucHost}
Services @@ -291,6 +293,12 @@ export default function Home() { {isDiscovered ? 'Coolify API' : 'Static'}
+
+ Connection + + {connected ? 'SSE Connected' : 'Disconnected'} + +
Bookmarks {filteredBookmarks.length} @@ -299,7 +307,7 @@ export default function Home() { ); diff --git a/src/components/DeploymentLogs.tsx b/src/components/DeploymentLogs.tsx index 17e317e..1cc3423 100644 --- a/src/components/DeploymentLogs.tsx +++ b/src/components/DeploymentLogs.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments'; +import { usePortal } from '@/lib/PortalContext'; import { Icon } from './Icons'; interface DeploymentLogsProps { @@ -10,10 +11,8 @@ interface DeploymentLogsProps { initialLogs?: string; } -// Color log lines based on content function getLogLineStyle(log: DeploymentLog): string { const output = log.output.toLowerCase(); - if (log.type === 'stderr' || output.includes('error') || output.includes('failed')) { return 'text-red-500 dark:text-red-400'; } @@ -26,7 +25,6 @@ function getLogLineStyle(log: DeploymentLog): string { if (output.startsWith('---') || output.startsWith('===') || output.startsWith('###')) { return 'text-cyan-600 dark:text-cyan-400 font-semibold'; } - // Commands often start with $ or > if (output.startsWith('$') || output.startsWith('>') || output.startsWith('#')) { return 'text-purple-600 dark:text-purple-400'; } @@ -34,55 +32,50 @@ function getLogLineStyle(log: DeploymentLog): string { } export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) { + const { activeDeployLogs } = usePortal(); const [logs, setLogs] = useState(() => parseDeploymentLogs(initialLogs)); - const [isPolling, setIsPolling] = useState(status === 'in_progress' || status === 'queued'); + const isActive = status === 'in_progress' || status === 'queued'; const [copied, setCopied] = useState(false); const [isExpanded, setIsExpanded] = useState(false); - const logsEndRef = useRef(null); const containerRef = useRef(null); + const logsEndRef = useRef(null); const [autoScroll, setAutoScroll] = useState(true); + // Update logs from SSE active deploy logs + useEffect(() => { + if (!isActive) return; + const match = activeDeployLogs.find(l => l.uuid === deploymentUuid); + if (match?.logs) { + setLogs(parseDeploymentLogs(match.logs)); + } + }, [activeDeployLogs, deploymentUuid, isActive]); + + // Fetch logs on first render if none provided const fetchLogs = useCallback(async () => { try { const response = await fetch(`/api/deployments/${deploymentUuid}`); if (response.ok) { const data = await response.json(); - const parsedLogs = parseDeploymentLogs(data.logs); - setLogs(parsedLogs); - - // Stop polling if deployment finished - if (data.status !== 'in_progress' && data.status !== 'queued') { - setIsPolling(false); - } + setLogs(parseDeploymentLogs(data.logs)); } } catch (error) { console.error('Failed to fetch logs:', error); } }, [deploymentUuid]); - // Poll for logs while deployment is in progress useEffect(() => { - if (!isPolling) return; - - const interval = setInterval(fetchLogs, 2000); - return () => clearInterval(interval); - }, [isPolling, fetchLogs]); - - // Initial fetch if no logs provided - useEffect(() => { - if (!initialLogs) { + if (!initialLogs && !isActive) { fetchLogs(); } - }, [initialLogs, fetchLogs]); + }, [initialLogs, isActive, fetchLogs]); - // Auto-scroll to bottom when new logs arrive (within container only) + // Auto-scroll useEffect(() => { if (autoScroll && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight; } }, [logs, autoScroll]); - // Detect manual scroll to disable auto-scroll const handleScroll = () => { if (!containerRef.current) return; const { scrollTop, scrollHeight, clientHeight } = containerRef.current; @@ -105,42 +98,30 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme return (
{ - e.stopPropagation(); - setIsExpanded(false); - }} + onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }} >
e.stopPropagation()} > - {/* Header */}
Build Logs - {deploymentUuid.substring(0, 12)}
- {isPolling && ( - - - - - Polling + {isActive && ( + + + Live )}
- - {/* Logs */}
{logs.length === 0 ? (
- {isPolling ? 'Waiting for logs...' : 'No logs available'} + {isActive ? 'Waiting for logs...' : 'No logs available'}
) : ( logs.map((log, index) => (
- - {index + 1} - + {index + 1} {log.timestamp && ( - - {log.timestamp} - + {log.timestamp} )} {log.output}
@@ -176,8 +151,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme )}
- - {/* Footer */}
{logs.length} lines
@@ -189,33 +162,24 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme // Inline view return (
- {/* Header */}
Build Logs
- {isPolling && ( - - - - - 2s + {isActive && ( + + + Live )}
- - {/* Logs */}
{logs.length === 0 ? (
- {isPolling ? 'Waiting for logs...' : 'No logs available'} + {isActive ? 'Waiting for logs...' : 'No logs available'}
) : ( logs.map((log, index) => (
- - {index + 1} - + {index + 1} {log.output}
)) )}
- - {/* Auto-scroll indicator */} {!autoScroll && logs.length > 0 && (
+ )} {row.original.application_fqdn && ( )} ); - // Count active filters const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (appFilter !== 'all' ? 1 : 0); return ( @@ -244,7 +281,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme {/* Filters */}
- {/* Application Filter */} - {/* Status Filter */}
- Page {table.getState().pagination.pageIndex + 1} of{' '} - {table.getPageCount()} + Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
{/* Actions */}
- {/* Refresh button */} - + + + {connected ? 'Live' : 'Offline'} + +
{/* Dark mode toggle */}
); } diff --git a/src/lib/PortalContext.tsx b/src/lib/PortalContext.tsx index eb76dfd..ac453e6 100644 --- a/src/lib/PortalContext.tsx +++ b/src/lib/PortalContext.tsx @@ -1,9 +1,10 @@ 'use client'; import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'; -import { services, bookmarks, Service, Bookmark, DiscoveredService, fallbackServices } from './services'; +import { bookmarks, Service, Bookmark, DiscoveredService, fallbackServices } from './services'; import type { Deployment } from './deployments'; -import type { SystemStats } from './stats'; +import type { SystemStats, MetricsData } from './stats'; +import { useEventStream } from './useEventStream'; export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading'; @@ -39,35 +40,24 @@ interface PortalContextType { statsLoading: boolean; statsError: boolean; refreshStats: () => Promise; + // SSE + connected: boolean; + metrics: MetricsData | null; + // Deploy action + triggerDeploy: (uuid: string) => Promise; + // Active deploy logs + activeDeployLogs: Array<{ uuid: string; logs: string; status: string }>; } const PortalContext = createContext(undefined); export function PortalProvider({ children }: { children: ReactNode }) { - const [darkMode, setDarkMode] = useState(true); // Default to dark mode + const [darkMode, setDarkMode] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [healthStatus, setHealthStatus] = useState(() => { - // Initialize all services as loading - const initial: HealthState = {}; - services.forEach(s => { - initial[s.name] = 'loading'; - }); - return initial; - }); - const [isRefreshing, setIsRefreshing] = useState(false); - const [deployments, setDeployments] = useState([]); - const [deploymentsLoading, setDeploymentsLoading] = useState(false); const [activeTab, setActiveTab] = useState('overview'); - // Discovery state - const [discoveredServices, setDiscoveredServices] = useState([]); - const [discoveryLoading, setDiscoveryLoading] = useState(true); - const [discoveryError, setDiscoveryError] = useState(false); - - // System stats state - const [systemStats, setSystemStats] = useState(null); - const [statsLoading, setStatsLoading] = useState(true); - const [statsError, setStatsError] = useState(false); + // SSE stream + const stream = useEventStream(); // Apply dark mode to document useEffect(() => { @@ -90,136 +80,25 @@ export function PortalProvider({ children }: { children: ReactNode }) { localStorage.setItem('portal-dark-mode', String(darkMode)); }, [darkMode]); - // Fetch health status (used as fallback when discovery is unavailable) - const refreshHealth = useCallback(async () => { - setIsRefreshing(true); - try { - const response = await fetch('/api/health'); - if (response.ok) { - const data = await response.json(); - setHealthStatus(data); - } - } catch (error) { - console.error('Failed to fetch health status:', error); - } finally { - setIsRefreshing(false); + // Derive health status from discovered services + const healthStatus: HealthState = {}; + const discoveredServices = stream.services as DiscoveredService[]; + for (const svc of discoveredServices) { + if (svc.coolifyStatus?.startsWith('running')) { + healthStatus[svc.name] = 'running'; + } else if (svc.coolifyStatus?.startsWith('exited') || svc.coolifyStatus === 'stopped') { + healthStatus[svc.name] = 'stopped'; + } else { + healthStatus[svc.name] = 'unknown'; } - }, []); + } - // Fetch discovered services from Coolify - const refreshDiscover = useCallback(async () => { - try { - const response = await fetch('/api/discover'); - if (response.ok) { - const data = await response.json(); - if (data.services && data.services.length > 0) { - setDiscoveredServices(data.services); - setDiscoveryError(false); - - // Build health status from discovered services - const newHealth: HealthState = {}; - for (const svc of data.services as DiscoveredService[]) { - if (svc.coolifyStatus.startsWith('running')) { - newHealth[svc.name] = 'running'; - } else if (svc.coolifyStatus.startsWith('exited') || svc.coolifyStatus === 'stopped') { - newHealth[svc.name] = 'stopped'; - } else { - newHealth[svc.name] = 'unknown'; - } - } - setHealthStatus(prev => ({ ...prev, ...newHealth })); - } else { - setDiscoveryError(true); - } - } else { - setDiscoveryError(true); - } - } catch (error) { - console.error('Failed to discover services:', error); - setDiscoveryError(true); - } finally { - setDiscoveryLoading(false); - } - }, []); - - // Initial discovery + periodic refresh (every 30s) - useEffect(() => { - refreshDiscover(); - const interval = setInterval(refreshDiscover, 30000); - return () => clearInterval(interval); - }, [refreshDiscover]); - - // Fall back to health checks if discovery fails - useEffect(() => { - if (discoveryError && discoveredServices.length === 0) { - refreshHealth(); - const interval = setInterval(refreshHealth, 30000); - return () => clearInterval(interval); - } - }, [discoveryError, discoveredServices.length, refreshHealth]); - - // Fetch system stats - const refreshStats = useCallback(async () => { - try { - const response = await fetch('/api/stats'); - if (response.ok) { - const data = await response.json(); - setSystemStats(data); - setStatsError(false); - } else { - setStatsError(true); - } - } catch (error) { - console.error('Failed to fetch stats:', error); - setStatsError(true); - } finally { - setStatsLoading(false); - } - }, []); - - // Poll stats every 30s - useEffect(() => { - refreshStats(); - const interval = setInterval(refreshStats, 30000); - return () => clearInterval(interval); - }, [refreshStats]); - - // Fetch deployments - const refreshDeployments = useCallback(async () => { - setDeploymentsLoading(true); - try { - const response = await fetch('/api/deployments'); - if (response.ok) { - const data = await response.json(); - setDeployments(data); - } - } catch (error) { - console.error('Failed to fetch deployments:', error); - } finally { - setDeploymentsLoading(false); - } - }, []); - - // Fetch deployments when tab is active, poll every 10s on deployments tab, 30s on overview - useEffect(() => { - if (activeTab === 'deployments') { - refreshDeployments(); - const interval = setInterval(refreshDeployments, 10000); - return () => clearInterval(interval); - } - if (activeTab === 'overview') { - refreshDeployments(); - const interval = setInterval(refreshDeployments, 30000); - return () => clearInterval(interval); - } - }, [activeTab, refreshDeployments]); - - // Determine which services to show: discovered or fallback + // Active services: discovered or fallback const activeServices: Service[] = discoveredServices.length > 0 ? discoveredServices : fallbackServices; - // Filter services and bookmarks based on search query + // Filter services and bookmarks const filteredServices = activeServices.filter(service => { if (!searchQuery) return true; const query = searchQuery.toLowerCase(); @@ -240,6 +119,21 @@ export function PortalProvider({ children }: { children: ReactNode }) { ); }); + // Legacy refresh callbacks (now no-ops since SSE handles updates) + const refreshHealth = useCallback(async () => {}, []); + const refreshDiscover = useCallback(async () => {}, []); + const refreshStats = useCallback(async () => {}, []); + const refreshDeployments = useCallback(async () => {}, []); + + // Deploy trigger + const triggerDeploy = useCallback(async (uuid: string) => { + await fetch('/api/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uuid, action: 'deploy' }), + }); + }, []); + return ( {children} diff --git a/src/lib/coolify-db.ts b/src/lib/coolify-db.ts new file mode 100644 index 0000000..ec4181f --- /dev/null +++ b/src/lib/coolify-db.ts @@ -0,0 +1,128 @@ +import pg from 'pg'; +import { serverConfig } from './config'; +import type { Deployment, DeploymentStatus } from './deployments'; + +const { Pool } = pg; + +let pool: pg.Pool | null = null; + +function getPool(): pg.Pool { + if (!pool) { + pool = new Pool({ + connectionString: serverConfig.coolifyDbUrl, + max: 3, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); + pool.on('error', (err) => { + console.error('Coolify DB pool error:', err); + }); + } + return pool; +} + +function mapStatus(status: string): DeploymentStatus { + if (status === 'failed') return 'error'; + if (status === 'cancelled-by-user') return 'cancelled'; + return status as DeploymentStatus; +} + +function rowToDeployment(row: Record): Deployment { + const createdAt = row.created_at as string; + const updatedAt = row.updated_at as string; + let duration: number | undefined; + if (createdAt && updatedAt) { + const start = new Date(createdAt).getTime(); + const end = new Date(updatedAt).getTime(); + duration = Math.max(0, Math.floor((end - start) / 1000)); + } + + return { + deployment_uuid: row.deployment_uuid as string, + application_uuid: (row.application_uuid as string) || 'unknown', + application_name: (row.application_name as string) || 'Unknown App', + application_fqdn: (row.application_fqdn as string) || undefined, + status: mapStatus(row.status as string), + created_at: createdAt, + updated_at: updatedAt, + git_branch: (row.git_branch as string) || 'main', + git_commit_sha: (row.commit as string) || undefined, + commit_message: (row.commit_message as string) || undefined, + is_webhook: row.is_webhook as boolean | undefined, + logs: (row.logs as string) || undefined, + duration, + }; +} + +export async function fetchDeployments(limit = 50): Promise { + const db = getPool(); + const { rows } = await db.query(` + SELECT + q.deployment_uuid, + a.uuid AS application_uuid, + a.name AS application_name, + a.fqdn AS application_fqdn, + q.status, + q.created_at, + q.updated_at, + a.git_branch, + q.commit, + q.commit_message, + q.is_webhook + FROM application_deployment_queues q + LEFT JOIN applications a ON a.id = q.application_id + ORDER BY q.created_at DESC + LIMIT $1 + `, [limit]); + + const deployments = rows.map(rowToDeployment); + + // Mark latest finished deployment per app as current + const latestByApp = new Map(); + for (const d of deployments) { + if (d.status === 'finished' && !latestByApp.has(d.application_uuid)) { + latestByApp.set(d.application_uuid, d.deployment_uuid); + } + } + for (const d of deployments) { + d.is_current = latestByApp.get(d.application_uuid) === d.deployment_uuid; + } + + return deployments; +} + +export async function fetchDeploymentDetail(uuid: string): Promise { + const db = getPool(); + const { rows } = await db.query(` + SELECT + q.deployment_uuid, + a.uuid AS application_uuid, + a.name AS application_name, + a.fqdn AS application_fqdn, + q.status, + q.created_at, + q.updated_at, + a.git_branch, + q.commit, + q.commit_message, + q.is_webhook, + q.logs + FROM application_deployment_queues q + LEFT JOIN applications a ON a.id = q.application_id + WHERE q.deployment_uuid = $1 + `, [uuid]); + + if (rows.length === 0) return null; + return rowToDeployment(rows[0]); +} + +export async function fetchActiveDeploymentLogs(): Promise> { + const db = getPool(); + const { rows } = await db.query(` + SELECT deployment_uuid AS uuid, logs, status + FROM application_deployment_queues + WHERE status IN ('in_progress', 'queued') + ORDER BY created_at DESC + `); + return rows as Array<{ uuid: string; logs: string; status: string }>; +} diff --git a/src/lib/event-manager.ts b/src/lib/event-manager.ts new file mode 100644 index 0000000..f59953f --- /dev/null +++ b/src/lib/event-manager.ts @@ -0,0 +1,241 @@ +import { createHash } from 'crypto'; +import { fetchInstantStats } from './prometheus'; +import { fetchRangeMetrics } from './prometheus'; +import { fetchDeployments, fetchActiveDeploymentLogs } from './coolify-db'; +import type { DiscoveredService, ServiceCategory } from './services'; +import { lookupService, lookupDatabase } from './service-registry'; +import { fetchResources, fetchAppDetail, fetchServiceDetail } from './coolify'; +import { serverConfig } from './config'; + +type SSEClient = { + write: (data: string) => void; + close: () => void; +}; + +function hash(data: unknown): string { + return createHash('md5').update(JSON.stringify(data)).digest('hex'); +} + +class EventManager { + private clients = new Set(); + private timers: ReturnType[] = []; + private cache: Record = {}; + + addClient(client: SSEClient) { + this.clients.add(client); + // Send cached data immediately + for (const [event, { data }] of Object.entries(this.cache)) { + this.sendTo(client, event, data); + } + if (this.clients.size === 1) { + this.startPolling(); + } + } + + removeClient(client: SSEClient) { + this.clients.delete(client); + if (this.clients.size === 0) { + this.stopPolling(); + } + } + + get clientCount() { + return this.clients.size; + } + + private sendTo(client: SSEClient, event: string, data: unknown) { + try { + client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + } catch { + this.clients.delete(client); + } + } + + private broadcast(event: string, data: unknown) { + const newHash = hash(data); + const cached = this.cache[event]; + + // Only broadcast if data changed (except heartbeat and active logs which always send) + if (event !== 'heartbeat' && event !== 'deployment:logs' && cached?.hash === newHash) { + return; + } + + this.cache[event] = { hash: newHash, data }; + for (const client of this.clients) { + this.sendTo(client, event, data); + } + } + + private async pollStats() { + try { + const stats = await fetchInstantStats(); + this.broadcast('stats', stats); + } catch (err) { + console.error('[SSE] stats error:', err); + } + } + + private async pollDiscovery() { + try { + const discovered = await this.discoverServices(); + this.broadcast('services', discovered); + } catch (err) { + console.error('[SSE] discovery error:', err); + } + } + + private async pollDeployments() { + try { + const deployments = await fetchDeployments(); + this.broadcast('deployments', deployments); + } catch (err) { + console.error('[SSE] deployments error:', err); + } + } + + private async pollMetrics() { + try { + const metrics = await fetchRangeMetrics(); + this.broadcast('metrics', metrics); + } catch (err) { + console.error('[SSE] metrics error:', err); + } + } + + private async pollActiveLogs() { + try { + const logs = await fetchActiveDeploymentLogs(); + if (logs.length > 0) { + this.broadcast('deployment:logs', logs); + } + } catch (err) { + console.error('[SSE] active logs error:', err); + } + } + + private startPolling() { + // Immediate first poll of all sources + this.pollStats(); + this.pollDiscovery(); + this.pollDeployments(); + this.pollMetrics(); + this.pollActiveLogs(); + + this.timers = [ + setInterval(() => this.pollStats(), 15000), + setInterval(() => this.pollDiscovery(), 30000), + setInterval(() => this.pollDeployments(), 10000), + setInterval(() => this.pollMetrics(), 60000), + setInterval(() => this.pollActiveLogs(), 2000), + setInterval(() => this.broadcast('heartbeat', { ts: Date.now() }), 15000), + ]; + } + + private stopPolling() { + for (const timer of this.timers) { + clearInterval(timer); + } + this.timers = []; + this.cache = {}; + } + + // Inline service discovery (same logic as discover route) + private async discoverServices(): Promise { + const nucHost = serverConfig.nucHost; + const resources = await fetchResources(); + if (!resources) return []; + + const detailPromises = resources.map(async (resource): Promise => { + if (resource.type === 'application') { + const detail = await fetchAppDetail(resource.uuid); + if (!detail) return null; + const port = this.extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings); + const meta = lookupService(detail.name); + const url = this.buildUrl(detail.fqdn, port, nucHost); + return { + name: detail.name, url, port, + icon: meta.icon, category: meta.category as ServiceCategory, + description: detail.description || meta.description, + source: 'discovered', fqdn: detail.fqdn || undefined, + resourceType: 'application', uuid: resource.uuid, coolifyStatus: resource.status, + }; + } + + if (resource.type === 'service') { + const detail = await fetchServiceDetail(resource.uuid); + if (!detail) return null; + const app = detail.applications?.[0]; + const cleanName = resource.name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, ''); + const meta = lookupService(cleanName); + let port = 0; + let fqdn: string | undefined; + if (app) { + port = this.extractPortFromServicePorts(app.ports) || this.extractPort(app.fqdn, null, null); + fqdn = app.fqdn && !app.fqdn.includes('sslip.io') ? app.fqdn : undefined; + } + const url = this.buildUrl(fqdn || null, port, nucHost); + return { + name: cleanName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), + url, port, icon: meta.icon, category: meta.category as ServiceCategory, + description: meta.description, source: 'discovered', fqdn, + resourceType: 'service', uuid: resource.uuid, coolifyStatus: resource.status, + }; + } + + if (resource.type.startsWith('standalone-')) { + const meta = lookupDatabase(resource.type, resource.name); + return { + name: resource.name, url: `http://${nucHost}:8000`, port: 0, + icon: meta.icon, category: meta.category as ServiceCategory, + description: meta.description, source: 'discovered', + resourceType: 'database', uuid: resource.uuid, coolifyStatus: resource.status, + }; + } + return null; + }); + + const results = await Promise.allSettled(detailPromises); + const discovered: DiscoveredService[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && r.value) discovered.push(r.value); + } + discovered.sort((a, b) => { + const aR = a.coolifyStatus.startsWith('running') ? 0 : 1; + const bR = b.coolifyStatus.startsWith('running') ? 0 : 1; + if (aR !== bR) return aR - bR; + return a.name.localeCompare(b.name); + }); + return discovered; + } + + private extractPort(fqdn: string | null, portsExposes: string | null, portsMappings: string | null): number { + if (fqdn) { + try { const url = new URL(fqdn); if (url.port) return parseInt(url.port, 10); } catch { /* */ } + } + if (portsMappings) { const h = portsMappings.split(',')[0].trim().split(':')[0]; if (h) return parseInt(h, 10); } + if (portsExposes) return parseInt(portsExposes.split(',')[0].trim(), 10); + return 0; + } + + private extractPortFromServicePorts(ports: string | null): number { + if (!ports) return 0; + return parseInt(ports.split(',')[0].trim().split(':')[0], 10) || 0; + } + + private buildUrl(fqdn: string | null, port: number, nucHost: string): string { + if (fqdn) { + try { + const url = new URL(fqdn); + if (url.hostname.includes('sslip.io')) return `http://${nucHost}:${port || url.port || 80}`; + return fqdn; + } catch { /* */ } + } + if (port > 0) return `http://${nucHost}:${port}`; + return `http://${nucHost}`; + } +} + +// Global singleton (survives hot-reload in dev) +const globalForEventManager = globalThis as unknown as { eventManager?: EventManager }; +export const eventManager = globalForEventManager.eventManager ?? new EventManager(); +globalForEventManager.eventManager = eventManager; diff --git a/src/lib/prometheus.ts b/src/lib/prometheus.ts new file mode 100644 index 0000000..cd2de0d --- /dev/null +++ b/src/lib/prometheus.ts @@ -0,0 +1,136 @@ +import { serverConfig } from './config'; +import type { SystemStats, MetricsData } from './stats'; + +const { prometheusUrl, nodeExporterInstance, nicDevice } = serverConfig; + +interface PrometheusInstantResult { + data: { + result: Array<{ + value: [number, string]; + }>; + }; +} + +interface PrometheusRangeResult { + data: { + result: Array<{ + values: Array<[number, string]>; + }>; + }; +} + +async function queryInstant(query: string): Promise { + const params = new URLSearchParams({ query }); + const res = await fetch(`${prometheusUrl}/api/v1/query?${params}`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return 0; + const data: PrometheusInstantResult = await res.json(); + const val = data.data?.result?.[0]?.value?.[1]; + return val ? parseFloat(val) : 0; +} + +async function queryRange(query: string, start: number, end: number, step: number): Promise> { + const params = new URLSearchParams({ + query, + start: String(start), + end: String(end), + step: String(step), + }); + const res = await fetch(`${prometheusUrl}/api/v1/query_range?${params}`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return []; + const data: PrometheusRangeResult = await res.json(); + const result = data.data?.result?.[0]; + if (!result) return []; + return result.values.map(([ts, val]) => [ts, parseFloat(val)]); +} + +const I = nodeExporterInstance; +const NIC = nicDevice; + +export async function fetchInstantStats(): Promise { + const [ + cpuPercent, + ramTotalBytes, + ramAvailBytes, + swapTotalBytes, + swapFreeBytes, + diskTotalBytes, + diskAvailBytes, + uptimeSeconds, + load1, + load5, + load15, + ] = await Promise.all([ + queryInstant(`100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${I}"}[2m])) * 100)`), + queryInstant(`node_memory_MemTotal_bytes{instance="${I}"}`), + queryInstant(`node_memory_MemAvailable_bytes{instance="${I}"}`), + queryInstant(`node_memory_SwapTotal_bytes{instance="${I}"}`), + queryInstant(`node_memory_SwapFree_bytes{instance="${I}"}`), + queryInstant(`node_filesystem_size_bytes{instance="${I}",mountpoint="/",fstype!="tmpfs"}`), + queryInstant(`node_filesystem_avail_bytes{instance="${I}",mountpoint="/",fstype!="tmpfs"}`), + queryInstant(`node_time_seconds{instance="${I}"} - node_boot_time_seconds{instance="${I}"}`), + queryInstant(`node_load1{instance="${I}"}`), + queryInstant(`node_load5{instance="${I}"}`), + queryInstant(`node_load15{instance="${I}"}`), + ]); + + const ramTotalMb = ramTotalBytes / (1024 * 1024); + const ramUsedMb = (ramTotalBytes - ramAvailBytes) / (1024 * 1024); + const swapTotalMb = swapTotalBytes / (1024 * 1024); + const swapUsedMb = (swapTotalBytes - swapFreeBytes) / (1024 * 1024); + const diskTotalGb = diskTotalBytes / (1024 * 1024 * 1024); + const diskUsedGb = (diskTotalBytes - diskAvailBytes) / (1024 * 1024 * 1024); + + return { + cpu_percent: Math.round(cpuPercent * 10) / 10, + ram_total_mb: Math.round(ramTotalMb), + ram_used_mb: Math.round(ramUsedMb), + ram_percent: ramTotalMb > 0 ? Math.round((ramUsedMb / ramTotalMb) * 1000) / 10 : 0, + swap_total_mb: Math.round(swapTotalMb), + swap_used_mb: Math.round(swapUsedMb), + swap_percent: swapTotalMb > 0 ? Math.round((swapUsedMb / swapTotalMb) * 1000) / 10 : 0, + disk_total_gb: Math.round(diskTotalGb * 10) / 10, + disk_used_gb: Math.round(diskUsedGb * 10) / 10, + disk_percent: diskTotalGb > 0 ? Math.round((diskUsedGb / diskTotalGb) * 1000) / 10 : 0, + uptime_seconds: Math.round(uptimeSeconds), + load_avg: [ + Math.round(load1 * 100) / 100, + Math.round(load5 * 100) / 100, + Math.round(load15 * 100) / 100, + ], + }; +} + +export async function fetchRangeMetrics(hours = 6): Promise { + const end = Math.floor(Date.now() / 1000); + const start = end - hours * 3600; + const step = 120; + + const [cpu, ram, netRx, netTx, temp] = await Promise.all([ + queryRange( + `100 - (avg(rate(node_cpu_seconds_total{mode="idle",instance="${I}"}[2m])) * 100)`, + start, end, step + ), + queryRange( + `(1 - node_memory_MemAvailable_bytes{instance="${I}"} / node_memory_MemTotal_bytes{instance="${I}"}) * 100`, + start, end, step + ), + queryRange( + `rate(node_network_receive_bytes_total{instance="${I}",device="${NIC}"}[2m])`, + start, end, step + ), + queryRange( + `rate(node_network_transmit_bytes_total{instance="${I}",device="${NIC}"}[2m])`, + start, end, step + ), + queryRange( + `max(node_hwmon_temp_celsius{instance="${I}",chip="platform_coretemp_0"})`, + start, end, step + ), + ]); + + return { cpu, ram, netRx, netTx, temp }; +} diff --git a/src/lib/services.ts b/src/lib/services.ts index 3ac1069..fa0f21c 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -27,36 +27,40 @@ export interface Bookmark { description?: string; } +import { clientConfig } from './config'; + +const h = clientConfig.nucHost; + export const fallbackServices: Service[] = [ // Infrastructure - { name: 'Coolify', url: 'http://192.168.1.3:8000', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' }, - { name: 'Dozzle', url: 'http://192.168.1.3:9999', port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' }, - { name: 'Playwriter Browser', url: 'http://192.168.1.3:6081/vnc.html', port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' }, + { name: 'Coolify', url: `http://${h}:8000`, port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' }, + { name: 'Dozzle', url: `http://${h}:9999`, port: 9999, icon: 'scroll-text', category: 'infrastructure', description: 'Real-time Docker log viewer' }, + { name: 'Playwriter Browser', url: `http://${h}:6081/vnc.html`, port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' }, // Automation - { name: 'n8n', url: 'http://192.168.1.3:5678', port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' }, + { name: 'n8n', url: `http://${h}:5678`, port: 5678, icon: 'workflow', category: 'automation', description: 'Workflow automation platform' }, // Development - { name: 'Gitea', url: 'http://192.168.1.3:3030', port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' }, - { name: 'CloudBeaver', url: 'http://192.168.1.3:8978', port: 8978, icon: 'database', category: 'development', description: 'Database management UI' }, - { name: 'Adminer', url: 'http://192.168.1.3:8088', port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' }, + { name: 'Gitea', url: `http://${h}:3030`, port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' }, + { name: 'CloudBeaver', url: `http://${h}:8978`, port: 8978, icon: 'database', category: 'development', description: 'Database management UI' }, + { name: 'Adminer', url: `http://${h}:8088`, port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' }, // Knowledge - { name: 'Outline', url: 'http://192.168.1.3:3080', port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' }, - { name: 'NocoDB', url: 'http://192.168.1.3:8084', port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' }, + { name: 'Outline', url: `http://${h}:3080`, port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' }, + { name: 'NocoDB', url: `http://${h}:8084`, port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' }, // Storage - { name: 'FileBrowser', url: 'http://192.168.1.3:8085', port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' }, - { name: 'MinIO', url: 'http://192.168.1.3:9001', port: 9001, icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' }, - { name: 'Kopia', url: 'http://192.168.1.3:51515', port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' }, + { name: 'FileBrowser', url: `http://${h}:8085`, port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' }, + { name: 'MinIO', url: `http://${h}:9001`, port: 9001, icon: 'hard-drive', category: 'storage', description: 'S3-compatible object storage' }, + { name: 'Kopia', url: `http://${h}:51515`, port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' }, // Monitoring - { name: 'Uptime Kuma', url: 'http://192.168.1.3:3001', port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' }, - { name: 'Ntfy', url: 'http://192.168.1.3:8333', port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' }, + { name: 'Uptime Kuma', url: `http://${h}:3001`, port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' }, + { name: 'Ntfy', url: `http://${h}:8333`, port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' }, // Security - { name: 'Vaultwarden', url: 'http://192.168.1.3:8222', port: 8222, icon: 'lock', category: 'security', description: 'Password manager' }, - { name: 'Authentik', url: 'http://192.168.1.3:9090', port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' }, + { name: 'Vaultwarden', url: `http://${h}:8222`, port: 8222, icon: 'lock', category: 'security', description: 'Password manager' }, + { name: 'Authentik', url: `http://${h}:9090`, port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' }, ]; // Re-export for backwards compatibility diff --git a/src/lib/useEventStream.ts b/src/lib/useEventStream.ts new file mode 100644 index 0000000..b95c90e --- /dev/null +++ b/src/lib/useEventStream.ts @@ -0,0 +1,112 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import type { DiscoveredService } from './services'; +import type { Deployment } from './deployments'; +import type { SystemStats, MetricsData } from './stats'; + +interface ActiveDeployLog { + uuid: string; + logs: string; + status: string; +} + +interface EventStreamState { + services: DiscoveredService[]; + stats: SystemStats | null; + deployments: Deployment[]; + metrics: MetricsData | null; + activeDeployLogs: ActiveDeployLog[]; + connected: boolean; + error: string | null; +} + +export function useEventStream() { + const [state, setState] = useState({ + services: [], + stats: null, + deployments: [], + metrics: null, + activeDeployLogs: [], + connected: false, + error: null, + }); + + const retryDelay = useRef(1000); + const eventSourceRef = useRef(null); + + const connect = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + } + + const es = new EventSource('/api/events'); + eventSourceRef.current = es; + + es.onopen = () => { + retryDelay.current = 1000; + setState(prev => ({ ...prev, connected: true, error: null })); + }; + + es.onerror = () => { + es.close(); + eventSourceRef.current = null; + setState(prev => ({ ...prev, connected: false })); + + const delay = retryDelay.current; + retryDelay.current = Math.min(delay * 2, 30000); + setTimeout(connect, delay); + }; + + es.addEventListener('services', (e) => { + try { + const services = JSON.parse(e.data); + setState(prev => ({ ...prev, services })); + } catch { /* ignore */ } + }); + + es.addEventListener('stats', (e) => { + try { + const stats = JSON.parse(e.data); + setState(prev => ({ ...prev, stats })); + } catch { /* ignore */ } + }); + + es.addEventListener('deployments', (e) => { + try { + const deployments = JSON.parse(e.data); + setState(prev => ({ ...prev, deployments })); + } catch { /* ignore */ } + }); + + es.addEventListener('metrics', (e) => { + try { + const metrics = JSON.parse(e.data); + setState(prev => ({ ...prev, metrics })); + } catch { /* ignore */ } + }); + + es.addEventListener('deployment:logs', (e) => { + try { + const activeDeployLogs = JSON.parse(e.data); + setState(prev => ({ ...prev, activeDeployLogs })); + } catch { /* ignore */ } + }); + + es.addEventListener('heartbeat', () => { + // Keep-alive; no state change needed + }); + }, []); + + useEffect(() => { + connect(); + return () => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }; + }, [connect]); + + return state; +}