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;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewResponse {
|
|
||||||
exists: boolean;
|
|
||||||
url?: string;
|
|
||||||
expiresIn?: number;
|
|
||||||
message?: string;
|
|
||||||
hint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeploymentDashboardProps {
|
interface DeploymentDashboardProps {
|
||||||
deployment: Deployment;
|
deployment: Deployment;
|
||||||
@@ -484,12 +477,8 @@ export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
|
|||||||
{ refreshInterval: 10000 }
|
{ refreshInterval: 10000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Preview image (only fetch once, no refresh needed)
|
// Preview image URL (ImageResponse generates PNG directly)
|
||||||
const { data: preview, isLoading: previewLoading } = useSWR<PreviewResponse>(
|
const previewUrl = `/api/deployments/${deployment.deployment_uuid}/preview`;
|
||||||
`/api/deployments/${deployment.deployment_uuid}/preview`,
|
|
||||||
fetcher,
|
|
||||||
{ revalidateOnFocus: false }
|
|
||||||
);
|
|
||||||
|
|
||||||
const coolifyUrl = `${clientConfig.coolifyUrl}/project/${clientConfig.coolifyProjectUuid}/production/application/${deployment.application_uuid}/deployment/${deployment.deployment_uuid}`;
|
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 */}
|
{/* Main content: Preview + Metadata Grid */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex gap-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="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">
|
<div className="aspect-[16/10] rounded-lg overflow-hidden border border-slate-200 dark:border-stone-700">
|
||||||
{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
|
<img
|
||||||
src={preview.url}
|
src={previewUrl}
|
||||||
alt={`Preview of ${deployment.application_name}`}
|
alt={`Preview of ${deployment.application_name}`}
|
||||||
className="w-full h-full object-cover"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user