From 9b9226b95417672ce52f5e0508139e96e98a808c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:41:40 +0100 Subject: [PATCH] Replace Puppeteer with Next.js ImageResponse for previews - Remove puppeteer (2.9GB) in favor of built-in ImageResponse (0 deps) - Preview endpoint generates styled deployment card as PNG - Shows app name, status, branch, commit, duration, domain - Rename route.ts to route.tsx for JSX support - Simplify dashboard to use image URL directly --- .../api/deployments/[uuid]/preview/route.ts | 54 ---- .../api/deployments/[uuid]/preview/route.tsx | 237 ++++++++++++++++++ src/components/DeploymentDashboard.tsx | 55 +--- 3 files changed, 246 insertions(+), 100 deletions(-) delete mode 100644 src/app/api/deployments/[uuid]/preview/route.ts create mode 100644 src/app/api/deployments/[uuid]/preview/route.tsx diff --git a/src/app/api/deployments/[uuid]/preview/route.ts b/src/app/api/deployments/[uuid]/preview/route.ts deleted file mode 100644 index be63e84..0000000 --- a/src/app/api/deployments/[uuid]/preview/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { NextResponse } from 'next/server'; -import { getPreviewUrl, previewExists } from '@/lib/s3'; -import { fetchDeploymentDetail } from '@/lib/coolify-db'; - -export async function GET( - _request: Request, - { params }: { params: Promise<{ uuid: string }> } -) { - const { uuid } = await params; - - try { - // Get deployment to find application UUID - const deployment = await fetchDeploymentDetail(uuid); - - if (!deployment) { - return NextResponse.json({ error: 'Deployment not found' }, { status: 404 }); - } - - const appUuid = deployment.application_uuid; - - // Check if preview exists - const exists = await previewExists(appUuid, uuid); - - if (!exists) { - return NextResponse.json({ - exists: false, - message: 'Preview not available', - hint: 'Screenshot will be captured after successful deployment', - }); - } - - // Get presigned URL (valid for 1 hour) - const url = await getPreviewUrl(appUuid, uuid); - - if (!url) { - return NextResponse.json( - { error: 'Failed to generate preview URL' }, - { status: 500 } - ); - } - - return NextResponse.json({ - exists: true, - url, - expiresIn: 3600, - }); - } catch (error) { - console.error('Preview fetch error:', error); - return NextResponse.json( - { error: 'Failed to fetch preview', details: String(error) }, - { status: 500 } - ); - } -} diff --git a/src/app/api/deployments/[uuid]/preview/route.tsx b/src/app/api/deployments/[uuid]/preview/route.tsx new file mode 100644 index 0000000..c03f96d --- /dev/null +++ b/src/app/api/deployments/[uuid]/preview/route.tsx @@ -0,0 +1,237 @@ +import { ImageResponse } from 'next/og'; +import { fetchDeploymentDetail } from '@/lib/coolify-db'; + +export const runtime = 'nodejs'; + +const STATUS_CONFIG: Record = { + finished: { color: '#06b6d4', bg: '#0e2a2f', label: 'Ready' }, + error: { color: '#ef4444', bg: '#2d1216', label: 'Error' }, + in_progress: { color: '#f59e0b', bg: '#2d2305', label: 'Building' }, + queued: { color: '#9ca3af', bg: '#1f2028', label: 'Queued' }, + cancelled: { color: '#9ca3af', bg: '#1f2028', label: 'Cancelled' }, +}; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +} + +function formatTimeAgo(dateStr: string): string { + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (mins > 0) return `${mins}m ago`; + return 'just now'; +} + +export async function GET( + _request: Request, + { params }: { params: Promise<{ uuid: string }> } +) { + const { uuid } = await params; + + const deployment = await fetchDeploymentDetail(uuid); + + if (!deployment) { + return new ImageResponse( + ( +
+ Deployment not found +
+ ), + { width: 640, height: 400 } + ); + } + + const status = STATUS_CONFIG[deployment.status] || STATUS_CONFIG.queued; + const branch = deployment.git_branch || 'main'; + const commit = deployment.git_commit_sha?.slice(0, 7) || '—'; + const commitMsg = deployment.commit_message + ? deployment.commit_message.length > 50 + ? deployment.commit_message.slice(0, 50) + '...' + : deployment.commit_message + : 'No commit message'; + const duration = deployment.duration ? formatDuration(deployment.duration) : '—'; + const timeAgo = formatTimeAgo(deployment.created_at); + const fqdn = deployment.application_fqdn?.replace(/^https?:\/\//, '') || ''; + + return new ImageResponse( + ( +
+ {/* Top bar: app name + status */} +
+
+ {/* App icon */} +
+ {deployment.application_name.charAt(0).toUpperCase()} +
+
+ + {deployment.application_name} + + {fqdn && ( + {fqdn} + )} +
+
+ + {/* Status badge */} +
+
+ + {status.label} + +
+
+ + {/* Divider */} +
+ + {/* Metadata grid */} +
+ {/* Branch */} +
+ + Branch + + + {branch} + +
+ + {/* Commit */} +
+ + Commit + + + {commit} + +
+ + {/* Duration */} +
+ + Duration + + + {duration} + +
+ + {/* Created */} +
+ + Created + + + {timeAgo} + +
+
+ + {/* Commit message */} +
+ {commitMsg} +
+ + {/* Footer */} +
+ + {deployment.deployment_uuid.slice(0, 12)} + +
+ NUC Portal +
+
+
+ ), + { + width: 640, + height: 400, + } + ); +} diff --git a/src/components/DeploymentDashboard.tsx b/src/components/DeploymentDashboard.tsx index bbfb0d8..118330a 100644 --- a/src/components/DeploymentDashboard.tsx +++ b/src/components/DeploymentDashboard.tsx @@ -47,13 +47,6 @@ interface StatsResponse { timestamp: string; } -interface PreviewResponse { - exists: boolean; - url?: string; - expiresIn?: number; - message?: string; - hint?: string; -} interface DeploymentDashboardProps { deployment: Deployment; @@ -484,12 +477,8 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { { refreshInterval: 10000 } ); - // Preview image (only fetch once, no refresh needed) - const { data: preview, isLoading: previewLoading } = useSWR( - `/api/deployments/${deployment.deployment_uuid}/preview`, - fetcher, - { revalidateOnFocus: false } - ); + // Preview image URL (ImageResponse generates PNG directly) + const previewUrl = `/api/deployments/${deployment.deployment_uuid}/preview`; const coolifyUrl = `${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${deployment.application_uuid}/deployment/${deployment.deployment_uuid}`; @@ -645,40 +634,14 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { {/* Main content: Preview + Metadata Grid */}
- {/* Preview thumbnail (left side) */} + {/* Preview thumbnail (left side) - generated via ImageResponse */}
-
- {previewLoading ? ( -
- - Loading preview... -
- ) : preview?.exists && preview.url ? ( - {`Preview { - // Hide image on error and show placeholder - e.currentTarget.style.display = 'none'; - e.currentTarget.nextElementSibling?.classList.remove('hidden'); - }} - /> - ) : ( -
- - - {deployment.status === 'in_progress' ? 'Preview after deploy' : 'No preview'} - -
- )} - {/* Fallback placeholder (hidden by default, shown on image error) */} -
-
- - Preview unavailable -
-
+
+ {`Preview