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
This commit is contained in:
Alejandro Gutiérrez
2026-02-06 19:41:40 +01:00
parent 91624fd6de
commit 9b9226b954
3 changed files with 246 additions and 100 deletions

View File

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

View File

@@ -0,0 +1,237 @@
import { ImageResponse } from 'next/og';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
export const runtime = 'nodejs';
const STATUS_CONFIG: Record<string, { color: string; bg: string; label: string }> = {
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(
(
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%',
backgroundColor: '#0a0a0b',
color: '#6b7280',
fontSize: 24,
}}
>
Deployment not found
</div>
),
{ 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(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
backgroundColor: '#0a0a0b',
padding: '40px',
fontFamily: 'system-ui, sans-serif',
}}
>
{/* Top bar: app name + status */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '32px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
{/* App icon */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '48px',
height: '48px',
borderRadius: '12px',
backgroundColor: '#1a1a1d',
border: '1px solid #2a2a2e',
fontSize: '24px',
}}
>
{deployment.application_name.charAt(0).toUpperCase()}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ color: '#f5f5f5', fontSize: '28px', fontWeight: 700 }}>
{deployment.application_name}
</span>
{fqdn && (
<span style={{ color: '#6b7280', fontSize: '16px' }}>{fqdn}</span>
)}
</div>
</div>
{/* Status badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
borderRadius: '9999px',
backgroundColor: status.bg,
}}
>
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: status.color,
}}
/>
<span style={{ color: status.color, fontSize: '16px', fontWeight: 600 }}>
{status.label}
</span>
</div>
</div>
{/* Divider */}
<div style={{ display: 'flex', width: '100%', height: '1px', backgroundColor: '#1f1f23', marginBottom: '28px' }} />
{/* Metadata grid */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '24px',
marginBottom: '32px',
}}
>
{/* Branch */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '140px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Branch
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{branch}
</span>
</div>
{/* Commit */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '140px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Commit
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontFamily: 'monospace' }}>
{commit}
</span>
</div>
{/* Duration */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '100px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Duration
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{duration}
</span>
</div>
{/* Created */}
<div style={{ display: 'flex', flexDirection: 'column', minWidth: '100px' }}>
<span style={{ color: '#6b7280', fontSize: '13px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Created
</span>
<span style={{ color: '#d1d5db', fontSize: '18px', fontWeight: 500 }}>
{timeAgo}
</span>
</div>
</div>
{/* Commit message */}
<div
style={{
display: 'flex',
padding: '16px 20px',
borderRadius: '12px',
backgroundColor: '#111114',
border: '1px solid #1f1f23',
marginBottom: '28px',
}}
>
<span style={{ color: '#9ca3af', fontSize: '15px' }}>{commitMsg}</span>
</div>
{/* Footer */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 'auto',
}}
>
<span style={{ color: '#4b5563', fontSize: '14px' }}>
{deployment.deployment_uuid.slice(0, 12)}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: '#4b5563', fontSize: '14px' }}>NUC Portal</span>
</div>
</div>
</div>
),
{
width: 640,
height: 400,
}
);
}

View File

@@ -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<PreviewResponse>(
`/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 */}
<div className="p-6">
<div className="flex gap-6">
{/* Preview thumbnail (left side) */}
{/* Preview thumbnail (left side) - generated via ImageResponse */}
<div className="flex-shrink-0 w-80">
<div className="aspect-[16/10] bg-slate-900 dark:bg-stone-950 rounded-lg flex items-center justify-center overflow-hidden border border-slate-200 dark:border-stone-700 relative">
{previewLoading ? (
<div className="text-center">
<Icon name="loader" size={32} className="text-slate-600 dark:text-stone-600 mx-auto mb-2 animate-spin" />
<span className="text-xs text-slate-500 dark:text-stone-500">Loading preview...</span>
</div>
) : preview?.exists && preview.url ? (
<img
src={preview.url}
alt={`Preview of ${deployment.application_name}`}
className="w-full h-full object-cover"
onError={(e) => {
// Hide image on error and show placeholder
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : (
<div className="text-center">
<Icon name="image" size={48} className="text-slate-600 dark:text-stone-600 mx-auto mb-2" />
<span className="text-xs text-slate-500 dark:text-stone-500">
{deployment.status === 'in_progress' ? 'Preview after deploy' : 'No preview'}
</span>
</div>
)}
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div className="hidden absolute inset-0 flex items-center justify-center bg-slate-900 dark:bg-stone-950">
<div className="text-center">
<Icon name="image" size={48} className="text-slate-600 dark:text-stone-600 mx-auto mb-2" />
<span className="text-xs text-slate-500 dark:text-stone-500">Preview unavailable</span>
</div>
</div>
<div className="aspect-[16/10] rounded-lg overflow-hidden border border-slate-200 dark:border-stone-700">
<img
src={previewUrl}
alt={`Preview of ${deployment.application_name}`}
className="w-full h-full object-cover"
/>
</div>
</div>