Add Vercel-style deployment dashboard

- Add /deployments/[uuid] route with detailed deployment view
- Add DeploymentDashboard component with tabs (Deployment, Logs, Resources, Source)
- Add real-time health/stats via SWR with 10s polling
- Add Docker API helpers (health, stats, uptime) via SSH
- Add redeploy action endpoint and button
- Add expand button to table for inline log viewing
- Add loading skeleton, error, and empty states
- Handle edge cases (in_progress, error, cancelled, missing data)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-06 18:00:14 +01:00
parent f7c57ca4f0
commit efc7a8392b
12 changed files with 2031 additions and 8 deletions

25
package-lock.json generated
View File

@@ -13,7 +13,8 @@
"pg": "^8.18.0", "pg": "^8.18.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0" "recharts": "^3.7.0",
"swr": "^2.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -3056,6 +3057,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -6565,6 +6575,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swr": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz",
"integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",

View File

@@ -15,7 +15,8 @@
"pg": "^8.18.0", "pg": "^8.18.0",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0" "recharts": "^3.7.0",
"swr": "^2.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@@ -0,0 +1,128 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import { findContainerByAppName, findContainerByUuid, sshExec, type HealthStatus } from '@/lib/docker';
interface HealthLogEntry {
start: string;
end: string;
exitCode: number;
output: string;
}
interface HealthResponse {
status: HealthStatus | 'unknown';
failingStreak: number;
log: HealthLogEntry[];
containerName: string | null;
lastCheck: string | null;
}
/**
* Get detailed container health including history logs and failing streak.
* Returns the full Health object from docker inspect.
*/
async function getDetailedContainerHealth(containerName: string): Promise<{
status: HealthStatus | null;
failingStreak: number;
log: HealthLogEntry[];
lastCheck: string | null;
}> {
const result = await sshExec(
`docker inspect --format='{{json .State.Health}}' ${containerName} 2>/dev/null`
);
if (!result || result === 'null' || result === '<nil>') {
return { status: null, failingStreak: 0, log: [], lastCheck: null };
}
try {
const health = JSON.parse(result);
const status = health.Status as HealthStatus | undefined;
const failingStreak = health.FailingStreak || 0;
// Map the Log entries
const log: HealthLogEntry[] = (health.Log || []).map((entry: {
Start?: string;
End?: string;
ExitCode?: number;
Output?: string;
}) => ({
start: entry.Start || '',
end: entry.End || '',
exitCode: entry.ExitCode || 0,
output: entry.Output || '',
}));
// Last check is the end time of the most recent log entry
const lastCheck = log.length > 0 ? log[log.length - 1].end : null;
return {
status: status && ['healthy', 'unhealthy', 'starting', 'none'].includes(status)
? status
: null,
failingStreak,
log,
lastCheck,
};
} catch {
return { status: null, failingStreak: 0, log: [], lastCheck: null };
}
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Fetch deployment info to get the application name
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
// Find the container by application UUID first (Coolify naming: uuid-buildnumber)
// Fall back to application name search if UUID doesn't match
let containerName = await findContainerByUuid(deployment.application_uuid);
if (!containerName) {
containerName = await findContainerByAppName(deployment.application_name);
}
if (!containerName) {
// Container not found - return unknown status
const response: HealthResponse = {
status: 'unknown',
failingStreak: 0,
log: [],
containerName: null,
lastCheck: null,
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
}
// Get detailed health information
const health = await getDetailedContainerHealth(containerName);
const response: HealthResponse = {
status: health.status || 'none',
failingStreak: health.failingStreak,
log: health.log,
containerName,
lastCheck: health.lastCheck,
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
} catch (error) {
console.error('Error fetching container health:', error);
return NextResponse.json(
{ error: 'Failed to fetch health status', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import { triggerDeploy } from '@/lib/coolify';
export async function POST(
_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 });
}
// Trigger deploy via Coolify API using application UUID
const result = await triggerDeploy(deployment.application_uuid);
if (!result.ok) {
return NextResponse.json(
{ error: 'Failed to trigger deployment', details: result.message },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: 'Deployment triggered',
application_uuid: deployment.application_uuid,
});
} catch (error) {
console.error('Redeploy error:', error);
return NextResponse.json(
{ error: 'Failed to redeploy', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { fetchDeploymentDetail } from '@/lib/coolify-db';
import {
findContainerByAppName,
getContainerStats,
getContainerUptime,
formatUptime,
type ContainerStats,
} from '@/lib/docker';
export interface StatsResponse {
containerName: string | null;
stats: ContainerStats | null;
uptime: {
startedAt: string | null;
seconds: number;
formatted: string;
} | null;
timestamp: string;
}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ uuid: string }> }
) {
const { uuid } = await params;
try {
// Fetch deployment info from Coolify to get application_name
const deployment = await fetchDeploymentDetail(uuid);
if (!deployment) {
return NextResponse.json({ error: 'Deployment not found' }, { status: 404 });
}
// Find container by app UUID first (Coolify uses {uuid}-{build} pattern), then by name
let containerName = await findContainerByAppName(deployment.application_uuid);
if (!containerName) {
containerName = await findContainerByAppName(deployment.application_name);
}
// If container not found, return null stats but success response
if (!containerName) {
const response: StatsResponse = {
containerName: null,
stats: null,
uptime: null,
timestamp: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
}
// Fetch stats and uptime in parallel
const [stats, uptimeSeconds] = await Promise.all([
getContainerStats(containerName),
getContainerUptime(containerName),
]);
// Build uptime object
let uptime: StatsResponse['uptime'] = null;
if (uptimeSeconds !== null) {
// Calculate started at from uptime
const startedAt = new Date(Date.now() - uptimeSeconds * 1000).toISOString();
uptime = {
startedAt,
seconds: uptimeSeconds,
formatted: formatUptime(uptimeSeconds),
};
}
const response: StatsResponse = {
containerName,
stats,
uptime,
timestamp: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' },
});
} catch (error) {
console.error('Error fetching deployment stats:', error);
return NextResponse.json(
{ error: 'Failed to fetch deployment stats', details: String(error) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,124 @@
'use client';
import { use, useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { DeploymentDashboard } from '@/components/DeploymentDashboard';
import { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from '@/components/DeploymentSkeleton';
import { Icon } from '@/components/Icons';
import type { Deployment } from '@/lib/deployments';
interface DeploymentPageProps {
params: Promise<{ uuid: string }>;
}
export default function DeploymentPage({ params }: DeploymentPageProps) {
const { uuid } = use(params);
const [deployment, setDeployment] = useState<Deployment | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDeployment = useCallback(async () => {
try {
setLoading(true);
setError(null);
const res = await fetch(`/api/deployments/${uuid}`);
if (!res.ok) {
if (res.status === 404) {
setError('Deployment not found');
} else if (res.status === 500) {
setError('Server error occurred');
} else {
setError('Failed to fetch deployment');
}
return;
}
const data = await res.json();
setDeployment(data);
} catch (err) {
console.error('Fetch deployment error:', err);
if (err instanceof TypeError && err.message.includes('fetch')) {
setError('Network error - please check your connection');
} else {
setError('Failed to fetch deployment');
}
} finally {
setLoading(false);
}
}, [uuid]);
useEffect(() => {
fetchDeployment();
}, [fetchDeployment]);
// Refresh deployment data periodically if in progress
useEffect(() => {
if (!deployment || deployment.status !== 'in_progress') return;
const interval = setInterval(async () => {
try {
const res = await fetch(`/api/deployments/${uuid}`);
if (res.ok) {
const data = await res.json();
setDeployment(data);
}
} catch {
// Ignore errors during background refresh
}
}, 3000);
return () => clearInterval(interval);
}, [deployment, uuid]);
// Render content based on state
const renderContent = () => {
if (loading) {
return <DeploymentSkeleton />;
}
if (error) {
return <DeploymentError error={error} uuid={uuid} />;
}
if (!deployment) {
return <DeploymentEmpty uuid={uuid} />;
}
return <DeploymentDashboard deployment={deployment} />;
};
return (
<div className="min-h-screen bg-slate-50 dark:bg-stone-950">
{/* Header with breadcrumb */}
<header className="sticky top-0 z-50 bg-white dark:bg-stone-950 border-b border-slate-200 dark:border-stone-800">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 py-4">
{/* Breadcrumb navigation */}
<nav className="flex items-center gap-2 text-sm">
<Link
href="/"
className="flex items-center gap-1.5 text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
>
<Icon name="server" size={16} />
<span>NUC Portal</span>
</Link>
<Icon name="chevron-right" size={14} className="text-slate-400 dark:text-stone-600" />
<Link
href="/?tab=deployments"
className="text-slate-500 dark:text-stone-500 hover:text-slate-700 dark:hover:text-stone-300 transition-colors"
>
Deployments
</Link>
<Icon name="chevron-right" size={14} className="text-slate-400 dark:text-stone-600" />
<span className="text-slate-900 dark:text-stone-100 font-mono">
{uuid.substring(0, 9)}
</span>
</nav>
</div>
</header>
{/* Main content */}
<main className="max-w-[1600px] mx-auto px-4 sm:px-6 py-8">
{renderContent()}
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
'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 &quot;<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid}</code>&quot; 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 &quot;<code className="font-mono text-sm bg-slate-100 dark:bg-stone-800 px-1 py-0.5 rounded">{uuid.substring(0, 9)}</code>&quot; 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>
);
}

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useMemo, useEffect, Fragment } from 'react'; import { useState, useMemo, useEffect, Fragment, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { import {
useReactTable, useReactTable,
getCoreRowModel, getCoreRowModel,
@@ -71,6 +72,7 @@ function LiveDuration({ createdAt }: { createdAt: string }) {
} }
export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) { export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) {
const router = useRouter();
const [sorting, setSorting] = useState<SortingState>([ const [sorting, setSorting] = useState<SortingState>([
{ id: 'created_at', desc: true }, { id: 'created_at', desc: true },
]); ]);
@@ -79,6 +81,17 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all'); const [statusFilter, setStatusFilter] = useState<DeploymentStatus | 'all'>('all');
const [appFilter, setAppFilter] = useState<string>('all'); const [appFilter, setAppFilter] = useState<string>('all');
// Toggle expand with stopPropagation to prevent row click navigation
const toggleExpand = useCallback((row: Row<Deployment>, e: React.MouseEvent) => {
e.stopPropagation();
row.toggleExpanded();
}, []);
// Navigate to deployment detail page on row click
const handleRowClick = useCallback((deploymentUuid: string) => {
router.push(`/deployments/${deploymentUuid}`);
}, [router]);
const applicationNames = useMemo(() => { const applicationNames = useMemo(() => {
const names = new Set(deployments.map((d) => d.application_name)); const names = new Set(deployments.map((d) => d.application_name));
return Array.from(names).sort(); return Array.from(names).sort();
@@ -98,13 +111,14 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
header: 'Deployment', header: 'Deployment',
cell: ({ row, getValue }) => ( cell: ({ row, getValue }) => (
<button <button
onClick={() => row.toggleExpanded()} onClick={(e) => toggleExpand(row, e)}
className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white font-mono text-sm" className="flex items-center gap-2 text-slate-700 dark:text-stone-300 hover:text-slate-900 dark:hover:text-white font-mono text-sm"
title={row.getIsExpanded() ? 'Collapse logs' : 'Expand logs'}
> >
<Icon <Icon
name="chevron-right" name={row.getIsExpanded() ? 'chevron-down' : 'chevron-right'}
size={14} size={14}
className={`transition-transform ${row.getIsExpanded() ? 'rotate-90' : ''}`} className="transition-transform"
/> />
<span>{getValue().substring(0, 9)}</span> <span>{getValue().substring(0, 9)}</span>
</button> </button>
@@ -239,7 +253,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
), ),
}), }),
], ],
[onDeploy] [onDeploy, toggleExpand]
); );
const table = useReactTable({ const table = useReactTable({
@@ -375,7 +389,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }
className={`border-b border-slate-100 dark:border-stone-800/50 hover:bg-slate-50 dark:hover:bg-stone-800/30 cursor-pointer transition-colors ${ className={`border-b border-slate-100 dark:border-stone-800/50 hover:bg-slate-50 dark:hover:bg-stone-800/30 cursor-pointer transition-colors ${
row.getIsExpanded() ? 'bg-slate-50 dark:bg-stone-800/50' : '' row.getIsExpanded() ? 'bg-slate-50 dark:bg-stone-800/50' : ''
}`} }`}
onClick={() => row.toggleExpanded()} onClick={() => handleRowClick(row.original.deployment_uuid)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3"> <td key={cell.id} className="px-4 py-3">

View File

@@ -113,6 +113,15 @@ export const icons: Record<string, React.ComponentType<IconProps>> = {
'power': createIcon('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'), 'power': createIcon('<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/>'),
'stop-circle': createIcon('<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/>'), 'stop-circle': createIcon('<circle cx="12" cy="12" r="10"/><rect x="9" y="9" width="6" height="6"/>'),
// User & Status
'user': createIcon('<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'),
'clock': createIcon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
'share': createIcon('<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>'),
'webhook': createIcon('<path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/>'),
'alert-circle': createIcon('<circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/>'),
'x-circle': createIcon('<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>'),
'arrow-left': createIcon('<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>'),
// Navigation // Navigation
'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'), 'chevron-right': createIcon('<path d="m9 18 6-6-6-6"/>'),
'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'), 'chevron-left': createIcon('<path d="m15 18-6-6 6-6"/>'),

View File

@@ -7,6 +7,8 @@ export { Header } from './Header';
export { Section } from './ui/Section'; export { Section } from './ui/Section';
export { DeploymentsTable } from './DeploymentsTable'; export { DeploymentsTable } from './DeploymentsTable';
export { DeploymentLogs } from './DeploymentLogs'; export { DeploymentLogs } from './DeploymentLogs';
export { DeploymentDashboard } from './DeploymentDashboard';
export { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from './DeploymentSkeleton';
export { VitalsBar } from './VitalsBar'; export { VitalsBar } from './VitalsBar';
export { OverviewTab } from './OverviewTab'; export { OverviewTab } from './OverviewTab';
export { SystemTrends } from './SystemTrends'; export { SystemTrends } from './SystemTrends';

264
src/lib/docker.ts Normal file
View File

@@ -0,0 +1,264 @@
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
// SSH host configured in ~/.ssh/config
const SSH_HOST = 'nuc';
// Timeout for SSH commands (10 seconds)
const SSH_TIMEOUT = 10000;
export interface ContainerStats {
cpuPercent: number;
memoryUsage: string;
memoryLimit: string;
memoryPercent: number;
netIO: { rx: string; tx: string };
blockIO: { read: string; write: string };
}
export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'none';
/**
* Execute a command via SSH to the NUC server.
* Returns stdout on success, null on error.
*/
export async function sshExec(command: string): Promise<string | null> {
try {
const { stdout } = await execAsync(`ssh ${SSH_HOST} "${command.replace(/"/g, '\\"')}"`, {
timeout: SSH_TIMEOUT,
});
return stdout.trim();
} catch {
return null;
}
}
/**
* Get the health status of a container.
* Returns 'healthy', 'unhealthy', 'starting', 'none' (no healthcheck configured),
* or null if container doesn't exist.
*/
export async function getContainerHealth(containerName: string): Promise<HealthStatus | null> {
// First check if container exists
const exists = await sshExec(
`docker inspect --format='{{.State.Status}}' ${containerName} 2>/dev/null`
);
if (!exists) return null;
// Now get health status
const result = await sshExec(
`docker inspect --format='{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' ${containerName} 2>/dev/null`
);
if (!result) return null;
const status = result.trim();
if (status === 'none' || status === '' || status === '<nil>') {
return 'none';
}
if (['healthy', 'unhealthy', 'starting'].includes(status)) {
return status as HealthStatus;
}
return 'none';
}
/**
* Get container resource statistics (CPU, memory, network I/O, block I/O).
* Returns null if container not found or not running.
*/
export async function getContainerStats(containerName: string): Promise<ContainerStats | null> {
const result = await sshExec(
`docker stats --no-stream --format='{{.CPUPerc}},{{.MemUsage}},{{.NetIO}},{{.BlockIO}}' ${containerName} 2>/dev/null`
);
if (!result) return null;
// Format: "0.08%,130.2MiB / 7.648GiB,27MB / 6.92MB,4.1kB / 4.1kB"
const parts = result.split(',');
if (parts.length < 4) return null;
const cpuStr = parts[0].replace('%', '').trim();
const cpuPercent = parseFloat(cpuStr) || 0;
// Memory: "130.2MiB / 7.648GiB"
const memParts = parts[1].split('/').map((s) => s.trim());
const memoryUsage = memParts[0] || '0';
const memoryLimit = memParts[1] || '0';
// Calculate memory percentage
const memUsageBytes = parseMemoryToBytes(memoryUsage);
const memLimitBytes = parseMemoryToBytes(memoryLimit);
const memoryPercent = memLimitBytes > 0 ? (memUsageBytes / memLimitBytes) * 100 : 0;
// Network I/O: "27MB / 6.92MB"
const netParts = parts[2].split('/').map((s) => s.trim());
const netIO = { rx: netParts[0] || '0', tx: netParts[1] || '0' };
// Block I/O: "4.1kB / 4.1kB"
const blockParts = parts[3].split('/').map((s) => s.trim());
const blockIO = { read: blockParts[0] || '0', write: blockParts[1] || '0' };
return {
cpuPercent,
memoryUsage,
memoryLimit,
memoryPercent,
netIO,
blockIO,
};
}
/**
* Parse memory string like "130.2MiB" or "7.648GiB" to bytes.
*/
function parseMemoryToBytes(memStr: string): number {
const match = memStr.match(/^([\d.]+)\s*(\w+)?$/);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'B').toUpperCase();
const units: Record<string, number> = {
B: 1,
KB: 1024,
KIB: 1024,
MB: 1024 * 1024,
MIB: 1024 * 1024,
GB: 1024 * 1024 * 1024,
GIB: 1024 * 1024 * 1024,
TB: 1024 * 1024 * 1024 * 1024,
TIB: 1024 * 1024 * 1024 * 1024,
};
return value * (units[unit] || 1);
}
/**
* Get container uptime in seconds.
* Returns null if container not found or not running.
*/
export async function getContainerUptime(containerName: string): Promise<number | null> {
const result = await sshExec(
`docker inspect --format='{{.State.StartedAt}}' ${containerName} 2>/dev/null`
);
if (!result || result === '0001-01-01T00:00:00Z') return null;
try {
const startTime = new Date(result.trim());
if (isNaN(startTime.getTime())) return null;
const now = new Date();
const uptimeMs = now.getTime() - startTime.getTime();
return Math.max(0, Math.floor(uptimeMs / 1000));
} catch {
return null;
}
}
/**
* Format uptime seconds to human-readable string.
* e.g., "2d 5h 30m" or "45m 12s"
*/
export function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
return parts.join(' ') || '0m';
}
/**
* Find container name by app/service name.
* Searches through running containers for matches.
* Returns the first matching container name, or null if not found.
*/
export async function findContainerByAppName(appName: string): Promise<string | null> {
// Get list of all container names
const result = await sshExec(`docker ps -a --format='{{.Names}}'`);
if (!result) return null;
const containers = result.split('\n').filter((name) => name.trim());
const searchName = appName.toLowerCase();
// Try exact match first
const exactMatch = containers.find(
(c) => c.toLowerCase() === searchName
);
if (exactMatch) return exactMatch;
// Try prefix match (e.g., "outline" matches "outline-pccg80wks4c084008owokkkg")
const prefixMatch = containers.find(
(c) => c.toLowerCase().startsWith(searchName + '-') || c.toLowerCase().startsWith(searchName)
);
if (prefixMatch) return prefixMatch;
// Try contains match
const containsMatch = containers.find(
(c) => c.toLowerCase().includes(searchName)
);
if (containsMatch) return containsMatch;
return null;
}
/**
* Get container status (running, exited, etc.)
*/
export async function getContainerStatus(containerName: string): Promise<string | null> {
const result = await sshExec(
`docker inspect --format='{{.State.Status}}' ${containerName} 2>/dev/null`
);
return result || null;
}
/**
* Check if a container exists and is running.
*/
export async function isContainerRunning(containerName: string): Promise<boolean> {
const status = await getContainerStatus(containerName);
return status === 'running';
}
/**
* Find container name by application UUID.
* Coolify container names typically start with the application UUID followed by a hyphen and build number.
* e.g., "t80w0cw0oooc4g0soswos4so-160146641903" for application UUID "t80w0cw0oooc4g0soswos4so"
* Returns the first matching container name, or null if not found.
*/
export async function findContainerByUuid(appUuid: string): Promise<string | null> {
if (!appUuid || appUuid === 'unknown') return null;
// Get list of all container names
const result = await sshExec(`docker ps -a --format='{{.Names}}'`);
if (!result) return null;
const containers = result.split('\n').filter((name) => name.trim());
const searchUuid = appUuid.toLowerCase();
// Try exact UUID prefix match (most common for Coolify apps)
// Container names are like: uuid-buildnumber (e.g., t80w0cw0oooc4g0soswos4so-160146641903)
const prefixMatch = containers.find(
(c) => c.toLowerCase().startsWith(searchUuid + '-') || c.toLowerCase() === searchUuid
);
if (prefixMatch) return prefixMatch;
// Try contains match (for service containers like outline-pccg80...)
const containsMatch = containers.find(
(c) => c.toLowerCase().includes(searchUuid)
);
if (containsMatch) return containsMatch;
return null;
}