Files
nuc-portal/src/components/DeploymentDashboard.tsx
Alejandro Gutiérrez 9b9226b954 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
2026-02-06 19:41:40 +01:00

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