Replace polling with real-time SSE stream and eliminate Python API dependency

- Add single /api/events SSE endpoint replacing 5 separate polling intervals
- Query Prometheus directly for system stats (replaces Python API on port 9876)
- Query Coolify PostgreSQL directly for deployments (replaces SSH/tinker approach)
- Add EventManager singleton for server-side polling + client broadcast
- Add useEventStream hook with exponential backoff reconnection
- Add live deployment log streaming via SSE for in-progress builds
- Add redeploy button and live duration counter in deployments table
- Add SSE connection indicator in header (green=live, red=offline)
- Externalize all hardcoded 192.168.1.3 references to env vars via config.ts
- Reduce API route code by ~400 lines through shared library modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-04 00:43:41 +01:00
parent d4053812cd
commit 9e683eba22
20 changed files with 1064 additions and 795 deletions

View File

@@ -1,104 +1,19 @@
import { NextResponse } from 'next/server';
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
// Internal API endpoint for production (served by Python script on NUC host)
const DEPLOYMENTS_API_URL = 'http://192.168.1.3:9876/deployments';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
export async function GET(
request: Request,
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
let deployment: Record<string, unknown>;
if (IS_PRODUCTION) {
// In production, use internal HTTP API served by coolify-api.py on NUC host
const response = await fetch(`${DEPLOYMENTS_API_URL}/${uuid}`, {
cache: 'no-store',
signal: AbortSignal.timeout(30000),
});
if (!response.ok) {
throw new Error(`Deployments API error: ${response.status}`);
}
deployment = await response.json();
} else {
// In development, use SSH to call docker exec on NUC
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
// PHP code to fetch single deployment with logs
const phpCode = `
$d = \\App\\Models\\ApplicationDeploymentQueue::with('application')
->where('deployment_uuid', '${uuid}')
->first();
if (!$d) {
echo json_encode(['error' => 'Not found']);
exit;
}
echo json_encode([
'deployment_uuid' => $d->deployment_uuid,
'application_uuid' => $d->application?->uuid ?? 'unknown',
'application_name' => $d->application?->name ?? 'Unknown',
'status' => $d->status,
'created_at' => $d->created_at->toIso8601String(),
'updated_at' => $d->updated_at->toIso8601String(),
'git_branch' => $d->application?->git_branch ?? 'main',
'git_commit_sha' => $d->commit ?? null,
'commit_message' => $d->commit_message ?? null,
'is_webhook' => $d->is_webhook ?? false,
'logs' => $d->logs ?? null,
]);
`;
const base64Code = Buffer.from(phpCode).toString('base64');
const command = `ssh nuc "echo '${base64Code}' | base64 -d | docker exec -i coolify php artisan tinker"`;
const { stdout } = await execAsync(command, {
maxBuffer: 10 * 1024 * 1024,
timeout: 30000,
});
// Parse output - find JSON object in tinker output
const lines = stdout.split('\n');
let jsonStr = '';
for (const line of lines) {
let cleaned = line;
if (cleaned.startsWith('. ')) {
cleaned = cleaned.substring(2);
} else if (cleaned.startsWith('> ')) {
continue;
}
const trimmed = cleaned.trim();
if (trimmed.startsWith('{')) {
jsonStr = trimmed;
break;
}
}
if (!jsonStr) {
throw new Error('No JSON output found');
}
deployment = JSON.parse(jsonStr);
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
if (deployment.error) {
return NextResponse.json({ error: deployment.error }, { status: 404 });
}
return NextResponse.json(deployment, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
},
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
} catch (error) {
console.error('Error fetching deployment:', error);