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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
237
src/app/api/deployments/[uuid]/preview/route.tsx
Normal file
237
src/app/api/deployments/[uuid]/preview/route.tsx
Normal 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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user