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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-04 00:43:41 +01:00
parent d4053812cd
commit 9e683eba22
20 changed files with 1064 additions and 795 deletions

160
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<string, unknown>;
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);

View File

@@ -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<Deployment[]> {
let rawDeployments: Array<Record<string, unknown>>;
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<string, string>();
// 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<string, unknown>) => {
// 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);

View File

@@ -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<T>(url: string): Promise<T | null> {
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<CoolifyResource[]>(
`${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<DiscoveredService | null> => {
const healthStatus = mapCoolifyStatus(resource.status);
if (resource.type === 'application') {
const detail = await fetchJson<CoolifyAppDetail>(
`${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<CoolifyServiceDetail>(
`${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;

View File

@@ -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',
},
});
}

View File

@@ -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<string, 'running' | 'stopped' | 'unknown'> = {};
// 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' },
});
}

View File

@@ -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<Array<[number, number]>> {
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(

View File

@@ -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<SystemStats> {
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);

View File

@@ -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 */}
<div className="mb-8 max-w-xl">
<SearchBar />
</div>
{/* Status summary */}
<div className="mb-6 flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-500" />
@@ -110,7 +111,6 @@ export default function Home() {
)}
</div>
{/* No results message */}
{noResults && (
<div className="text-center py-12">
<p className="text-slate-500 dark:text-stone-500">
@@ -119,7 +119,6 @@ export default function Home() {
</div>
)}
{/* Services */}
{hasServices && (
<div>
{Object.entries(servicesByCategory).map(([category, services]) => (
@@ -146,12 +145,10 @@ export default function Home() {
case 'bookmarks':
return (
<>
{/* Search */}
<div className="mb-8 max-w-xl">
<SearchBar />
</div>
{/* No results message */}
{searchQuery && !hasBookmarks && (
<div className="text-center py-12">
<p className="text-slate-500 dark:text-stone-500">
@@ -160,7 +157,6 @@ export default function Home() {
</div>
)}
{/* Bookmarks */}
{hasBookmarks && (
<div>
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => (
@@ -228,12 +224,19 @@ export default function Home() {
</h2>
<p className="text-sm text-slate-500 dark:text-stone-500">
All deployments across Coolify applications
{connected && (
<span className="ml-2 inline-flex items-center gap-1 text-emerald-500">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
Live
</span>
)}
</p>
</div>
<DeploymentsTable
deployments={deployments}
isLoading={deploymentsLoading}
onRefresh={refreshDeployments}
onDeploy={triggerDeploy}
/>
</div>
);
@@ -246,7 +249,6 @@ export default function Home() {
Appearance
</h2>
{/* Dark Mode Toggle */}
<div className="flex items-center justify-between py-4 border-b border-slate-100 dark:border-stone-800">
<div>
<h3 className="font-medium text-slate-900 dark:text-stone-100">Dark Mode</h3>
@@ -279,7 +281,7 @@ export default function Home() {
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-stone-500">Server IP</span>
<span className="text-slate-900 dark:text-stone-100 font-mono">192.168.1.3</span>
<span className="text-slate-900 dark:text-stone-100 font-mono">{clientConfig.nucHost}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-stone-500">Services</span>
@@ -291,6 +293,12 @@ export default function Home() {
{isDiscovered ? 'Coolify API' : 'Static'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-stone-500">Connection</span>
<span className={connected ? 'text-emerald-500' : 'text-red-500'}>
{connected ? 'SSE Connected' : 'Disconnected'}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</span>
@@ -299,7 +307,7 @@ export default function Home() {
<div className="mt-8 pt-6 border-t border-slate-100 dark:border-stone-800">
<a
href="http://192.168.1.3:3030/nuc/nuc-portal"
href={`http://${clientConfig.nucHost}:3030/alezmad/nuc-portal`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
@@ -328,11 +336,10 @@ export default function Home() {
{renderTabContent()}
</main>
{/* Footer */}
<footer className="text-center py-8 text-sm text-slate-400 dark:text-stone-600">
<span>NUC Portal</span>
<span className="mx-2"></span>
<span>192.168.1.3</span>
<span className="mx-2">&bull;</span>
<span>{clientConfig.nucHost}</span>
</footer>
</div>
);

View File

@@ -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<DeploymentLog[]>(() => 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<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const logsEndRef = useRef<HTMLDivElement>(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 (
<div
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
>
<div
className="w-full max-w-6xl h-[80vh] bg-slate-900 rounded-xl shadow-2xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
<span className="text-sm font-medium text-slate-200">Build Logs - {deploymentUuid.substring(0, 12)}</span>
<div className="flex items-center gap-2">
{isPolling && (
<span className="flex items-center gap-1 text-xs text-slate-400">
<span className="animate-spin">
<Icon name="refresh-cw" size={12} />
</span>
Polling
{isActive && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard();
}}
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name="x" size={14} />
@@ -148,8 +129,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
</button>
</div>
</div>
{/* Logs */}
<div
ref={containerRef}
onScroll={handleScroll}
@@ -157,18 +136,14 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">
{isPolling ? 'Waiting for logs...' : 'No logs available'}
{isActive ? 'Waiting for logs...' : 'No logs available'}
</div>
) : (
logs.map((log, index) => (
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">
{index + 1}
</span>
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">{index + 1}</span>
{log.timestamp && (
<span className="text-slate-500 mr-3 select-none shrink-0">
{log.timestamp}
</span>
<span className="text-slate-500 mr-3 select-none shrink-0">{log.timestamp}</span>
)}
<span className="whitespace-pre-wrap break-all">{log.output}</span>
</div>
@@ -176,8 +151,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
)}
<div ref={logsEndRef} />
</div>
{/* Footer */}
<div className="px-4 py-2 border-t border-slate-700 text-xs text-slate-500">
{logs.length} lines
</div>
@@ -189,33 +162,24 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
// Inline view
return (
<div className="border-t border-slate-200 dark:border-stone-800 bg-slate-900 dark:bg-stone-950">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-slate-700 dark:border-stone-800">
<span className="text-sm font-medium text-slate-300">Build Logs</span>
<div className="flex items-center gap-2">
{isPolling && (
<span className="flex items-center gap-1 text-xs text-slate-500">
<span className="animate-spin">
<Icon name="refresh-cw" size={12} />
</span>
2s
{isActive && (
<span className="flex items-center gap-1 text-xs text-emerald-400">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Live
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard();
}}
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
onClick={(e) => { e.stopPropagation(); setIsExpanded(true); }}
className="flex items-center gap-1 px-2 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded transition-colors"
>
<Icon name="maximize-2" size={14} />
@@ -223,8 +187,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
</button>
</div>
</div>
{/* Logs */}
<div
ref={containerRef}
onScroll={handleScroll}
@@ -233,22 +195,18 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">
{isPolling ? 'Waiting for logs...' : 'No logs available'}
{isActive ? 'Waiting for logs...' : 'No logs available'}
</div>
) : (
logs.map((log, index) => (
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">
{index + 1}
</span>
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">{index + 1}</span>
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
{/* Auto-scroll indicator */}
{!autoScroll && logs.length > 0 && (
<button
onClick={(e) => {

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, Fragment } from 'react';
import { useState, useMemo, useEffect, Fragment } from 'react';
import {
useReactTable,
getCoreRowModel,
@@ -24,6 +24,7 @@ import {
formatRelativeTime,
truncateCommitMessage,
} from '@/lib/deployments';
import { clientConfig } from '@/lib/config';
import { Icon } from './Icons';
import { DeploymentLogs } from './DeploymentLogs';
@@ -31,6 +32,7 @@ interface DeploymentsTableProps {
deployments: Deployment[];
isLoading: boolean;
onRefresh: () => void;
onDeploy?: (uuid: string) => void;
}
const columnHelper = createColumnHelper<Deployment>();
@@ -50,7 +52,25 @@ const StatusBadge = ({ status }: { status: DeploymentStatus }) => (
</span>
);
export function DeploymentsTable({ deployments, isLoading, onRefresh }: DeploymentsTableProps) {
function LiveDuration({ createdAt }: { createdAt: string }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const start = new Date(createdAt).getTime();
const tick = () => setElapsed(Math.floor((Date.now() - start) / 1000));
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [createdAt]);
return (
<span className="text-orange-500 dark:text-orange-400 text-sm tabular-nums">
{formatDuration(elapsed)}
</span>
);
}
export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true },
]);
@@ -59,13 +79,11 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
const [appFilter, setAppFilter] = useState<string>('all');
// Get unique application names for filter dropdown
const applicationNames = useMemo(() => {
const names = new Set(deployments.map((d) => d.application_name));
return Array.from(names).sort();
}, [deployments]);
// Filter deployments
const filteredDeployments = useMemo(() => {
return deployments.filter((d) => {
if (statusFilter !== 'all' && d.status !== statusFilter) return false;
@@ -110,7 +128,10 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
}),
columnHelper.accessor('duration', {
header: 'Duration',
cell: ({ getValue }) => {
cell: ({ getValue, row }) => {
if (row.original.status === 'in_progress') {
return <LiveDuration createdAt={row.original.created_at} />;
}
const duration = getValue();
return (
<span className="text-slate-500 dark:text-stone-400 text-sm">
@@ -127,7 +148,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
header: 'Application',
cell: ({ getValue, row }) => (
<a
href={`http://192.168.1.3:8000/project/a8484ggc88c40w4g4k004ow0/production/application/${row.original.application_uuid}`}
href={`${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${row.original.application_uuid}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white"
@@ -166,8 +187,13 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
cell: ({ getValue, row }) => (
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-400">
<span>{formatRelativeTime(getValue())}</span>
<span className="text-slate-400 dark:text-stone-600">by</span>
<span>{row.original.is_webhook ? 'webhook' : 'API'}</span>
<span className="text-slate-400 dark:text-stone-600">
{row.original.is_webhook ? (
<Icon name="webhook" size={13} className="text-violet-500" />
) : (
<Icon name="terminal" size={13} className="text-slate-400 dark:text-stone-600" />
)}
</span>
</div>
),
}),
@@ -175,6 +201,18 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
id: 'actions',
cell: ({ row }) => (
<div className="flex items-center gap-1">
{onDeploy && (
<button
onClick={(e) => {
e.stopPropagation();
onDeploy(row.original.application_uuid);
}}
className="p-1 text-slate-400 dark:text-stone-500 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
title="Redeploy"
>
<Icon name="rocket" size={16} />
</button>
)}
{row.original.application_fqdn && (
<a
href={row.original.application_fqdn}
@@ -188,7 +226,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
</a>
)}
<a
href={`http://192.168.1.3:8000/project/a8484ggc88c40w4g4k004ow0/production/application/${row.original.application_uuid}/deployment/${row.original.deployment_uuid}`}
href={`${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${row.original.application_uuid}/deployment/${row.original.deployment_uuid}`}
target="_blank"
rel="noopener noreferrer"
className="p-1 text-slate-400 dark:text-stone-500 hover:text-slate-600 dark:hover:text-stone-300 hover:bg-slate-100 dark:hover:bg-stone-800 rounded transition-colors"
@@ -201,7 +239,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
),
}),
],
[]
[onDeploy]
);
const table = useReactTable({
@@ -236,7 +274,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
/>
);
// 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 */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
{/* Application Filter */}
<select
value={appFilter}
onChange={(e) => setAppFilter(e.target.value)}
@@ -252,13 +288,10 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
>
<option value="all">All Applications</option>
{applicationNames.map((name) => (
<option key={name} value={name}>
{name}
</option>
<option key={name} value={name}>{name}</option>
))}
</select>
{/* Status Filter */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as DeploymentStatus | 'all')}
@@ -274,10 +307,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
{activeFilterCount > 0 && (
<button
onClick={() => {
setStatusFilter('all');
setAppFilter('all');
}}
onClick={() => { setStatusFilter('all'); setAppFilter('all'); }}
className="text-xs text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
>
Clear filters
@@ -289,18 +319,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
<span className="text-sm text-slate-500 dark:text-stone-500">
{filteredDeployments.length} deployments
</span>
<button
onClick={onRefresh}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white bg-slate-100 dark:bg-stone-800 hover:bg-slate-200 dark:hover:bg-stone-700 rounded-lg transition-colors disabled:opacity-50"
>
<Icon
name="refresh-cw"
size={14}
className={isLoading ? 'animate-spin' : ''}
/>
Refresh
</button>
</div>
</div>
@@ -325,12 +343,8 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() === 'asc' && (
<Icon name="chevron-up" size={14} />
)}
{header.column.getIsSorted() === 'desc' && (
<Icon name="chevron-down" size={14} />
)}
{header.column.getIsSorted() === 'asc' && <Icon name="chevron-up" size={14} />}
{header.column.getIsSorted() === 'desc' && <Icon name="chevron-down" size={14} />}
</button>
)}
</th>
@@ -384,7 +398,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
</tbody>
</table>
{/* Pagination */}
{filteredDeployments.length > 25 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 dark:border-stone-800">
<div className="flex items-center gap-2">
@@ -395,17 +408,14 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
className="px-2 py-1 text-sm bg-slate-100 dark:bg-stone-800 border border-slate-200 dark:border-stone-700 rounded text-slate-700 dark:text-stone-300 focus:outline-none"
>
{[25, 50, 100].map((size) => (
<option key={size} value={size}>
{size}
</option>
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500 dark:text-stone-500">
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<div className="flex items-center gap-1">
<button

View File

@@ -1,6 +1,7 @@
'use client';
import { usePortal } from '@/lib/PortalContext';
import { clientConfig } from '@/lib/config';
import { Icon } from './Icons';
import { VitalsBar } from './VitalsBar';
@@ -17,7 +18,7 @@ interface HeaderProps {
}
export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
const { darkMode, setDarkMode, refreshHealth, isRefreshing } = usePortal();
const { darkMode, setDarkMode, connected } = usePortal();
return (
<header className="sticky top-0 z-50 bg-white dark:bg-stone-950 border-b border-slate-200 dark:border-stone-800">
@@ -34,26 +35,27 @@ export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
NUC Portal
</h1>
<p className="text-xs text-slate-500 dark:text-stone-500">
192.168.1.3
{clientConfig.nucHost}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* Refresh button */}
<button
onClick={refreshHealth}
disabled={isRefreshing}
className="p-2 rounded-lg bg-slate-100 dark:bg-stone-900 border border-slate-200 dark:border-stone-800 hover:bg-slate-200 dark:hover:bg-stone-800 disabled:opacity-50 transition-colors"
title="Refresh health status"
{/* SSE connection indicator */}
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs"
title={connected ? 'Real-time connection active' : 'Reconnecting...'}
>
<Icon
name="refresh-cw"
size={18}
className={`text-slate-600 dark:text-stone-400 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
<span className={`w-2 h-2 rounded-full ${
connected
? 'bg-emerald-500 animate-pulse'
: 'bg-red-500'
}`} />
<span className={connected ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500 dark:text-red-400'}>
{connected ? 'Live' : 'Offline'}
</span>
</div>
{/* Dark mode toggle */}
<button

View File

@@ -1,42 +1,12 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { usePortal } from '@/lib/PortalContext';
import { clientConfig } from '@/lib/config';
import { Icon } from './Icons';
import type { MetricsData, MetricSeries } from '@/lib/stats';
import type { MetricSeries } from '@/lib/stats';
import { formatBytes } from '@/lib/stats';
function useMetrics() {
const [data, setData] = useState<MetricsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/metrics');
if (res.ok) {
const json = await res.json();
setData(json);
setError(false);
} else {
setError(true);
}
} catch {
setError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
const interval = setInterval(refresh, 60000);
return () => clearInterval(interval);
}, [refresh]);
return { data, loading, error };
}
function formatTime(ts: number): string {
const d = new Date(ts * 1000);
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
@@ -131,9 +101,9 @@ interface SystemTrendsProps {
}
export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
const { data, loading, error } = useMetrics();
const { metrics } = usePortal();
if (error && !data) return null;
const loading = !metrics;
return (
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
@@ -154,7 +124,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
)}
</div>
<a
href="http://192.168.1.3:3333"
href={clientConfig.grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-slate-400 dark:text-stone-600 hover:text-slate-600 dark:hover:text-stone-400 transition-colors flex items-center gap-1"
@@ -165,18 +135,18 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
</div>
{/* Charts */}
{loading && !data ? (
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<ShimmerChart />
<ShimmerChart />
<ShimmerChart />
<ShimmerChart />
</div>
) : data ? (
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
<SparkChart
label="CPU"
series={data.cpu}
series={metrics.cpu}
color="#10b981"
fillColor="#10b981"
formatValue={(v) => `${v.toFixed(1)}%`}
@@ -184,7 +154,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
/>
<SparkChart
label="RAM"
series={data.ram}
series={metrics.ram}
color="#f59e0b"
fillColor="#f59e0b"
formatValue={(v) => `${v.toFixed(1)}%`}
@@ -192,7 +162,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
/>
<SparkChart
label="Temp"
series={data.temp}
series={metrics.temp}
color="#ef4444"
fillColor="#ef4444"
formatValue={(v) => `${v.toFixed(0)}`}
@@ -200,8 +170,8 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
/>
<SparkChart
label="Network"
series={data.netRx.map(([ts, val], i) => {
const tx = data.netTx[i]?.[1] || 0;
series={metrics.netRx.map(([ts, val], i) => {
const tx = metrics.netTx[i]?.[1] || 0;
return [ts, val + tx] as [number, number];
})}
color="#6366f1"
@@ -209,7 +179,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
formatValue={(v) => formatBytes(v)}
/>
</div>
) : null}
)}
</div>
);
}

View File

@@ -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<void>;
// SSE
connected: boolean;
metrics: MetricsData | null;
// Deploy action
triggerDeploy: (uuid: string) => Promise<void>;
// Active deploy logs
activeDeployLogs: Array<{ uuid: string; logs: string; status: string }>;
}
const PortalContext = createContext<PortalContextType | undefined>(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<HealthState>(() => {
// 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<Deployment[]>([]);
const [deploymentsLoading, setDeploymentsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('overview');
// Discovery state
const [discoveredServices, setDiscoveredServices] = useState<DiscoveredService[]>([]);
const [discoveryLoading, setDiscoveryLoading] = useState(true);
const [discoveryError, setDiscoveryError] = useState(false);
// System stats state
const [systemStats, setSystemStats] = useState<SystemStats | null>(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 (
<PortalContext.Provider
value={{
@@ -253,20 +147,24 @@ export function PortalProvider({ children }: { children: ReactNode }) {
filteredServices,
filteredBookmarks,
refreshHealth,
isRefreshing,
deployments,
deploymentsLoading,
isRefreshing: false,
deployments: stream.deployments,
deploymentsLoading: !stream.connected && stream.deployments.length === 0,
refreshDeployments,
activeTab,
setActiveTab,
discoveredServices,
discoveryLoading,
discoveryError,
discoveryLoading: !stream.connected && discoveredServices.length === 0,
discoveryError: !stream.connected && discoveredServices.length === 0,
refreshDiscover,
systemStats,
statsLoading,
statsError,
systemStats: stream.stats,
statsLoading: !stream.connected && !stream.stats,
statsError: false,
refreshStats,
connected: stream.connected,
metrics: stream.metrics,
triggerDeploy,
activeDeployLogs: stream.activeDeployLogs,
}}
>
{children}

128
src/lib/coolify-db.ts Normal file
View File

@@ -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<string, unknown>): 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<Deployment[]> {
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<string, string>();
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<Deployment | null> {
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<Array<{ uuid: string; logs: string; status: string }>> {
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 }>;
}

241
src/lib/event-manager.ts Normal file
View File

@@ -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<SSEClient>();
private timers: ReturnType<typeof setInterval>[] = [];
private cache: Record<string, { hash: string; data: unknown }> = {};
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<DiscoveredService[]> {
const nucHost = serverConfig.nucHost;
const resources = await fetchResources();
if (!resources) return [];
const detailPromises = resources.map(async (resource): Promise<DiscoveredService | null> => {
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;

136
src/lib/prometheus.ts Normal file
View File

@@ -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<number> {
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<Array<[number, number]>> {
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<SystemStats> {
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<MetricsData> {
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 };
}

View File

@@ -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

112
src/lib/useEventStream.ts Normal file
View File

@@ -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<EventStreamState>({
services: [],
stats: null,
deployments: [],
metrics: null,
activeDeployLogs: [],
connected: false,
error: null,
});
const retryDelay = useRef(1000);
const eventSourceRef = useRef<EventSource | null>(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;
}