Next.js 16 dashboard for managing NUC services via Coolify API. Features service cards with health indicators, deployment dashboard with live log streaming, S3-backed preview images, SSE real-time updates, and dark mode support. 18 services across 7 categories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
261 lines
9.8 KiB
TypeScript
261 lines
9.8 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { Icon } from './Icons';
|
|
|
|
/**
|
|
* Skeleton component for deployment detail page
|
|
* Matches the layout of DeploymentDashboard for a seamless loading experience
|
|
*/
|
|
export function DeploymentSkeleton() {
|
|
return (
|
|
<div className="space-y-6 animate-pulse">
|
|
{/* Header section with app name and actions */}
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-10 h-10 rounded-xl bg-slate-200 dark:bg-stone-800" />
|
|
<div>
|
|
<div className="h-6 w-40 bg-slate-200 dark:bg-stone-800 rounded mb-2" />
|
|
<div className="h-4 w-24 bg-slate-100 dark:bg-stone-800/50 rounded" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action buttons skeleton */}
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-9 w-20 bg-slate-200 dark:bg-stone-800 rounded-lg" />
|
|
<div className="h-9 w-20 bg-slate-200 dark:bg-stone-800 rounded-lg" />
|
|
<div className="h-9 w-20 bg-slate-900/20 dark:bg-stone-100/20 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tab navigation skeleton */}
|
|
<div className="border-b border-slate-200 dark:border-stone-800">
|
|
<div className="flex gap-6 pb-3">
|
|
{['Deployment', 'Logs', 'Resources', 'Source'].map((tab, i) => (
|
|
<div
|
|
key={tab}
|
|
className={`h-5 rounded ${
|
|
i === 0
|
|
? 'w-24 bg-slate-300 dark:bg-stone-700'
|
|
: 'w-16 bg-slate-200 dark:bg-stone-800'
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content card skeleton */}
|
|
<div className="bg-white dark:bg-stone-900 rounded-xl border border-slate-200 dark:border-stone-700/50 shadow-sm overflow-hidden">
|
|
{/* Card header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-slate-100 dark:border-stone-800">
|
|
<div className="h-5 w-36 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-8 w-16 bg-slate-200 dark:bg-stone-800 rounded-lg" />
|
|
<div className="h-8 w-16 bg-slate-200 dark:bg-stone-800 rounded-lg" />
|
|
<div className="h-8 w-16 bg-slate-900/20 dark:bg-stone-100/20 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content: Preview + Metadata Grid */}
|
|
<div className="p-6">
|
|
<div className="flex gap-6">
|
|
{/* Preview thumbnail skeleton */}
|
|
<div className="flex-shrink-0 w-80">
|
|
<div className="aspect-[16/10] bg-slate-100 dark:bg-stone-800 rounded-lg flex items-center justify-center">
|
|
<Icon name="image" size={48} className="text-slate-300 dark:text-stone-700" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metadata grid skeleton */}
|
|
<div className="flex-1 grid grid-cols-2 gap-x-8 gap-y-5">
|
|
{/* Created */}
|
|
<MetadataRowSkeleton />
|
|
{/* Status */}
|
|
<MetadataRowSkeleton hasIndicator />
|
|
{/* Health */}
|
|
<MetadataRowSkeleton hasIndicator />
|
|
{/* Duration */}
|
|
<MetadataRowSkeleton />
|
|
{/* Environment */}
|
|
<MetadataRowSkeleton hasBadge />
|
|
{/* Domains */}
|
|
<MetadataRowSkeleton />
|
|
{/* Source */}
|
|
<MetadataRowSkeleton hasSecondLine />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible sections skeleton */}
|
|
{['Deployment Settings', 'Build Logs', 'Container Stats', 'Deployment Summary'].map(
|
|
(section, i) => (
|
|
<div
|
|
key={section}
|
|
className="border-t border-slate-100 dark:border-stone-800 p-4 flex items-center gap-3"
|
|
>
|
|
<div className="w-4 h-4 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
<div className="w-4 h-4 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
<div className="h-5 bg-slate-200 dark:bg-stone-800 rounded" style={{ width: `${section.length * 8}px` }} />
|
|
{i === 1 && (
|
|
<div className="ml-auto flex items-center gap-2">
|
|
<div className="h-4 w-12 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
<div className="w-2 h-2 bg-slate-300 dark:bg-stone-700 rounded-full" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Action Cards Grid skeleton */}
|
|
<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">
|
|
{[...Array(4)].map((_, i) => (
|
|
<ActionCardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer link skeleton */}
|
|
<div className="flex justify-end">
|
|
<div className="h-5 w-32 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Skeleton for metadata rows
|
|
*/
|
|
function MetadataRowSkeleton({
|
|
hasIndicator = false,
|
|
hasBadge = false,
|
|
hasSecondLine = false,
|
|
}: {
|
|
hasIndicator?: boolean;
|
|
hasBadge?: boolean;
|
|
hasSecondLine?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="h-3 w-16 bg-slate-100 dark:bg-stone-800/50 rounded mb-2" />
|
|
<div className="flex items-center gap-2">
|
|
{hasIndicator && (
|
|
<div className="w-2 h-2 bg-slate-300 dark:bg-stone-700 rounded-full" />
|
|
)}
|
|
<div className="h-5 w-28 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
{hasBadge && (
|
|
<div className="h-5 w-16 bg-slate-100 dark:bg-stone-800/50 rounded" />
|
|
)}
|
|
</div>
|
|
{hasSecondLine && (
|
|
<div className="h-4 w-48 bg-slate-100 dark:bg-stone-800/50 rounded mt-1" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Skeleton for action cards
|
|
*/
|
|
function ActionCardSkeleton() {
|
|
return (
|
|
<div className="p-4 rounded-lg border border-slate-200 dark:border-stone-700">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-5 h-5 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
<div className="h-5 w-24 bg-slate-200 dark:bg-stone-800 rounded" />
|
|
</div>
|
|
</div>
|
|
<div className="h-4 w-full bg-slate-100 dark:bg-stone-800/50 rounded" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Error state component for deployment page
|
|
*/
|
|
export function DeploymentError({
|
|
error,
|
|
uuid,
|
|
}: {
|
|
error: string;
|
|
uuid: string;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24">
|
|
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-100 dark:bg-red-900/20 mb-4">
|
|
<Icon name="alert-circle" size={32} className="text-red-500" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
|
|
{error}
|
|
</h2>
|
|
<p className="text-slate-500 dark:text-stone-500 mb-6 text-center max-w-md">
|
|
{error === 'Deployment not found' ? (
|
|
<>
|
|
The deployment with UUID "<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid}</code>" could not be found.
|
|
It may have been deleted or never existed.
|
|
</>
|
|
) : (
|
|
<>
|
|
Unable to load deployment details. This could be due to a network issue
|
|
or the deployment service being temporarily unavailable.
|
|
</>
|
|
)}
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="flex items-center gap-2 px-4 py-2 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="refresh-cw" size={16} />
|
|
Retry
|
|
</button>
|
|
<Link
|
|
href="/?tab=deployments"
|
|
className="flex items-center gap-2 px-4 py-2 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"
|
|
>
|
|
<Icon name="arrow-left" size={16} />
|
|
Back to Deployments
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Empty state component for when no deployment data exists
|
|
*/
|
|
export function DeploymentEmpty({ uuid }: { uuid: string }) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-24">
|
|
<div className="w-16 h-16 flex items-center justify-center rounded-full bg-slate-100 dark:bg-stone-800 mb-4">
|
|
<Icon name="box" size={32} className="text-slate-400 dark:text-stone-500" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-slate-900 dark:text-stone-100 mb-2">
|
|
No deployment data
|
|
</h2>
|
|
<p className="text-slate-500 dark:text-stone-500 mb-6 text-center max-w-md">
|
|
The deployment "<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid.substring(0, 9)}</code>" exists
|
|
but has no data available yet. It may still be initializing.
|
|
</p>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => window.location.reload()}
|
|
className="flex items-center gap-2 px-4 py-2 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="refresh-cw" size={16} />
|
|
Refresh
|
|
</button>
|
|
<Link
|
|
href="/?tab=deployments"
|
|
className="flex items-center gap-2 px-4 py-2 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"
|
|
>
|
|
<Icon name="arrow-left" size={16} />
|
|
Back to Deployments
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|