From efc7a8392b6eaa54808465c86186d770ecc52b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:00:14 +0100 Subject: [PATCH] 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 --- package-lock.json | 25 +- package.json | 3 +- .../api/deployments/[uuid]/health/route.ts | 128 ++ .../api/deployments/[uuid]/redeploy/route.ts | 41 + src/app/api/deployments/[uuid]/stats/route.ts | 89 ++ src/app/deployments/[uuid]/page.tsx | 124 ++ src/components/DeploymentDashboard.tsx | 1068 +++++++++++++++++ src/components/DeploymentSkeleton.tsx | 260 ++++ src/components/DeploymentsTable.tsx | 26 +- src/components/Icons.tsx | 9 + src/components/index.ts | 2 + src/lib/docker.ts | 264 ++++ 12 files changed, 2031 insertions(+), 8 deletions(-) create mode 100644 src/app/api/deployments/[uuid]/health/route.ts create mode 100644 src/app/api/deployments/[uuid]/redeploy/route.ts create mode 100644 src/app/api/deployments/[uuid]/stats/route.ts create mode 100644 src/app/deployments/[uuid]/page.tsx create mode 100644 src/components/DeploymentDashboard.tsx create mode 100644 src/components/DeploymentSkeleton.tsx create mode 100644 src/lib/docker.ts diff --git a/package-lock.json b/package-lock.json index 64b4540..fe08fb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "pg": "^8.18.0", "react": "19.2.3", "react-dom": "19.2.3", - "recharts": "^3.7.0" + "recharts": "^3.7.0", + "swr": "^2.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -3056,6 +3057,15 @@ "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -6565,6 +6575,19 @@ "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": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", diff --git a/package.json b/package.json index 9182762..3900f07 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "pg": "^8.18.0", "react": "19.2.3", "react-dom": "19.2.3", - "recharts": "^3.7.0" + "recharts": "^3.7.0", + "swr": "^2.4.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/api/deployments/[uuid]/health/route.ts b/src/app/api/deployments/[uuid]/health/route.ts new file mode 100644 index 0000000..c345448 --- /dev/null +++ b/src/app/api/deployments/[uuid]/health/route.ts @@ -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 === '') { + 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 } + ); + } +} diff --git a/src/app/api/deployments/[uuid]/redeploy/route.ts b/src/app/api/deployments/[uuid]/redeploy/route.ts new file mode 100644 index 0000000..50b6092 --- /dev/null +++ b/src/app/api/deployments/[uuid]/redeploy/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/deployments/[uuid]/stats/route.ts b/src/app/api/deployments/[uuid]/stats/route.ts new file mode 100644 index 0000000..bdcf089 --- /dev/null +++ b/src/app/api/deployments/[uuid]/stats/route.ts @@ -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 } + ); + } +} diff --git a/src/app/deployments/[uuid]/page.tsx b/src/app/deployments/[uuid]/page.tsx new file mode 100644 index 0000000..b81f2c8 --- /dev/null +++ b/src/app/deployments/[uuid]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ; + } + + if (error) { + return ; + } + + if (!deployment) { + return ; + } + + return ; + }; + + return ( +
+ {/* Header with breadcrumb */} +
+
+ {/* Breadcrumb navigation */} + +
+
+ + {/* Main content */} +
+ {renderContent()} +
+
+ ); +} diff --git a/src/components/DeploymentDashboard.tsx b/src/components/DeploymentDashboard.tsx new file mode 100644 index 0000000..2746312 --- /dev/null +++ b/src/components/DeploymentDashboard.tsx @@ -0,0 +1,1068 @@ +'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 = { + finished: 'bg-emerald-500', + error: 'bg-red-500', + in_progress: 'bg-amber-500 animate-pulse', + queued: 'bg-slate-400', + cancelled: 'bg-slate-400', + }; + + return ( +
+ + {showLabel && ( + + {STATUS_LABELS[status]} + + )} +
+ ); +}; + +// 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 ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +// 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 = ( +
+
+
+ + + {title} + +
+
+ {badge && ( + + {badge} + + )} + {isExternal && href && !isDisabled && ( + + )} +
+
+

+ {loading ? 'Triggering deployment...' : description} +

+
+ ); + + if (href && !isDisabled) { + return ( + + {content} + + ); + } + + if (onClick && !isDisabled) { + return ; + } + + return content; +} + +// Metadata row component +function MetadataRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+
{children}
+
+ ); +} + +// Health indicator component with colored dot +function HealthIndicator({ + status, + isLoading, + deploymentStatus, +}: { + status: HealthStatus | undefined; + isLoading: boolean; + deploymentStatus?: DeploymentStatus; +}) { + if (isLoading) { + return ( +
+ + checking... +
+ ); + } + + // Handle building/queued deployments that don't have containers yet + if (deploymentStatus === 'in_progress' || deploymentStatus === 'queued') { + return ( +
+ + + {deploymentStatus === 'in_progress' ? 'Building...' : 'Pending'} + +
+ ); + } + + // Handle failed deployments + if (deploymentStatus === 'error') { + return ( +
+ + Failed +
+ ); + } + + // Handle cancelled deployments + if (deploymentStatus === 'cancelled') { + return ( +
+ + Cancelled +
+ ); + } + + const statusConfig: Record = { + 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 ( +
+ + {config.label} +
+ ); +} + +// 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 ( +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + // 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 ( +
+ + Container not started yet - deployment in progress +
+ ); + } + + // Deployment failed - container may never have started + if (deploymentStatus === 'error') { + return ( +
+ + Deployment failed - no container available +
+ ); + } + + // Deployment cancelled + if (deploymentStatus === 'cancelled') { + return ( +
+ + Deployment cancelled - no container available +
+ ); + } + + // Default: container not found (may be stopped or removed) + return ( +
+ Container not found or not running +
+ ); + } + + return ( +
+ {/* CPU */} +
+
+ + CPU +
+ + {stats ? `${stats.cpuPercent.toFixed(1)}%` : '-'} + +
+ + {/* Memory */} +
+
+ + Memory +
+ + {stats ? stats.memoryUsage : '-'} + + {stats && ( + + ({stats.memoryPercent.toFixed(1)}%) + + )} +
+ + {/* Uptime */} +
+
+ + Uptime +
+ + {uptime ? uptime.formatted : '-'} + +
+ + {/* Container */} +
+
+ + Container +
+ + {containerName ? containerName.substring(0, 20) : '-'} + +
+
+ ); +} + +export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { + const [activeTab, setActiveTab] = useState('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( + `/api/deployments/${deployment.deployment_uuid}/health`, + fetcher, + { refreshInterval: 10000 } + ); + + const { data: stats, isLoading: statsLoading } = useSWR( + `/api/deployments/${deployment.deployment_uuid}/stats`, + fetcher, + { refreshInterval: 10000 } + ); + + 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 ( +
+ {/* Header section with app name and actions */} +
+
+ +
+ +
+ +
+

+ {deployment.application_name} +

+
+ + {deployment.deployment_uuid.substring(0, 9)} + +
+
+
+ + {/* Action buttons - Vercel style */} +
+ + + {deployment.application_fqdn && ( + + Visit + + + )} +
+
+ + {/* Tab navigation (Vercel style) */} +
+ +
+ + {/* Tab content */} + {activeTab === 'deployment' && ( +
+ {/* Error banner for failed deployments */} + {isErrorState && ( +
+
+ +
+

+ Deployment Failed +

+

+ Check the build logs below for error details. +

+
+
+
+ )} + + {/* In-progress banner */} + {isInProgress && ( +
+
+ +
+

+ {deployment.status === 'queued' ? 'Deployment Queued' : 'Deployment in Progress'} +

+

+ {deployment.status === 'queued' + ? 'Waiting for available resources...' + : 'Building and deploying your application...'} +

+
+
+
+ )} + + {/* Deployment Details Header - Vercel style */} +
+

+ Deployment Details +

+
+ + + {deployment.application_fqdn && ( + + Visit + + + )} +
+
+ + {/* Main content: Preview + Metadata Grid */} +
+
+ {/* Preview thumbnail (left side) */} +
+
+
+ + Preview +
+
+
+ + {/* Metadata grid (right side) - 2 columns like Vercel */} +
+ {/* Created */} + +
+
+ +
+ {formatDate(deployment.created_at)} +
+
+ + {/* Status */} + +
+ + {deployment.is_current && ( + + Latest + + )} +
+
+ + {/* Health */} + + + + + {/* Duration */} + +
+ + + {deployment.duration != null + ? formatDuration(deployment.duration) + : isInProgress + ? 'In progress...' + : '-'} + + + {deployment.created_at ? formatRelativeTime(deployment.created_at) : ''} + +
+
+ + {/* Environment */} + +
+ + + Production + + {deployment.is_current && ( + Current + )} +
+
+ + {/* Domains */} + + {deployment.application_fqdn ? ( + + + + {deployment.application_fqdn.replace(/^https?:\/\//, '')} + + + ) : ( + No domain configured + )} + + + {/* Source */} + +
+ + {deployment.git_branch || '—'} + + {deployment.git_commit_sha?.substring(0, 7) || '—'} + +
+

+ {deployment.commit_message || 'No commit message'} +

+
+
+
+
+ + {/* Collapsible Sections - Vercel style */} + + Inherited + + } + > +
+ Settings inherited from application configuration. +
+
+ + + {warningCount > 0 && ( + + + {warningCount} + + )} + {deployment.duration && ( + + {formatDuration(deployment.duration)} + + )} + +
+ } + > + + + + + {stats?.stats && ( + + CPU: {stats.stats.cpuPercent.toFixed(1)}% + + )} + +
+ } + > +
+ +
+ + + + + + Resources + + +
+ } + > +
+ {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.'} +
+ + + {/* Action Cards Grid - Vercel style */} +
+
+ + + + +
+
+
+ )} + + {activeTab === 'logs' && ( +
+
+

+ Build Logs +

+
+
+ +
+
+ )} + + {activeTab === 'resources' && ( +
+
+

+ Resources +

+
+ +
+
+ + {/* Stats Grid */} + + + {/* Additional Network & Disk I/O */} + {stats?.stats && ( +
+
+

+ Network I/O +

+
+
+ Received + + {stats.stats.netIO.rx} + +
+
+ Transmitted + + {stats.stats.netIO.tx} + +
+
+
+ +
+

+ Block I/O +

+
+
+ Read + + {stats.stats.blockIO.read} + +
+
+ Write + + {stats.stats.blockIO.write} + +
+
+
+
+ )} + + {/* Health History */} + {health?.log && health.log.length > 0 && ( +
+

+ Health Check History +

+
+ {health.log.slice(-5).reverse().map((entry, i) => ( +
+ + + {new Date(entry.end).toLocaleTimeString()} + + + {entry.output.trim() || (entry.exitCode === 0 ? 'OK' : 'Failed')} + +
+ ))} +
+
+ )} +
+ )} + + {activeTab === 'source' && ( +
+

+ Source +

+
+
+ +
+

+ {deployment.git_branch || '—'} +

+

+ {deployment.git_commit_sha + ? deployment.git_commit_sha.substring(0, 7) + : '—'} +

+
+
+
+

+ {deployment.commit_message || 'No commit message'} +

+
+
+ {deployment.is_webhook && ( + + + Triggered by webhook + + )} + {deployment.is_api && ( + + + Triggered by API + + )} + {!deployment.is_webhook && !deployment.is_api && ( + + + Manual trigger + + )} +
+
+
+ )} + + {/* Link to Coolify */} + + + ); +} diff --git a/src/components/DeploymentSkeleton.tsx b/src/components/DeploymentSkeleton.tsx new file mode 100644 index 0000000..c854713 --- /dev/null +++ b/src/components/DeploymentSkeleton.tsx @@ -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 ( +
+ {/* Header section with app name and actions */} +
+
+
+
+
+
+
+
+ + {/* Action buttons skeleton */} +
+
+
+
+
+
+ + {/* Tab navigation skeleton */} +
+
+ {['Deployment', 'Logs', 'Resources', 'Source'].map((tab, i) => ( +
+ ))} +
+
+ + {/* Main content card skeleton */} +
+ {/* Card header */} +
+
+
+
+
+
+
+
+ + {/* Main content: Preview + Metadata Grid */} +
+
+ {/* Preview thumbnail skeleton */} +
+
+ +
+
+ + {/* Metadata grid skeleton */} +
+ {/* Created */} + + {/* Status */} + + {/* Health */} + + {/* Duration */} + + {/* Environment */} + + {/* Domains */} + + {/* Source */} + +
+
+
+ + {/* Collapsible sections skeleton */} + {['Deployment Settings', 'Build Logs', 'Container Stats', 'Deployment Summary'].map( + (section, i) => ( +
+
+
+
+ {i === 1 && ( +
+
+
+
+ )} +
+ ) + )} + + {/* Action Cards Grid skeleton */} +
+
+ {[...Array(4)].map((_, i) => ( + + ))} +
+
+
+ + {/* Footer link skeleton */} +
+
+
+
+ ); +} + +/** + * Skeleton for metadata rows + */ +function MetadataRowSkeleton({ + hasIndicator = false, + hasBadge = false, + hasSecondLine = false, +}: { + hasIndicator?: boolean; + hasBadge?: boolean; + hasSecondLine?: boolean; +}) { + return ( +
+
+
+ {hasIndicator && ( +
+ )} +
+ {hasBadge && ( +
+ )} +
+ {hasSecondLine && ( +
+ )} +
+ ); +} + +/** + * Skeleton for action cards + */ +function ActionCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +/** + * Error state component for deployment page + */ +export function DeploymentError({ + error, + uuid, +}: { + error: string; + uuid: string; +}) { + return ( +
+
+ +
+

+ {error} +

+

+ {error === 'Deployment not found' ? ( + <> + The deployment with UUID "{uuid}" 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. + + )} +

+
+ + + + Back to Deployments + +
+
+ ); +} + +/** + * Empty state component for when no deployment data exists + */ +export function DeploymentEmpty({ uuid }: { uuid: string }) { + return ( +
+
+ +
+

+ No deployment data +

+

+ The deployment "{uuid.substring(0, 9)}" exists + but has no data available yet. It may still be initializing. +

+
+ + + + Back to Deployments + +
+
+ ); +} diff --git a/src/components/DeploymentsTable.tsx b/src/components/DeploymentsTable.tsx index c63247d..aa7e870 100644 --- a/src/components/DeploymentsTable.tsx +++ b/src/components/DeploymentsTable.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useMemo, useEffect, Fragment } from 'react'; +import { useState, useMemo, useEffect, Fragment, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; import { useReactTable, getCoreRowModel, @@ -71,6 +72,7 @@ function LiveDuration({ createdAt }: { createdAt: string }) { } export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy }: DeploymentsTableProps) { + const router = useRouter(); const [sorting, setSorting] = useState([ { id: 'created_at', desc: true }, ]); @@ -79,6 +81,17 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy } const [statusFilter, setStatusFilter] = useState('all'); const [appFilter, setAppFilter] = useState('all'); + // Toggle expand with stopPropagation to prevent row click navigation + const toggleExpand = useCallback((row: Row, 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 names = new Set(deployments.map((d) => d.application_name)); return Array.from(names).sort(); @@ -98,13 +111,14 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy } header: 'Deployment', cell: ({ row, getValue }) => ( @@ -239,7 +253,7 @@ export function DeploymentsTable({ deployments, isLoading, onRefresh, onDeploy } ), }), ], - [onDeploy] + [onDeploy, toggleExpand] ); 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 ${ row.getIsExpanded() ? 'bg-slate-50 dark:bg-stone-800/50' : '' }`} - onClick={() => row.toggleExpanded()} + onClick={() => handleRowClick(row.original.deployment_uuid)} > {row.getVisibleCells().map((cell) => ( diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 6906791..9fdffff 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -113,6 +113,15 @@ export const icons: Record> = { 'power': createIcon(''), 'stop-circle': createIcon(''), + // User & Status + 'user': createIcon(''), + 'clock': createIcon(''), + 'share': createIcon(''), + 'webhook': createIcon(''), + 'alert-circle': createIcon(''), + 'x-circle': createIcon(''), + 'arrow-left': createIcon(''), + // Navigation 'chevron-right': createIcon(''), 'chevron-left': createIcon(''), diff --git a/src/components/index.ts b/src/components/index.ts index 234cc47..dacd230 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -7,6 +7,8 @@ export { Header } from './Header'; export { Section } from './ui/Section'; export { DeploymentsTable } from './DeploymentsTable'; export { DeploymentLogs } from './DeploymentLogs'; +export { DeploymentDashboard } from './DeploymentDashboard'; +export { DeploymentSkeleton, DeploymentError, DeploymentEmpty } from './DeploymentSkeleton'; export { VitalsBar } from './VitalsBar'; export { OverviewTab } from './OverviewTab'; export { SystemTrends } from './SystemTrends'; diff --git a/src/lib/docker.ts b/src/lib/docker.ts new file mode 100644 index 0000000..945e34a --- /dev/null +++ b/src/lib/docker.ts @@ -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 { + 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 { + // 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 === '') { + 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 { + 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 = { + 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 { + 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 { + // 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 { + 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 { + 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 { + 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; +}