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:
160
package-lock.json
generated
160
package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -1701,6 +1703,18 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.10",
|
"version": "19.2.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
|
||||||
@@ -5590,6 +5604,95 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5648,6 +5751,45 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6208,6 +6350,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -6924,6 +7075,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"recharts": "^3.7.0"
|
"recharts": "^3.7.0"
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
|
|||||||
@@ -1,104 +1,19 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchDeploymentDetail } from '@/lib/coolify-db';
|
||||||
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';
|
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: Request,
|
_request: Request,
|
||||||
{ params }: { params: Promise<{ uuid: string }> }
|
{ params }: { params: Promise<{ uuid: string }> }
|
||||||
) {
|
) {
|
||||||
const { uuid } = await params;
|
const { uuid } = await params;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let deployment: Record<string, unknown>;
|
const deployment = await fetchDeploymentDetail(uuid);
|
||||||
|
if (!deployment) {
|
||||||
if (IS_PRODUCTION) {
|
return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (deployment.error) {
|
|
||||||
return NextResponse.json({ error: deployment.error }, { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(deployment, {
|
return NextResponse.json(deployment, {
|
||||||
headers: {
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching deployment:', error);
|
console.error('Error fetching deployment:', error);
|
||||||
|
|||||||
@@ -1,145 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { Deployment, DeploymentStatus } from '@/lib/deployments';
|
import { fetchDeployments } from '@/lib/coolify-db';
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const deployments = await fetchDeploymentsFromCoolify();
|
const deployments = await fetchDeployments();
|
||||||
|
|
||||||
return NextResponse.json(deployments, {
|
return NextResponse.json(deployments, {
|
||||||
headers: {
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching deployments:', error);
|
console.error('Error fetching deployments:', error);
|
||||||
|
|||||||
@@ -1,48 +1,10 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { DiscoveredService, ServiceCategory } from '@/lib/services';
|
import type { DiscoveredService, ServiceCategory } from '@/lib/services';
|
||||||
import { lookupService, lookupDatabase } from '@/lib/service-registry';
|
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 { nucHost } = serverConfig;
|
||||||
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;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' {
|
function mapCoolifyStatus(status: string): 'running' | 'stopped' | 'unknown' {
|
||||||
if (status.startsWith('running')) return 'running';
|
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 {
|
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) {
|
if (fqdn) {
|
||||||
try {
|
try {
|
||||||
const url = new URL(fqdn);
|
const url = new URL(fqdn);
|
||||||
if (url.port) return parseInt(url.port, 10);
|
if (url.port) return parseInt(url.port, 10);
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try ports_mappings first (host:container format like "3030:3000")
|
|
||||||
if (portsMappings) {
|
if (portsMappings) {
|
||||||
const first = portsMappings.split(',')[0].trim();
|
const first = portsMappings.split(',')[0].trim();
|
||||||
const hostPort = first.split(':')[0];
|
const hostPort = first.split(':')[0];
|
||||||
if (hostPort) return parseInt(hostPort, 10);
|
if (hostPort) return parseInt(hostPort, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to ports_exposes (container port)
|
|
||||||
if (portsExposes) {
|
if (portsExposes) {
|
||||||
const first = portsExposes.split(',')[0].trim();
|
const first = portsExposes.split(',')[0].trim();
|
||||||
return parseInt(first, 10);
|
return parseInt(first, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPortFromServicePorts(ports: string | null): number {
|
function extractPortFromServicePorts(ports: string | null): number {
|
||||||
if (!ports) return 0;
|
if (!ports) return 0;
|
||||||
// Service ports can be "22222:22" or "3030:3000"
|
|
||||||
const first = ports.split(',')[0].trim();
|
const first = ports.split(',')[0].trim();
|
||||||
const parts = first.split(':');
|
const parts = first.split(':');
|
||||||
return parseInt(parts[0], 10) || 0;
|
return parseInt(parts[0], 10) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanServiceName(name: string): string {
|
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, '');
|
return name.replace(/-[a-z0-9]{20,}$/i, '').replace(/_[a-z0-9]{20,}$/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(fqdn: string | null, port: number): string {
|
function buildUrl(fqdn: string | null, port: number): string {
|
||||||
if (fqdn) {
|
if (fqdn) {
|
||||||
// Use the FQDN as-is if it looks like a proper URL
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(fqdn);
|
const url = new URL(fqdn);
|
||||||
// If it's an sslip.io address, replace with nuc.lan
|
|
||||||
if (url.hostname.includes('sslip.io')) {
|
if (url.hostname.includes('sslip.io')) {
|
||||||
return `http://${NUC_HOST}:${port || url.port || 80}`;
|
return `http://${nucHost}:${port || url.port || 80}`;
|
||||||
}
|
}
|
||||||
return fqdn;
|
return fqdn;
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
if (port > 0) {
|
if (port > 0) return `http://${nucHost}:${port}`;
|
||||||
return `http://${NUC_HOST}:${port}`;
|
return `http://${nucHost}`;
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
// Step 1: Get all resources from the server
|
const resources = await fetchResources();
|
||||||
const resources = await fetchJson<CoolifyResource[]>(
|
|
||||||
`${COOLIFY_API}/servers/${SERVER_UUID}/resources`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!resources) {
|
if (!resources) {
|
||||||
return NextResponse.json(
|
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 detailPromises = resources.map(async (resource): Promise<DiscoveredService | null> => {
|
||||||
const healthStatus = mapCoolifyStatus(resource.status);
|
|
||||||
|
|
||||||
if (resource.type === 'application') {
|
if (resource.type === 'application') {
|
||||||
const detail = await fetchJson<CoolifyAppDetail>(
|
const detail = await fetchAppDetail(resource.uuid);
|
||||||
`${COOLIFY_API}/applications/${resource.uuid}`
|
|
||||||
);
|
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
|
|
||||||
const port = extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings);
|
const port = extractPort(detail.fqdn, detail.ports_exposes, detail.ports_mappings);
|
||||||
@@ -163,12 +92,9 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resource.type === 'service') {
|
if (resource.type === 'service') {
|
||||||
const detail = await fetchJson<CoolifyServiceDetail>(
|
const detail = await fetchServiceDetail(resource.uuid);
|
||||||
`${COOLIFY_API}/services/${resource.uuid}`
|
|
||||||
);
|
|
||||||
if (!detail) return null;
|
if (!detail) return null;
|
||||||
|
|
||||||
// A Coolify "service" can contain multiple applications. Use the primary one.
|
|
||||||
const app = detail.applications?.[0];
|
const app = detail.applications?.[0];
|
||||||
const cleanName = cleanServiceName(resource.name);
|
const cleanName = cleanServiceName(resource.name);
|
||||||
const meta = lookupService(cleanName);
|
const meta = lookupService(cleanName);
|
||||||
@@ -202,7 +128,7 @@ export async function GET() {
|
|||||||
const meta = lookupDatabase(resource.type, resource.name);
|
const meta = lookupDatabase(resource.type, resource.name);
|
||||||
return {
|
return {
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
url: `http://${NUC_HOST}:8000`,
|
url: `http://${nucHost}:8000`,
|
||||||
port: 0,
|
port: 0,
|
||||||
icon: meta.icon,
|
icon: meta.icon,
|
||||||
category: meta.category as ServiceCategory,
|
category: meta.category as ServiceCategory,
|
||||||
@@ -226,7 +152,6 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: running first, then by name
|
|
||||||
discovered.sort((a, b) => {
|
discovered.sort((a, b) => {
|
||||||
const aRunning = a.coolifyStatus.startsWith('running') ? 0 : 1;
|
const aRunning = a.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
const bRunning = b.coolifyStatus.startsWith('running') ? 0 : 1;
|
const bRunning = b.coolifyStatus.startsWith('running') ? 0 : 1;
|
||||||
|
|||||||
41
src/app/api/events/route.ts
Normal file
41
src/app/api/events/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,22 +1,18 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { services } from '@/lib/services';
|
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'> {
|
async function checkServiceHealth(port: number, timeout = 3000): Promise<'running' | 'stopped'> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
const response = await fetch(`http://${nucHost}:${port}`, {
|
||||||
const response = await fetch(`http://${NUC_HOST}:${port}`, {
|
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}).catch(() => null);
|
}).catch(() => null);
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
// If we get any response (even 404, 403, etc.), the service is running
|
|
||||||
return response ? 'running' : 'stopped';
|
return response ? 'running' : 'stopped';
|
||||||
} catch {
|
} catch {
|
||||||
return 'stopped';
|
return 'stopped';
|
||||||
@@ -26,7 +22,6 @@ async function checkServiceHealth(port: number, timeout = 3000): Promise<'runnin
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const healthStatus: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
const healthStatus: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||||
|
|
||||||
// Check all services in parallel
|
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
services.map(async (service) => {
|
services.map(async (service) => {
|
||||||
const status = await checkServiceHealth(service.port);
|
const status = await checkServiceHealth(service.port);
|
||||||
@@ -34,19 +29,15 @@ export async function GET() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process results
|
|
||||||
results.forEach((result) => {
|
results.forEach((result) => {
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
healthStatus[result.value.name] = result.value.status;
|
healthStatus[result.value.name] = result.value.status;
|
||||||
} else {
|
} else {
|
||||||
// If promise rejected, mark as unknown
|
|
||||||
healthStatus[(result.reason as { name: string })?.name] = 'unknown';
|
healthStatus[(result.reason as { name: string })?.name] = 'unknown';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json(healthStatus, {
|
return NextResponse.json(healthStatus, {
|
||||||
headers: {
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,12 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
|
import { fetchRangeMetrics } from '@/lib/prometheus';
|
||||||
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)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const end = Math.floor(Date.now() / 1000);
|
const metrics = await fetchRangeMetrics();
|
||||||
const start = end - 6 * 3600; // 6 hours
|
return NextResponse.json(metrics, {
|
||||||
const step = 120; // 2-minute resolution
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
|
});
|
||||||
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' } }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Metrics error:', error);
|
console.error('Metrics error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,44 +1,11 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { SystemStats } from '@/lib/stats';
|
import { fetchInstantStats } from '@/lib/prometheus';
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const stats = await fetchStats();
|
const stats = await fetchInstantStats();
|
||||||
|
|
||||||
return NextResponse.json(stats, {
|
return NextResponse.json(stats, {
|
||||||
headers: {
|
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching stats:', error);
|
console.error('Error fetching stats:', error);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable, OverviewTab } from '@/components';
|
import { Header, SearchBar, ServiceCard, BookmarkCard, CategorySection, Icon, DeploymentsTable, OverviewTab } from '@/components';
|
||||||
import { usePortal } from '@/lib/PortalContext';
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services';
|
import { categoryLabels, categoryOrder, bookmarkCategoryLabels, bookmarkCategoryOrder, ServiceCategory, BookmarkCategory } from '@/lib/services';
|
||||||
|
|
||||||
type TabId = 'overview' | 'services' | 'bookmarks' | 'ai' | 'deployments' | 'settings';
|
type TabId = 'overview' | 'services' | 'bookmarks' | 'ai' | 'deployments' | 'settings';
|
||||||
@@ -44,6 +45,8 @@ export default function Home() {
|
|||||||
discoveredServices,
|
discoveredServices,
|
||||||
discoveryLoading,
|
discoveryLoading,
|
||||||
discoveryError,
|
discoveryError,
|
||||||
|
triggerDeploy,
|
||||||
|
connected,
|
||||||
} = usePortal();
|
} = usePortal();
|
||||||
|
|
||||||
// Group services by category
|
// Group services by category
|
||||||
@@ -80,12 +83,10 @@ export default function Home() {
|
|||||||
case 'services':
|
case 'services':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search */}
|
|
||||||
<div className="mb-8 max-w-xl">
|
<div className="mb-8 max-w-xl">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status summary */}
|
|
||||||
<div className="mb-6 flex items-center gap-4 text-sm">
|
<div className="mb-6 flex items-center gap-4 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
@@ -110,7 +111,6 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No results message */}
|
|
||||||
{noResults && (
|
{noResults && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-slate-500 dark:text-stone-500">
|
<p className="text-slate-500 dark:text-stone-500">
|
||||||
@@ -119,7 +119,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Services */}
|
|
||||||
{hasServices && (
|
{hasServices && (
|
||||||
<div>
|
<div>
|
||||||
{Object.entries(servicesByCategory).map(([category, services]) => (
|
{Object.entries(servicesByCategory).map(([category, services]) => (
|
||||||
@@ -146,12 +145,10 @@ export default function Home() {
|
|||||||
case 'bookmarks':
|
case 'bookmarks':
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Search */}
|
|
||||||
<div className="mb-8 max-w-xl">
|
<div className="mb-8 max-w-xl">
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No results message */}
|
|
||||||
{searchQuery && !hasBookmarks && (
|
{searchQuery && !hasBookmarks && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-slate-500 dark:text-stone-500">
|
<p className="text-slate-500 dark:text-stone-500">
|
||||||
@@ -160,7 +157,6 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bookmarks */}
|
|
||||||
{hasBookmarks && (
|
{hasBookmarks && (
|
||||||
<div>
|
<div>
|
||||||
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => (
|
{Object.entries(bookmarksByCategory).map(([category, bookmarks]) => (
|
||||||
@@ -228,12 +224,19 @@ export default function Home() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-slate-500 dark:text-stone-500">
|
<p className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
All deployments across Coolify applications
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<DeploymentsTable
|
<DeploymentsTable
|
||||||
deployments={deployments}
|
deployments={deployments}
|
||||||
isLoading={deploymentsLoading}
|
isLoading={deploymentsLoading}
|
||||||
onRefresh={refreshDeployments}
|
onRefresh={refreshDeployments}
|
||||||
|
onDeploy={triggerDeploy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -246,7 +249,6 @@ export default function Home() {
|
|||||||
Appearance
|
Appearance
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Dark Mode Toggle */}
|
|
||||||
<div className="flex items-center justify-between py-4 border-b border-slate-100 dark:border-stone-800">
|
<div className="flex items-center justify-between py-4 border-b border-slate-100 dark:border-stone-800">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-slate-900 dark:text-stone-100">Dark Mode</h3>
|
<h3 className="font-medium text-slate-900 dark:text-stone-100">Dark Mode</h3>
|
||||||
@@ -279,7 +281,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-stone-500">Server IP</span>
|
<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>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-stone-500">Services</span>
|
<span className="text-slate-500 dark:text-stone-500">Services</span>
|
||||||
@@ -291,6 +293,12 @@ export default function Home() {
|
|||||||
{isDiscovered ? 'Coolify API' : 'Static'}
|
{isDiscovered ? 'Coolify API' : 'Static'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex justify-between">
|
||||||
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
<span className="text-slate-500 dark:text-stone-500">Bookmarks</span>
|
||||||
<span className="text-slate-900 dark:text-stone-100">{filteredBookmarks.length}</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">
|
<div className="mt-8 pt-6 border-t border-slate-100 dark:border-stone-800">
|
||||||
<a
|
<a
|
||||||
href="http://192.168.1.3:3030/nuc/nuc-portal"
|
href={`http://${clientConfig.nucHost}:3030/alezmad/nuc-portal`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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()}
|
{renderTabContent()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="text-center py-8 text-sm text-slate-400 dark:text-stone-600">
|
<footer className="text-center py-8 text-sm text-slate-400 dark:text-stone-600">
|
||||||
<span>NUC Portal</span>
|
<span>NUC Portal</span>
|
||||||
<span className="mx-2">•</span>
|
<span className="mx-2">•</span>
|
||||||
<span>192.168.1.3</span>
|
<span>{clientConfig.nucHost}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments';
|
import { parseDeploymentLogs, DeploymentLog, DeploymentStatus } from '@/lib/deployments';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
import { Icon } from './Icons';
|
import { Icon } from './Icons';
|
||||||
|
|
||||||
interface DeploymentLogsProps {
|
interface DeploymentLogsProps {
|
||||||
@@ -10,10 +11,8 @@ interface DeploymentLogsProps {
|
|||||||
initialLogs?: string;
|
initialLogs?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Color log lines based on content
|
|
||||||
function getLogLineStyle(log: DeploymentLog): string {
|
function getLogLineStyle(log: DeploymentLog): string {
|
||||||
const output = log.output.toLowerCase();
|
const output = log.output.toLowerCase();
|
||||||
|
|
||||||
if (log.type === 'stderr' || output.includes('error') || output.includes('failed')) {
|
if (log.type === 'stderr' || output.includes('error') || output.includes('failed')) {
|
||||||
return 'text-red-500 dark:text-red-400';
|
return 'text-red-500 dark:text-red-400';
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,6 @@ function getLogLineStyle(log: DeploymentLog): string {
|
|||||||
if (output.startsWith('---') || output.startsWith('===') || output.startsWith('###')) {
|
if (output.startsWith('---') || output.startsWith('===') || output.startsWith('###')) {
|
||||||
return 'text-cyan-600 dark:text-cyan-400 font-semibold';
|
return 'text-cyan-600 dark:text-cyan-400 font-semibold';
|
||||||
}
|
}
|
||||||
// Commands often start with $ or >
|
|
||||||
if (output.startsWith('$') || output.startsWith('>') || output.startsWith('#')) {
|
if (output.startsWith('$') || output.startsWith('>') || output.startsWith('#')) {
|
||||||
return 'text-purple-600 dark:text-purple-400';
|
return 'text-purple-600 dark:text-purple-400';
|
||||||
}
|
}
|
||||||
@@ -34,55 +32,50 @@ function getLogLineStyle(log: DeploymentLog): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) {
|
export function DeploymentLogs({ deploymentUuid, status, initialLogs }: DeploymentLogsProps) {
|
||||||
|
const { activeDeployLogs } = usePortal();
|
||||||
const [logs, setLogs] = useState<DeploymentLog[]>(() => parseDeploymentLogs(initialLogs));
|
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 [copied, setCopied] = useState(false);
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [autoScroll, setAutoScroll] = useState(true);
|
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 () => {
|
const fetchLogs = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/deployments/${deploymentUuid}`);
|
const response = await fetch(`/api/deployments/${deploymentUuid}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const parsedLogs = parseDeploymentLogs(data.logs);
|
setLogs(parseDeploymentLogs(data.logs));
|
||||||
setLogs(parsedLogs);
|
|
||||||
|
|
||||||
// Stop polling if deployment finished
|
|
||||||
if (data.status !== 'in_progress' && data.status !== 'queued') {
|
|
||||||
setIsPolling(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch logs:', error);
|
console.error('Failed to fetch logs:', error);
|
||||||
}
|
}
|
||||||
}, [deploymentUuid]);
|
}, [deploymentUuid]);
|
||||||
|
|
||||||
// Poll for logs while deployment is in progress
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPolling) return;
|
if (!initialLogs && !isActive) {
|
||||||
|
|
||||||
const interval = setInterval(fetchLogs, 2000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [isPolling, fetchLogs]);
|
|
||||||
|
|
||||||
// Initial fetch if no logs provided
|
|
||||||
useEffect(() => {
|
|
||||||
if (!initialLogs) {
|
|
||||||
fetchLogs();
|
fetchLogs();
|
||||||
}
|
}
|
||||||
}, [initialLogs, fetchLogs]);
|
}, [initialLogs, isActive, fetchLogs]);
|
||||||
|
|
||||||
// Auto-scroll to bottom when new logs arrive (within container only)
|
// Auto-scroll
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && containerRef.current) {
|
if (autoScroll && containerRef.current) {
|
||||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [logs, autoScroll]);
|
}, [logs, autoScroll]);
|
||||||
|
|
||||||
// Detect manual scroll to disable auto-scroll
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||||
@@ -105,42 +98,30 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
className="fixed inset-0 z-50 bg-black/50 flex items-center justify-center p-4"
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||||
e.stopPropagation();
|
|
||||||
setIsExpanded(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-6xl h-[80vh] bg-slate-900 rounded-xl shadow-2xl flex flex-col"
|
className="w-full max-w-6xl h-[80vh] bg-slate-900 rounded-xl shadow-2xl flex flex-col"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
<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>
|
<span className="text-sm font-medium text-slate-200">Build Logs - {deploymentUuid.substring(0, 12)}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isPolling && (
|
{isActive && (
|
||||||
<span className="flex items-center gap-1 text-xs text-slate-400">
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||||
<span className="animate-spin">
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
<Icon name="refresh-cw" size={12} />
|
Live
|
||||||
</span>
|
|
||||||
Polling
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
|
||||||
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"
|
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} />
|
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||||
{copied ? 'Copied' : 'Copy'}
|
{copied ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(false); }}
|
||||||
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"
|
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} />
|
<Icon name="x" size={14} />
|
||||||
@@ -148,8 +129,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
@@ -157,18 +136,14 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
>
|
>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-slate-500 text-center py-8">
|
<div className="text-slate-500 text-center py-8">
|
||||||
{isPolling ? 'Waiting for logs...' : 'No logs available'}
|
{isActive ? 'Waiting for logs...' : 'No logs available'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
logs.map((log, index) => (
|
||||||
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
||||||
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">
|
<span className="text-slate-600 mr-3 select-none shrink-0 w-16 text-right">{index + 1}</span>
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
{log.timestamp && (
|
{log.timestamp && (
|
||||||
<span className="text-slate-500 mr-3 select-none shrink-0">
|
<span className="text-slate-500 mr-3 select-none shrink-0">{log.timestamp}</span>
|
||||||
{log.timestamp}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span className="whitespace-pre-wrap break-all">{log.output}</span>
|
<span className="whitespace-pre-wrap break-all">{log.output}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,8 +151,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
)}
|
)}
|
||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="px-4 py-2 border-t border-slate-700 text-xs text-slate-500">
|
<div className="px-4 py-2 border-t border-slate-700 text-xs text-slate-500">
|
||||||
{logs.length} lines
|
{logs.length} lines
|
||||||
</div>
|
</div>
|
||||||
@@ -189,33 +162,24 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
// Inline view
|
// Inline view
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-slate-200 dark:border-stone-800 bg-slate-900 dark:bg-stone-950">
|
<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">
|
<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>
|
<span className="text-sm font-medium text-slate-300">Build Logs</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isPolling && (
|
{isActive && (
|
||||||
<span className="flex items-center gap-1 text-xs text-slate-500">
|
<span className="flex items-center gap-1 text-xs text-emerald-400">
|
||||||
<span className="animate-spin">
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
<Icon name="refresh-cw" size={12} />
|
Live
|
||||||
</span>
|
|
||||||
2s
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(); }}
|
||||||
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"
|
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} />
|
<Icon name={copied ? 'check' : 'copy'} size={14} />
|
||||||
{copied ? 'Copied' : 'Copy'}
|
{copied ? 'Copied' : 'Copy'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => { e.stopPropagation(); setIsExpanded(true); }}
|
||||||
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"
|
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} />
|
<Icon name="maximize-2" size={14} />
|
||||||
@@ -223,8 +187,6 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logs */}
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
@@ -233,22 +195,18 @@ export function DeploymentLogs({ deploymentUuid, status, initialLogs }: Deployme
|
|||||||
>
|
>
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-slate-500 text-center py-8">
|
<div className="text-slate-500 text-center py-8">
|
||||||
{isPolling ? 'Waiting for logs...' : 'No logs available'}
|
{isActive ? 'Waiting for logs...' : 'No logs available'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log, index) => (
|
logs.map((log, index) => (
|
||||||
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
<div key={index} className={`flex ${getLogLineStyle(log)}`}>
|
||||||
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">
|
<span className="text-slate-600 mr-2 select-none shrink-0 w-8 text-right">{index + 1}</span>
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
|
<span className="whitespace-pre-wrap break-words min-w-0">{log.output}</span>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
<div ref={logsEndRef} />
|
<div ref={logsEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto-scroll indicator */}
|
|
||||||
{!autoScroll && logs.length > 0 && (
|
{!autoScroll && logs.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useMemo, Fragment } from 'react';
|
import { useState, useMemo, useEffect, Fragment } from 'react';
|
||||||
import {
|
import {
|
||||||
useReactTable,
|
useReactTable,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
truncateCommitMessage,
|
truncateCommitMessage,
|
||||||
} from '@/lib/deployments';
|
} from '@/lib/deployments';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
import { Icon } from './Icons';
|
import { Icon } from './Icons';
|
||||||
import { DeploymentLogs } from './DeploymentLogs';
|
import { DeploymentLogs } from './DeploymentLogs';
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ interface DeploymentsTableProps {
|
|||||||
deployments: Deployment[];
|
deployments: Deployment[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onDeploy?: (uuid: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnHelper = createColumnHelper<Deployment>();
|
const columnHelper = createColumnHelper<Deployment>();
|
||||||
@@ -50,7 +52,25 @@ const StatusBadge = ({ status }: { status: DeploymentStatus }) => (
|
|||||||
</span>
|
</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>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: 'created_at', desc: true },
|
{ id: 'created_at', desc: true },
|
||||||
]);
|
]);
|
||||||
@@ -59,13 +79,11 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
|
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
|
||||||
const [appFilter, setAppFilter] = useState<string>('all');
|
const [appFilter, setAppFilter] = useState<string>('all');
|
||||||
|
|
||||||
// Get unique application names for filter dropdown
|
|
||||||
const applicationNames = useMemo(() => {
|
const applicationNames = useMemo(() => {
|
||||||
const names = new Set(deployments.map((d) => d.application_name));
|
const names = new Set(deployments.map((d) => d.application_name));
|
||||||
return Array.from(names).sort();
|
return Array.from(names).sort();
|
||||||
}, [deployments]);
|
}, [deployments]);
|
||||||
|
|
||||||
// Filter deployments
|
|
||||||
const filteredDeployments = useMemo(() => {
|
const filteredDeployments = useMemo(() => {
|
||||||
return deployments.filter((d) => {
|
return deployments.filter((d) => {
|
||||||
if (statusFilter !== 'all' && d.status !== statusFilter) return false;
|
if (statusFilter !== 'all' && d.status !== statusFilter) return false;
|
||||||
@@ -110,7 +128,10 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
}),
|
}),
|
||||||
columnHelper.accessor('duration', {
|
columnHelper.accessor('duration', {
|
||||||
header: 'Duration',
|
header: 'Duration',
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue, row }) => {
|
||||||
|
if (row.original.status === 'in_progress') {
|
||||||
|
return <LiveDuration createdAt={row.original.created_at} />;
|
||||||
|
}
|
||||||
const duration = getValue();
|
const duration = getValue();
|
||||||
return (
|
return (
|
||||||
<span className="text-slate-500 dark:text-stone-400 text-sm">
|
<span className="text-slate-500 dark:text-stone-400 text-sm">
|
||||||
@@ -127,7 +148,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
header: 'Application',
|
header: 'Application',
|
||||||
cell: ({ getValue, row }) => (
|
cell: ({ getValue, row }) => (
|
||||||
<a
|
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white"
|
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 }) => (
|
cell: ({ getValue, row }) => (
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-400">
|
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-400">
|
||||||
<span>{formatRelativeTime(getValue())}</span>
|
<span>{formatRelativeTime(getValue())}</span>
|
||||||
<span className="text-slate-400 dark:text-stone-600">by</span>
|
<span className="text-slate-400 dark:text-stone-600">
|
||||||
<span>{row.original.is_webhook ? 'webhook' : 'API'}</span>
|
{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>
|
</div>
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
@@ -175,6 +201,18 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
id: 'actions',
|
id: 'actions',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex items-center gap-1">
|
<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 && (
|
{row.original.application_fqdn && (
|
||||||
<a
|
<a
|
||||||
href={row.original.application_fqdn}
|
href={row.original.application_fqdn}
|
||||||
@@ -188,7 +226,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
<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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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({
|
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);
|
const activeFilterCount = (statusFilter !== 'all' ? 1 : 0) + (appFilter !== 'all' ? 1 : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +281,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Application Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={appFilter}
|
value={appFilter}
|
||||||
onChange={(e) => setAppFilter(e.target.value)}
|
onChange={(e) => setAppFilter(e.target.value)}
|
||||||
@@ -252,13 +288,10 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
>
|
>
|
||||||
<option value="all">All Applications</option>
|
<option value="all">All Applications</option>
|
||||||
{applicationNames.map((name) => (
|
{applicationNames.map((name) => (
|
||||||
<option key={name} value={name}>
|
<option key={name} value={name}>{name}</option>
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Status Filter */}
|
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value as DeploymentStatus | 'all')}
|
onChange={(e) => setStatusFilter(e.target.value as DeploymentStatus | 'all')}
|
||||||
@@ -274,10 +307,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
|
|
||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => { setStatusFilter('all'); setAppFilter('all'); }}
|
||||||
setStatusFilter('all');
|
|
||||||
setAppFilter('all');
|
|
||||||
}}
|
|
||||||
className="text-xs text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
|
className="text-xs text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300"
|
||||||
>
|
>
|
||||||
Clear filters
|
Clear filters
|
||||||
@@ -289,18 +319,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
<span className="text-sm text-slate-500 dark:text-stone-500">
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
{filteredDeployments.length} deployments
|
{filteredDeployments.length} deployments
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -325,12 +343,8 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
{header.column.getIsSorted() === 'asc' && (
|
{header.column.getIsSorted() === 'asc' && <Icon name="chevron-up" size={14} />}
|
||||||
<Icon name="chevron-up" size={14} />
|
{header.column.getIsSorted() === 'desc' && <Icon name="chevron-down" size={14} />}
|
||||||
)}
|
|
||||||
{header.column.getIsSorted() === 'desc' && (
|
|
||||||
<Icon name="chevron-down" size={14} />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
@@ -384,7 +398,6 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh }: Deployme
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{filteredDeployments.length > 25 && (
|
{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 justify-between px-4 py-3 border-t border-slate-200 dark:border-stone-800">
|
||||||
<div className="flex items-center gap-2">
|
<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"
|
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) => (
|
{[25, 50, 100].map((size) => (
|
||||||
<option key={size} value={size}>
|
<option key={size} value={size}>{size}</option>
|
||||||
{size}
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-sm text-slate-500 dark:text-stone-500">
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
||||||
Page {table.getState().pagination.pageIndex + 1} of{' '}
|
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
|
||||||
{table.getPageCount()}
|
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { usePortal } from '@/lib/PortalContext';
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
import { Icon } from './Icons';
|
import { Icon } from './Icons';
|
||||||
import { VitalsBar } from './VitalsBar';
|
import { VitalsBar } from './VitalsBar';
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
|
export function Header({ activeTab, onTabChange, tabs }: HeaderProps) {
|
||||||
const { darkMode, setDarkMode, refreshHealth, isRefreshing } = usePortal();
|
const { darkMode, setDarkMode, connected } = usePortal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-white dark:bg-stone-950 border-b border-slate-200 dark:border-stone-800">
|
<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
|
NUC Portal
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-500 dark:text-stone-500">
|
<p className="text-xs text-slate-500 dark:text-stone-500">
|
||||||
192.168.1.3
|
{clientConfig.nucHost}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Refresh button */}
|
{/* SSE connection indicator */}
|
||||||
<button
|
<div
|
||||||
onClick={refreshHealth}
|
className="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs"
|
||||||
disabled={isRefreshing}
|
title={connected ? 'Real-time connection active' : 'Reconnecting...'}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<span className={`w-2 h-2 rounded-full ${
|
||||||
name="refresh-cw"
|
connected
|
||||||
size={18}
|
? 'bg-emerald-500 animate-pulse'
|
||||||
className={`text-slate-600 dark:text-stone-400 ${isRefreshing ? 'animate-spin' : ''}`}
|
: 'bg-red-500'
|
||||||
/>
|
}`} />
|
||||||
</button>
|
<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 */}
|
{/* Dark mode toggle */}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,42 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
import { AreaChart, Area, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||||
|
import { usePortal } from '@/lib/PortalContext';
|
||||||
|
import { clientConfig } from '@/lib/config';
|
||||||
import { Icon } from './Icons';
|
import { Icon } from './Icons';
|
||||||
import type { MetricsData, MetricSeries } from '@/lib/stats';
|
import type { MetricSeries } from '@/lib/stats';
|
||||||
import { formatBytes } 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 {
|
function formatTime(ts: number): string {
|
||||||
const d = new Date(ts * 1000);
|
const d = new Date(ts * 1000);
|
||||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
|
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) {
|
export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
||||||
const { data, loading, error } = useMetrics();
|
const { metrics } = usePortal();
|
||||||
|
|
||||||
if (error && !data) return null;
|
const loading = !metrics;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-100 dark:border-stone-700/50 shadow-sm p-5">
|
<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>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="http://192.168.1.3:3333"
|
href={clientConfig.grafanaUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Charts */}
|
{/* Charts */}
|
||||||
{loading && !data ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
<ShimmerChart />
|
<ShimmerChart />
|
||||||
<ShimmerChart />
|
<ShimmerChart />
|
||||||
<ShimmerChart />
|
<ShimmerChart />
|
||||||
<ShimmerChart />
|
<ShimmerChart />
|
||||||
</div>
|
</div>
|
||||||
) : data ? (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
<SparkChart
|
<SparkChart
|
||||||
label="CPU"
|
label="CPU"
|
||||||
series={data.cpu}
|
series={metrics.cpu}
|
||||||
color="#10b981"
|
color="#10b981"
|
||||||
fillColor="#10b981"
|
fillColor="#10b981"
|
||||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
@@ -184,7 +154,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
|||||||
/>
|
/>
|
||||||
<SparkChart
|
<SparkChart
|
||||||
label="RAM"
|
label="RAM"
|
||||||
series={data.ram}
|
series={metrics.ram}
|
||||||
color="#f59e0b"
|
color="#f59e0b"
|
||||||
fillColor="#f59e0b"
|
fillColor="#f59e0b"
|
||||||
formatValue={(v) => `${v.toFixed(1)}%`}
|
formatValue={(v) => `${v.toFixed(1)}%`}
|
||||||
@@ -192,7 +162,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
|||||||
/>
|
/>
|
||||||
<SparkChart
|
<SparkChart
|
||||||
label="Temp"
|
label="Temp"
|
||||||
series={data.temp}
|
series={metrics.temp}
|
||||||
color="#ef4444"
|
color="#ef4444"
|
||||||
fillColor="#ef4444"
|
fillColor="#ef4444"
|
||||||
formatValue={(v) => `${v.toFixed(0)}`}
|
formatValue={(v) => `${v.toFixed(0)}`}
|
||||||
@@ -200,8 +170,8 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
|||||||
/>
|
/>
|
||||||
<SparkChart
|
<SparkChart
|
||||||
label="Network"
|
label="Network"
|
||||||
series={data.netRx.map(([ts, val], i) => {
|
series={metrics.netRx.map(([ts, val], i) => {
|
||||||
const tx = data.netTx[i]?.[1] || 0;
|
const tx = metrics.netTx[i]?.[1] || 0;
|
||||||
return [ts, val + tx] as [number, number];
|
return [ts, val + tx] as [number, number];
|
||||||
})}
|
})}
|
||||||
color="#6366f1"
|
color="#6366f1"
|
||||||
@@ -209,7 +179,7 @@ export function SystemTrends({ uptimeLabel, loadAvg }: SystemTrendsProps) {
|
|||||||
formatValue={(v) => formatBytes(v)}
|
formatValue={(v) => formatBytes(v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
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 { 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';
|
export type HealthStatus = 'running' | 'stopped' | 'unknown' | 'loading';
|
||||||
|
|
||||||
@@ -39,35 +40,24 @@ interface PortalContextType {
|
|||||||
statsLoading: boolean;
|
statsLoading: boolean;
|
||||||
statsError: boolean;
|
statsError: boolean;
|
||||||
refreshStats: () => Promise<void>;
|
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);
|
const PortalContext = createContext<PortalContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function PortalProvider({ children }: { children: ReactNode }) {
|
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 [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');
|
const [activeTab, setActiveTab] = useState('overview');
|
||||||
|
|
||||||
// Discovery state
|
// SSE stream
|
||||||
const [discoveredServices, setDiscoveredServices] = useState<DiscoveredService[]>([]);
|
const stream = useEventStream();
|
||||||
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);
|
|
||||||
|
|
||||||
// Apply dark mode to document
|
// Apply dark mode to document
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,136 +80,25 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('portal-dark-mode', String(darkMode));
|
localStorage.setItem('portal-dark-mode', String(darkMode));
|
||||||
}, [darkMode]);
|
}, [darkMode]);
|
||||||
|
|
||||||
// Fetch health status (used as fallback when discovery is unavailable)
|
// Derive health status from discovered services
|
||||||
const refreshHealth = useCallback(async () => {
|
const healthStatus: HealthState = {};
|
||||||
setIsRefreshing(true);
|
const discoveredServices = stream.services as DiscoveredService[];
|
||||||
try {
|
for (const svc of discoveredServices) {
|
||||||
const response = await fetch('/api/health');
|
if (svc.coolifyStatus?.startsWith('running')) {
|
||||||
if (response.ok) {
|
healthStatus[svc.name] = 'running';
|
||||||
const data = await response.json();
|
} else if (svc.coolifyStatus?.startsWith('exited') || svc.coolifyStatus === 'stopped') {
|
||||||
setHealthStatus(data);
|
healthStatus[svc.name] = 'stopped';
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
healthStatus[svc.name] = 'unknown';
|
||||||
console.error('Failed to fetch health status:', error);
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}
|
||||||
|
|
||||||
// Fetch discovered services from Coolify
|
// Active services: discovered or fallback
|
||||||
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
|
|
||||||
const activeServices: Service[] = discoveredServices.length > 0
|
const activeServices: Service[] = discoveredServices.length > 0
|
||||||
? discoveredServices
|
? discoveredServices
|
||||||
: fallbackServices;
|
: fallbackServices;
|
||||||
|
|
||||||
// Filter services and bookmarks based on search query
|
// Filter services and bookmarks
|
||||||
const filteredServices = activeServices.filter(service => {
|
const filteredServices = activeServices.filter(service => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const query = searchQuery.toLowerCase();
|
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 (
|
return (
|
||||||
<PortalContext.Provider
|
<PortalContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -253,20 +147,24 @@ export function PortalProvider({ children }: { children: ReactNode }) {
|
|||||||
filteredServices,
|
filteredServices,
|
||||||
filteredBookmarks,
|
filteredBookmarks,
|
||||||
refreshHealth,
|
refreshHealth,
|
||||||
isRefreshing,
|
isRefreshing: false,
|
||||||
deployments,
|
deployments: stream.deployments,
|
||||||
deploymentsLoading,
|
deploymentsLoading: !stream.connected && stream.deployments.length === 0,
|
||||||
refreshDeployments,
|
refreshDeployments,
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
discoveredServices,
|
discoveredServices,
|
||||||
discoveryLoading,
|
discoveryLoading: !stream.connected && discoveredServices.length === 0,
|
||||||
discoveryError,
|
discoveryError: !stream.connected && discoveredServices.length === 0,
|
||||||
refreshDiscover,
|
refreshDiscover,
|
||||||
systemStats,
|
systemStats: stream.stats,
|
||||||
statsLoading,
|
statsLoading: !stream.connected && !stream.stats,
|
||||||
statsError,
|
statsError: false,
|
||||||
refreshStats,
|
refreshStats,
|
||||||
|
connected: stream.connected,
|
||||||
|
metrics: stream.metrics,
|
||||||
|
triggerDeploy,
|
||||||
|
activeDeployLogs: stream.activeDeployLogs,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
128
src/lib/coolify-db.ts
Normal file
128
src/lib/coolify-db.ts
Normal 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
241
src/lib/event-manager.ts
Normal 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
136
src/lib/prometheus.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -27,36 +27,40 @@ export interface Bookmark {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { clientConfig } from './config';
|
||||||
|
|
||||||
|
const h = clientConfig.nucHost;
|
||||||
|
|
||||||
export const fallbackServices: Service[] = [
|
export const fallbackServices: Service[] = [
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
{ name: 'Coolify', url: 'http://192.168.1.3:8000', port: 8000, icon: 'server', category: 'infrastructure', description: 'Container deployment & management' },
|
{ name: 'Coolify', url: `http://${h}: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: 'Dozzle', url: `http://${h}: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: 'Playwriter Browser', url: `http://${h}:6081/vnc.html`, port: 6081, icon: 'monitor', category: 'infrastructure', description: 'Remote browser for automation' },
|
||||||
|
|
||||||
// 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
|
// Development
|
||||||
{ name: 'Gitea', url: 'http://192.168.1.3:3030', port: 3030, icon: 'git-branch', category: 'development', description: 'Self-hosted Git service' },
|
{ name: 'Gitea', url: `http://${h}: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: 'CloudBeaver', url: `http://${h}: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: 'Adminer', url: `http://${h}:8088`, port: 8088, icon: 'table', category: 'development', description: 'Lightweight database admin' },
|
||||||
|
|
||||||
// Knowledge
|
// Knowledge
|
||||||
{ name: 'Outline', url: 'http://192.168.1.3:3080', port: 3080, icon: 'book-open', category: 'knowledge', description: 'Team wiki & documentation' },
|
{ name: 'Outline', url: `http://${h}: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: 'NocoDB', url: `http://${h}:8084`, port: 8084, icon: 'grid-3x3', category: 'knowledge', description: 'Airtable alternative database' },
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
{ name: 'FileBrowser', url: 'http://192.168.1.3:8085', port: 8085, icon: 'folder', category: 'storage', description: 'Web file manager' },
|
{ name: 'FileBrowser', url: `http://${h}: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: 'MinIO', url: `http://${h}: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: 'Kopia', url: `http://${h}:51515`, port: 51515, icon: 'archive', category: 'storage', description: 'Backup & restore' },
|
||||||
|
|
||||||
// Monitoring
|
// Monitoring
|
||||||
{ name: 'Uptime Kuma', url: 'http://192.168.1.3:3001', port: 3001, icon: 'activity', category: 'monitoring', description: 'Service status monitoring' },
|
{ name: 'Uptime Kuma', url: `http://${h}: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: 'Ntfy', url: `http://${h}:8333`, port: 8333, icon: 'bell', category: 'monitoring', description: 'Push notifications server' },
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
{ name: 'Vaultwarden', url: 'http://192.168.1.3:8222', port: 8222, icon: 'lock', category: 'security', description: 'Password manager' },
|
{ name: 'Vaultwarden', url: `http://${h}: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: 'Authentik', url: `http://${h}:9090`, port: 9090, icon: 'shield', category: 'security', description: 'Identity provider & SSO' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Re-export for backwards compatibility
|
// Re-export for backwards compatibility
|
||||||
|
|||||||
112
src/lib/useEventStream.ts
Normal file
112
src/lib/useEventStream.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user