- 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
1074 lines
41 KiB
TypeScript
1074 lines
41 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import Link from 'next/link';
|
|
import useSWR from 'swr';
|
|
import { Icon } from './Icons';
|
|
import { DeploymentLogs } from './DeploymentLogs';
|
|
import {
|
|
Deployment,
|
|
DeploymentStatus,
|
|
STATUS_LABELS,
|
|
formatDuration,
|
|
formatRelativeTime,
|
|
formatDate,
|
|
} from '@/lib/deployments';
|
|
import { clientConfig } from '@/lib/config';
|
|
|
|
// SWR fetcher
|
|
const fetcher = (url: string) => fetch(url).then((r) => r.json());
|
|
|
|
// Types for health and stats responses
|
|
type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'none' | 'unknown';
|
|
|
|
interface HealthResponse {
|
|
status: HealthStatus;
|
|
failingStreak: number;
|
|
log: { start: string; end: string; exitCode: number; output: string }[];
|
|
containerName: string | null;
|
|
lastCheck: string | null;
|
|
}
|
|
|
|
interface StatsResponse {
|
|
containerName: string | null;
|
|
stats: {
|
|
cpuPercent: number;
|
|
memoryUsage: string;
|
|
memoryLimit: string;
|
|
memoryPercent: number;
|
|
netIO: { rx: string; tx: string };
|
|
blockIO: { read: string; write: string };
|
|
} | null;
|
|
uptime: {
|
|
startedAt: string | null;
|
|
seconds: number;
|
|
formatted: string;
|
|
} | null;
|
|
timestamp: string;
|
|
}
|
|
|
|
|
|
interface DeploymentDashboardProps {
|
|
deployment: Deployment;
|
|
}
|
|
|
|
type TabId = 'deployment' | 'logs' | 'resources' | 'source';
|
|
|
|
const tabs: { id: TabId; label: string }[] = [
|
|
{ id: 'deployment', label: 'Deployment' },
|
|
{ id: 'logs', label: 'Logs' },
|
|
{ id: 'resources', label: 'Resources' },
|
|
{ id: 'source', label: 'Source' },
|
|
];
|
|
|
|
// Status badge component similar to Vercel's design
|
|
const StatusBadge = ({ status, showLabel = true }: { status: DeploymentStatus; showLabel?: boolean }) => {
|
|
const dotColors: Record<DeploymentStatus, string> = {
|
|
finished: 'bg-emerald-500',
|
|
error: 'bg-red-500',
|
|
in_progress: 'bg-amber-500 animate-pulse',
|
|
queued: 'bg-slate-400',
|
|
cancelled: 'bg-slate-400',
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${dotColors[status]}`} />
|
|
{showLabel && (
|
|
<span className="text-slate-900 dark:text-stone-100 font-medium">
|
|
{STATUS_LABELS[status]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Collapsible section component
|
|
interface CollapsibleSectionProps {
|
|
icon: string;
|
|
iconColor?: string;
|
|
title: string;
|
|
badge?: React.ReactNode;
|
|
rightContent?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
defaultOpen?: boolean;
|
|
}
|
|
|
|
function CollapsibleSection({
|
|
icon,
|
|
iconColor = 'text-amber-500',
|
|
title,
|
|
badge,
|
|
rightContent,
|
|
children,
|
|
defaultOpen = false,
|
|
}: CollapsibleSectionProps) {
|
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
|
|
return (
|
|
<div className="border-t border-slate-100 dark:border-stone-800">
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className="w-full flex items-center justify-between p-4 hover:bg-slate-50 dark:hover:bg-stone-800/50 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Icon
|
|
name={isOpen ? 'chevron-down' : 'chevron-right'}
|
|
size={16}
|
|
className="text-slate-400 dark:text-stone-500"
|
|
/>
|
|
<Icon name={icon} size={16} className={iconColor} />
|
|
<span className="text-slate-700 dark:text-stone-300 font-medium">{title}</span>
|
|
{badge}
|
|
</div>
|
|
{rightContent}
|
|
</button>
|
|
{isOpen && <div className="px-4 pb-4">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Action card component similar to Vercel's bottom cards
|
|
interface ActionCardProps {
|
|
icon: string;
|
|
title: string;
|
|
description: string;
|
|
badge?: string;
|
|
badgeColor?: string;
|
|
disabled?: boolean;
|
|
disabledReason?: string;
|
|
loading?: boolean;
|
|
onClick?: () => void;
|
|
href?: string;
|
|
isExternal?: boolean;
|
|
}
|
|
|
|
function ActionCard({
|
|
icon,
|
|
title,
|
|
description,
|
|
badge,
|
|
badgeColor = 'bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400',
|
|
disabled = false,
|
|
disabledReason,
|
|
loading = false,
|
|
onClick,
|
|
href,
|
|
isExternal = true,
|
|
}: ActionCardProps) {
|
|
const isDisabled = disabled || loading;
|
|
const isClickable = (href || onClick) && !isDisabled;
|
|
|
|
const content = (
|
|
<div
|
|
className={`
|
|
group relative p-4 rounded-lg border transition-all duration-200
|
|
${isDisabled
|
|
? 'border-slate-200 dark:border-stone-800 opacity-50 cursor-not-allowed bg-slate-50 dark:bg-stone-900'
|
|
: isClickable
|
|
? 'border-slate-200 dark:border-stone-700 hover:border-slate-300 dark:hover:border-stone-600 hover:bg-slate-50 dark:hover:bg-stone-800/50 hover:shadow-sm cursor-pointer'
|
|
: 'border-slate-200 dark:border-stone-700'
|
|
}
|
|
`}
|
|
title={isDisabled && disabledReason ? disabledReason : undefined}
|
|
>
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Icon
|
|
name={loading ? 'loader' : icon}
|
|
size={18}
|
|
className={`transition-colors ${loading ? 'animate-spin text-slate-600 dark:text-stone-400' : isDisabled ? 'text-slate-400 dark:text-stone-600' : 'text-slate-600 dark:text-stone-400 group-hover:text-slate-700 dark:group-hover:text-stone-300'}`}
|
|
/>
|
|
<span className={`font-medium transition-colors ${isDisabled ? 'text-slate-500 dark:text-stone-600' : 'text-slate-900 dark:text-stone-100'}`}>
|
|
{title}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{badge && (
|
|
<span className={`px-2 py-0.5 text-xs rounded ${badgeColor}`}>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
{isExternal && href && !isDisabled && (
|
|
<Icon
|
|
name="external-link"
|
|
size={14}
|
|
className="text-slate-400 dark:text-stone-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className={`text-sm ${isDisabled ? 'text-slate-400 dark:text-stone-600' : 'text-slate-500 dark:text-stone-500'}`}>
|
|
{loading ? 'Triggering deployment...' : description}
|
|
</p>
|
|
</div>
|
|
);
|
|
|
|
if (href && !isDisabled) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
target={isExternal ? '_blank' : undefined}
|
|
rel={isExternal ? 'noopener noreferrer' : undefined}
|
|
className="block"
|
|
>
|
|
{content}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
if (onClick && !isDisabled) {
|
|
return <button onClick={onClick} className="w-full text-left">{content}</button>;
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
// Metadata row component
|
|
function MetadataRow({
|
|
label,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<dt className="text-xs font-medium text-slate-500 dark:text-stone-500 uppercase tracking-wider mb-1">
|
|
{label}
|
|
</dt>
|
|
<dd className="text-slate-700 dark:text-stone-300">{children}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Health indicator component with colored dot
|
|
function HealthIndicator({
|
|
status,
|
|
isLoading,
|
|
deploymentStatus,
|
|
}: {
|
|
status: HealthStatus | undefined;
|
|
isLoading: boolean;
|
|
deploymentStatus?: DeploymentStatus;
|
|
}) {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-stone-600 animate-pulse" />
|
|
<span className="text-slate-500 dark:text-stone-500 text-sm">checking...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle building/queued deployments that don't have containers yet
|
|
if (deploymentStatus === 'in_progress' || deploymentStatus === 'queued') {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
|
<span className="text-amber-600 dark:text-amber-400">
|
|
{deploymentStatus === 'in_progress' ? 'Building...' : 'Pending'}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle failed deployments
|
|
if (deploymentStatus === 'error') {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-red-500" />
|
|
<span className="text-red-600 dark:text-red-400">Failed</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle cancelled deployments
|
|
if (deploymentStatus === 'cancelled') {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-slate-400 dark:bg-stone-500" />
|
|
<span className="text-slate-500 dark:text-stone-500">Cancelled</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const statusConfig: Record<HealthStatus, { color: string; label: string }> = {
|
|
healthy: { color: 'bg-emerald-500', label: 'Healthy' },
|
|
unhealthy: { color: 'bg-red-500', label: 'Unhealthy' },
|
|
starting: { color: 'bg-amber-500 animate-pulse', label: 'Starting' },
|
|
none: { color: 'bg-slate-400 dark:bg-stone-500', label: 'No healthcheck' },
|
|
unknown: { color: 'bg-slate-400 dark:bg-stone-500', label: 'Unknown' },
|
|
};
|
|
|
|
const config = statusConfig[status || 'unknown'];
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className={`w-2 h-2 rounded-full ${config.color}`} />
|
|
<span className="text-slate-700 dark:text-stone-300">{config.label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Stats display component
|
|
function StatsDisplay({
|
|
stats,
|
|
uptime,
|
|
containerName,
|
|
isLoading,
|
|
deploymentStatus,
|
|
}: {
|
|
stats: StatsResponse['stats'];
|
|
uptime: StatsResponse['uptime'];
|
|
containerName: string | null;
|
|
isLoading: boolean;
|
|
deploymentStatus?: DeploymentStatus;
|
|
}) {
|
|
if (isLoading) {
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{[...Array(4)].map((_, i) => (
|
|
<div key={i} className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg animate-pulse">
|
|
<div className="h-3 bg-slate-200 dark:bg-stone-700 rounded w-16 mb-2" />
|
|
<div className="h-5 bg-slate-200 dark:bg-stone-700 rounded w-20" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Handle different deployment statuses when no stats
|
|
if (!stats && !uptime) {
|
|
// Deployment is building/queued - container not started yet
|
|
if (deploymentStatus === 'in_progress' || deploymentStatus === 'queued') {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-amber-600 dark:text-amber-400">
|
|
<Icon name="loader" size={16} className="animate-spin" />
|
|
<span>Container not started yet - deployment in progress</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Deployment failed - container may never have started
|
|
if (deploymentStatus === 'error') {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
|
<Icon name="alert-circle" size={16} />
|
|
<span>Deployment failed - no container available</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Deployment cancelled
|
|
if (deploymentStatus === 'cancelled') {
|
|
return (
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-500">
|
|
<Icon name="x-circle" size={16} />
|
|
<span>Deployment cancelled - no container available</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Default: container not found (may be stopped or removed)
|
|
return (
|
|
<div className="text-sm text-slate-500 dark:text-stone-500">
|
|
Container not found or not running
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{/* CPU */}
|
|
<div className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon name="activity" size={14} className="text-slate-400 dark:text-stone-500" />
|
|
<span className="text-xs font-medium text-slate-500 dark:text-stone-500 uppercase">CPU</span>
|
|
</div>
|
|
<span className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
|
{stats ? `${stats.cpuPercent.toFixed(1)}%` : '-'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Memory */}
|
|
<div className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon name="database" size={14} className="text-slate-400 dark:text-stone-500" />
|
|
<span className="text-xs font-medium text-slate-500 dark:text-stone-500 uppercase">Memory</span>
|
|
</div>
|
|
<span className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
|
{stats ? stats.memoryUsage : '-'}
|
|
</span>
|
|
{stats && (
|
|
<span className="text-xs text-slate-500 dark:text-stone-500 ml-1">
|
|
({stats.memoryPercent.toFixed(1)}%)
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Uptime */}
|
|
<div className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon name="clock" size={14} className="text-slate-400 dark:text-stone-500" />
|
|
<span className="text-xs font-medium text-slate-500 dark:text-stone-500 uppercase">Uptime</span>
|
|
</div>
|
|
<span className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
|
{uptime ? uptime.formatted : '-'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Container */}
|
|
<div className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<div className="flex items-center gap-1.5 mb-1">
|
|
<Icon name="box" size={14} className="text-slate-400 dark:text-stone-500" />
|
|
<span className="text-xs font-medium text-slate-500 dark:text-stone-500 uppercase">Container</span>
|
|
</div>
|
|
<span className="text-sm font-mono text-slate-700 dark:text-stone-300 truncate block">
|
|
{containerName ? containerName.substring(0, 20) : '-'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
|
|
const [activeTab, setActiveTab] = useState<TabId>('deployment');
|
|
// Auto-expand logs for error deployments
|
|
const [logsOpen, setLogsOpen] = useState(true);
|
|
const [isRedeploying, setIsRedeploying] = useState(false);
|
|
|
|
// Check if deployment is in a terminal error state
|
|
const isErrorState = deployment.status === 'error';
|
|
const isInProgress = deployment.status === 'in_progress' || deployment.status === 'queued';
|
|
const isFinished = deployment.status === 'finished';
|
|
const isCancelled = deployment.status === 'cancelled';
|
|
|
|
const handleRedeploy = async () => {
|
|
if (!confirm('Trigger a new deployment?')) return;
|
|
setIsRedeploying(true);
|
|
try {
|
|
const res = await fetch(`/api/deployments/${deployment.deployment_uuid}/redeploy`, {
|
|
method: 'POST',
|
|
});
|
|
if (res.ok) {
|
|
alert('Deployment triggered!');
|
|
} else {
|
|
const data = await res.json();
|
|
alert(`Failed to trigger deployment: ${data.error || 'Unknown error'}`);
|
|
}
|
|
} catch (error) {
|
|
alert(`Failed to trigger deployment: ${error}`);
|
|
} finally {
|
|
setIsRedeploying(false);
|
|
}
|
|
};
|
|
|
|
// SWR hooks for real-time health and stats data
|
|
const { data: health, isLoading: healthLoading } = useSWR<HealthResponse>(
|
|
`/api/deployments/${deployment.deployment_uuid}/health`,
|
|
fetcher,
|
|
{ refreshInterval: 10000 }
|
|
);
|
|
|
|
const { data: stats, isLoading: statsLoading } = useSWR<StatsResponse>(
|
|
`/api/deployments/${deployment.deployment_uuid}/stats`,
|
|
fetcher,
|
|
{ refreshInterval: 10000 }
|
|
);
|
|
|
|
// 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}`;
|
|
|
|
// Dozzle URL for runtime logs (uses IP since no domain configured)
|
|
// Format: http://host:9999/container/{hostId}~{containerName}
|
|
const dozzleUrl = stats?.containerName
|
|
? `http://192.168.1.3:9999/container/${clientConfig.dozzleHostId}~${stats.containerName}`
|
|
: null;
|
|
|
|
// Count warnings in logs (simple heuristic)
|
|
const warningCount = deployment.logs
|
|
? (deployment.logs.match(/warning|warn/gi) || []).length
|
|
: 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header section with app name and actions */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Link
|
|
href="/?tab=deployments"
|
|
className="flex items-center gap-2 text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
|
|
>
|
|
<div className="w-10 h-10 flex items-center justify-center rounded-xl bg-slate-100 dark:bg-stone-800">
|
|
<Icon name="box" size={20} className="text-slate-600 dark:text-stone-400" />
|
|
</div>
|
|
</Link>
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-slate-900 dark:text-stone-100">
|
|
{deployment.application_name}
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="font-mono text-sm text-slate-500 dark:text-stone-500">
|
|
{deployment.deployment_uuid.substring(0, 9)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons - Vercel style */}
|
|
<div className="flex items-center gap-2">
|
|
<button className="flex items-center gap-2 px-4 py-2 text-sm text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors">
|
|
<Icon name="share" size={16} />
|
|
Share
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('logs')}
|
|
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors"
|
|
>
|
|
<Icon name="terminal" size={16} />
|
|
Logs
|
|
</button>
|
|
{deployment.application_fqdn && (
|
|
<a
|
|
href={deployment.application_fqdn}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-slate-900 dark:bg-stone-100 text-white dark:text-stone-900 rounded-lg hover:bg-slate-800 dark:hover:bg-stone-200 transition-colors"
|
|
>
|
|
Visit
|
|
<Icon name="external-link" size={14} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab navigation (Vercel style) */}
|
|
<div className="border-b border-slate-200 dark:border-stone-800">
|
|
<nav className="flex gap-6">
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`pb-3 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === tab.id
|
|
? 'border-slate-900 dark:border-stone-100 text-slate-900 dark:text-stone-100'
|
|
: 'border-transparent text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Tab content */}
|
|
{activeTab === 'deployment' && (
|
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm overflow-hidden">
|
|
{/* Error banner for failed deployments */}
|
|
{isErrorState && (
|
|
<div className="bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800/50 px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<Icon name="alert-circle" size={20} className="text-red-600 dark:text-red-400 flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-red-800 dark:text-red-300">
|
|
Deployment Failed
|
|
</p>
|
|
<p className="text-sm text-red-600 dark:text-red-400">
|
|
Check the build logs below for error details.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* In-progress banner */}
|
|
{isInProgress && (
|
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800/50 px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<Icon name="loader" size={20} className="text-amber-600 dark:text-amber-400 flex-shrink-0 animate-spin" />
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
|
{deployment.status === 'queued' ? 'Deployment Queued' : 'Deployment in Progress'}
|
|
</p>
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
|
{deployment.status === 'queued'
|
|
? 'Waiting for available resources...'
|
|
: 'Building and deploying your application...'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Deployment Details Header - Vercel style */}
|
|
<div className="flex items-center justify-between p-4 border-b border-slate-100 dark:border-stone-800">
|
|
<h2 className="text-base font-semibold text-slate-900 dark:text-stone-100">
|
|
Deployment Details
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<button className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors">
|
|
<Icon name="share" size={14} />
|
|
Share
|
|
</button>
|
|
<button className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-600 dark:text-stone-400 border border-slate-200 dark:border-stone-700 rounded-lg hover:bg-slate-50 dark:hover:bg-stone-800 transition-colors">
|
|
<Icon name="terminal" size={14} />
|
|
Logs
|
|
</button>
|
|
{deployment.application_fqdn && (
|
|
<a
|
|
href={deployment.application_fqdn}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-slate-900 dark:bg-stone-100 text-white dark:text-stone-900 rounded-lg hover:bg-slate-800 dark:hover:bg-stone-200 transition-colors"
|
|
>
|
|
Visit
|
|
<Icon name="chevron-down" size={14} />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content: Preview + Metadata Grid */}
|
|
<div className="p-6">
|
|
<div className="flex gap-6">
|
|
{/* Preview thumbnail (left side) - generated via ImageResponse */}
|
|
<div className="flex-shrink-0 w-80">
|
|
<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>
|
|
|
|
{/* Metadata grid (right side) - 2 columns like Vercel */}
|
|
<div className="flex-1 grid grid-cols-2 gap-x-8 gap-y-5">
|
|
{/* Created */}
|
|
<MetadataRow label="Created">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-5 h-5 rounded-full bg-slate-200 dark:bg-stone-700 flex items-center justify-center">
|
|
<Icon name="user" size={12} className="text-slate-500 dark:text-stone-400" />
|
|
</div>
|
|
<span>{formatDate(deployment.created_at)}</span>
|
|
</div>
|
|
</MetadataRow>
|
|
|
|
{/* Status */}
|
|
<MetadataRow label="Status">
|
|
<div className="flex items-center gap-2">
|
|
<StatusBadge status={deployment.status} />
|
|
{deployment.is_current && (
|
|
<span className="px-2 py-0.5 text-xs bg-slate-100 dark:bg-stone-800 text-slate-600 dark:text-stone-400 rounded">
|
|
Latest
|
|
</span>
|
|
)}
|
|
</div>
|
|
</MetadataRow>
|
|
|
|
{/* Health */}
|
|
<MetadataRow label="Health">
|
|
<HealthIndicator
|
|
status={health?.status}
|
|
isLoading={healthLoading}
|
|
deploymentStatus={deployment.status}
|
|
/>
|
|
</MetadataRow>
|
|
|
|
{/* Duration */}
|
|
<MetadataRow label="Duration">
|
|
<div className="flex items-center gap-2">
|
|
<Icon name="clock" size={16} className="text-slate-400 dark:text-stone-500" />
|
|
<span>
|
|
{deployment.duration != null
|
|
? formatDuration(deployment.duration)
|
|
: isInProgress
|
|
? 'In progress...'
|
|
: '-'}
|
|
</span>
|
|
<span className="text-slate-500 dark:text-stone-500 text-sm">
|
|
{deployment.created_at ? formatRelativeTime(deployment.created_at) : ''}
|
|
</span>
|
|
</div>
|
|
</MetadataRow>
|
|
|
|
{/* Environment */}
|
|
<MetadataRow label="Environment">
|
|
<div className="flex items-center gap-2">
|
|
<Icon name="globe" size={16} className="text-slate-400 dark:text-stone-500" />
|
|
<span className="px-2 py-0.5 text-xs bg-slate-100 dark:bg-stone-800 text-slate-700 dark:text-stone-300 rounded">
|
|
Production
|
|
</span>
|
|
{deployment.is_current && (
|
|
<span className="text-xs text-cyan-600 dark:text-cyan-400 font-medium">Current</span>
|
|
)}
|
|
</div>
|
|
</MetadataRow>
|
|
|
|
{/* Domains */}
|
|
<MetadataRow label="Domains">
|
|
{deployment.application_fqdn ? (
|
|
<a
|
|
href={deployment.application_fqdn}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-cyan-600 dark:text-cyan-400 hover:underline"
|
|
>
|
|
<Icon name="globe" size={14} />
|
|
<span className="truncate max-w-[200px]">
|
|
{deployment.application_fqdn.replace(/^https?:\/\//, '')}
|
|
</span>
|
|
</a>
|
|
) : (
|
|
<span className="text-slate-500 dark:text-stone-500">No domain configured</span>
|
|
)}
|
|
</MetadataRow>
|
|
|
|
{/* Source */}
|
|
<MetadataRow label="Source">
|
|
<div className="flex items-center gap-2">
|
|
<Icon name="git-branch" size={16} className="text-slate-400 dark:text-stone-500" />
|
|
<span>{deployment.git_branch || '—'}</span>
|
|
<span className="font-mono text-slate-500 dark:text-stone-500">
|
|
{deployment.git_commit_sha?.substring(0, 7) || '—'}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-sm text-slate-500 dark:text-stone-500 truncate">
|
|
{deployment.commit_message || 'No commit message'}
|
|
</p>
|
|
</MetadataRow>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible Sections - Vercel style */}
|
|
<CollapsibleSection
|
|
icon="settings"
|
|
title="Deployment Settings"
|
|
badge={
|
|
<span className="px-2 py-0.5 text-xs bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-400 rounded-full">
|
|
Inherited
|
|
</span>
|
|
}
|
|
>
|
|
<div className="pl-7 text-sm text-slate-500 dark:text-stone-500">
|
|
Settings inherited from application configuration.
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection
|
|
icon="terminal"
|
|
iconColor={isErrorState ? 'text-red-500' : 'text-amber-500'}
|
|
title="Build Logs"
|
|
defaultOpen={logsOpen || isErrorState}
|
|
rightContent={
|
|
<div className="flex items-center gap-3">
|
|
{warningCount > 0 && (
|
|
<span className="flex items-center gap-1 text-amber-500">
|
|
<Icon name="alert-circle" size={14} />
|
|
<span className="text-xs">{warningCount}</span>
|
|
</span>
|
|
)}
|
|
{deployment.duration && (
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
|
{formatDuration(deployment.duration)}
|
|
</span>
|
|
)}
|
|
<StatusBadge status={deployment.status} showLabel={false} />
|
|
</div>
|
|
}
|
|
>
|
|
<DeploymentLogs
|
|
deploymentUuid={deployment.deployment_uuid}
|
|
status={deployment.status}
|
|
initialLogs={deployment.logs}
|
|
/>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection
|
|
icon="activity"
|
|
iconColor="text-cyan-500"
|
|
title="Container Stats"
|
|
defaultOpen={isFinished}
|
|
rightContent={
|
|
<div className="flex items-center gap-2">
|
|
{stats?.stats && (
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">
|
|
CPU: {stats.stats.cpuPercent.toFixed(1)}%
|
|
</span>
|
|
)}
|
|
<HealthIndicator
|
|
status={health?.status}
|
|
isLoading={healthLoading}
|
|
deploymentStatus={deployment.status}
|
|
/>
|
|
</div>
|
|
}
|
|
>
|
|
<div className="pl-7">
|
|
<StatsDisplay
|
|
stats={stats?.stats || null}
|
|
uptime={stats?.uptime || null}
|
|
containerName={stats?.containerName || null}
|
|
isLoading={statsLoading}
|
|
deploymentStatus={deployment.status}
|
|
/>
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
<CollapsibleSection
|
|
icon={isErrorState ? 'alert-circle' : isInProgress ? 'loader' : 'check-circle'}
|
|
iconColor={isErrorState ? 'text-red-500' : isInProgress ? 'text-amber-500' : 'text-emerald-500'}
|
|
title="Deployment Summary"
|
|
defaultOpen={isErrorState}
|
|
rightContent={
|
|
<div className="flex items-center gap-2">
|
|
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-stone-500">
|
|
<Icon name="server" size={14} />
|
|
Resources
|
|
</span>
|
|
<StatusBadge status={deployment.status} showLabel={false} />
|
|
</div>
|
|
}
|
|
>
|
|
<div className={`pl-7 text-sm ${isErrorState ? 'text-red-600 dark:text-red-400' : 'text-slate-500 dark:text-stone-500'}`}>
|
|
{isErrorState && 'Deployment failed. Check the build logs above for details.'}
|
|
{isInProgress && 'Deployment is in progress. Container will be available once build completes.'}
|
|
{isCancelled && 'Deployment was cancelled.'}
|
|
{isFinished && 'Deployment completed successfully. Container is running.'}
|
|
</div>
|
|
</CollapsibleSection>
|
|
|
|
{/* Action Cards Grid - Vercel style */}
|
|
<div className="p-6 border-t border-slate-100 dark:border-stone-800">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<ActionCard
|
|
icon="scroll-text"
|
|
title="Runtime Logs"
|
|
description={dozzleUrl
|
|
? "View live container logs in Dozzle"
|
|
: isInProgress
|
|
? "Container not started yet"
|
|
: isErrorState
|
|
? "No container - deployment failed"
|
|
: "Container not found"}
|
|
href={dozzleUrl || undefined}
|
|
disabled={!dozzleUrl}
|
|
disabledReason={isInProgress
|
|
? "Container will be available after build completes"
|
|
: isErrorState
|
|
? "Deployment failed - no container available"
|
|
: "Container not found or not running"}
|
|
/>
|
|
<ActionCard
|
|
icon="coolify"
|
|
title="Coolify"
|
|
description="Manage deployment in Coolify"
|
|
href={coolifyUrl}
|
|
/>
|
|
<ActionCard
|
|
icon="globe"
|
|
title="Visit Site"
|
|
description="Open the deployed application"
|
|
href={deployment.application_fqdn || undefined}
|
|
disabled={!deployment.application_fqdn}
|
|
disabledReason="No domain configured"
|
|
/>
|
|
<ActionCard
|
|
icon="refresh-cw"
|
|
title="Redeploy"
|
|
description="Trigger a new deployment"
|
|
onClick={handleRedeploy}
|
|
loading={isRedeploying}
|
|
isExternal={false}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'logs' && (
|
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm overflow-hidden">
|
|
<div className="p-6 border-b border-slate-100 dark:border-stone-800">
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
|
Build Logs
|
|
</h2>
|
|
</div>
|
|
<div className="p-4">
|
|
<DeploymentLogs
|
|
deploymentUuid={deployment.deployment_uuid}
|
|
status={deployment.status}
|
|
initialLogs={deployment.logs}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'resources' && (
|
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100">
|
|
Resources
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
<HealthIndicator
|
|
status={health?.status}
|
|
isLoading={healthLoading}
|
|
deploymentStatus={deployment.status}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<StatsDisplay
|
|
stats={stats?.stats || null}
|
|
uptime={stats?.uptime || null}
|
|
containerName={stats?.containerName || null}
|
|
isLoading={statsLoading}
|
|
deploymentStatus={deployment.status}
|
|
/>
|
|
|
|
{/* Additional Network & Disk I/O */}
|
|
{stats?.stats && (
|
|
<div className="mt-6 grid grid-cols-2 gap-4">
|
|
<div className="p-4 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<h3 className="text-sm font-medium text-slate-700 dark:text-stone-300 mb-3">
|
|
Network I/O
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">Received</span>
|
|
<span className="text-sm font-mono text-slate-700 dark:text-stone-300">
|
|
{stats.stats.netIO.rx}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">Transmitted</span>
|
|
<span className="text-sm font-mono text-slate-700 dark:text-stone-300">
|
|
{stats.stats.netIO.tx}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-4 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<h3 className="text-sm font-medium text-slate-700 dark:text-stone-300 mb-3">
|
|
Block I/O
|
|
</h3>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">Read</span>
|
|
<span className="text-sm font-mono text-slate-700 dark:text-stone-300">
|
|
{stats.stats.blockIO.read}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-sm text-slate-500 dark:text-stone-500">Write</span>
|
|
<span className="text-sm font-mono text-slate-700 dark:text-stone-300">
|
|
{stats.stats.blockIO.write}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Health History */}
|
|
{health?.log && health.log.length > 0 && (
|
|
<div className="mt-6">
|
|
<h3 className="text-sm font-medium text-slate-700 dark:text-stone-300 mb-3">
|
|
Health Check History
|
|
</h3>
|
|
<div className="space-y-2 max-h-48 overflow-y-auto">
|
|
{health.log.slice(-5).reverse().map((entry, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-3 p-2 bg-slate-50 dark:bg-stone-800 rounded text-sm"
|
|
>
|
|
<span
|
|
className={`w-2 h-2 rounded-full ${
|
|
entry.exitCode === 0 ? 'bg-emerald-500' : 'bg-red-500'
|
|
}`}
|
|
/>
|
|
<span className="font-mono text-slate-500 dark:text-stone-500 text-xs">
|
|
{new Date(entry.end).toLocaleTimeString()}
|
|
</span>
|
|
<span className="text-slate-600 dark:text-stone-400 truncate flex-1">
|
|
{entry.output.trim() || (entry.exitCode === 0 ? 'OK' : 'Failed')}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'source' && (
|
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm p-6">
|
|
<h2 className="text-lg font-semibold text-slate-900 dark:text-stone-100 mb-4">
|
|
Source
|
|
</h2>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<Icon name="git-branch" size={20} className="text-slate-400 dark:text-stone-500" />
|
|
<div>
|
|
<p className="text-slate-700 dark:text-stone-300 font-medium">
|
|
{deployment.git_branch || '—'}
|
|
</p>
|
|
<p className="text-sm font-mono text-slate-500 dark:text-stone-500">
|
|
{deployment.git_commit_sha
|
|
? deployment.git_commit_sha.substring(0, 7)
|
|
: '—'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-3 bg-slate-50 dark:bg-stone-800 rounded-lg">
|
|
<p className="text-sm text-slate-700 dark:text-stone-300">
|
|
{deployment.commit_message || 'No commit message'}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-500">
|
|
{deployment.is_webhook && (
|
|
<span className="flex items-center gap-1">
|
|
<Icon name="webhook" size={14} className="text-violet-500" />
|
|
Triggered by webhook
|
|
</span>
|
|
)}
|
|
{deployment.is_api && (
|
|
<span className="flex items-center gap-1">
|
|
<Icon name="terminal" size={14} />
|
|
Triggered by API
|
|
</span>
|
|
)}
|
|
{!deployment.is_webhook && !deployment.is_api && (
|
|
<span className="flex items-center gap-1">
|
|
<Icon name="play" size={14} />
|
|
Manual trigger
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Link to Coolify */}
|
|
<div className="flex justify-end">
|
|
<a
|
|
href={coolifyUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-sm text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
|
|
>
|
|
<Icon name="coolify" size={16} />
|
|
View in Coolify
|
|
<Icon name="external-link" size={14} />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|