# Deployment Dashboard Implementation Plan **Date:** 2026-02-06 18:00 **Project:** nuc-portal **Goal:** Add Vercel-style deployment detail pages with parallel wave-based implementation --- ## Quick Start ```bash # Launch Wave 1 (3 parallel agents) # Each agent follows: Research → Implement → Verify Loop pattern ``` --- ## Reference Resources | Resource | Location | |----------|----------| | **Vercel Reference** | https://vercel.com/intentisenough/intentisenough/FtWsb3pocwJDAsvqbrWUnkb8Biz8 | | **nuc-portal codebase** | `/Users/agutierrez/Desktop/nuc/nuc-portal/` | | **Design doc** | `.artifacts/2026-02-06_17-30_deployment-dashboard-design.md` | | **Playwright MCP** | `mcp__playwriter-local__*` (local) or `mcp__playwriter-nuc-01__*` (remote) | --- ## Architecture Overview ``` Current Flow: Deployments Tab → Click Row → Expand/Collapse Logs Inline New Flow: Deployments Tab → Click Row → Navigate to /deployments/[uuid] → Click Expand Button → Expand Logs Inline (preserved) ``` ### New Files Structure ``` nuc-portal/src/ ├── app/deployments/[uuid]/ │ └── page.tsx # Dashboard page (Wave 1A) ├── components/ │ └── DeploymentDashboard.tsx # Dashboard component (Wave 2A) ├── lib/ │ └── docker.ts # Docker API helpers (Wave 1B) └── app/api/deployments/[uuid]/ ├── health/route.ts # Healthcheck endpoint (Wave 2B) └── stats/route.ts # Container stats endpoint (Wave 2C) ``` --- ## Agent Execution Pattern All agents follow this pattern: ``` ┌──────────┐ ┌───────────┐ ┌──────────┐ │ RESEARCH │ ──→ │ IMPLEMENT │ ──→ │ VERIFY │ └──────────┘ └───────────┘ └────┬─────┘ │ │ │ (Frontend only) ┌─────┴─────┐ ▼ YES NO ┌──────────┐ │ │ │Playwright│ ▼ ▼ │ Snapshot │ [DONE] ┌────────┐ └──────────┘ │ANALYZE │ │ & FIX │ └───┬────┘ │ ┌──────┴──────┐ │ retry < 3? │ └──────┬──────┘ YES │ NO │ │ │ ▼ │ ▼ [VERIFY] │ [ESCALATE] ◄──────┘ ``` ### Verification Loop Rules 1. **Max 3 retries** per verification step 2. On failure: analyze error → apply fix → re-verify 3. After 3 failures: **ESCALATE** to parent with error details 4. Never skip verification - all tasks must pass before wave completes --- ## Wave 1: Foundation (Parallel) ### Task 1A: Route & Page Structure **Type:** Frontend | **Agent:** general-purpose #### Research Phase ``` Use Playwright MCP to study Vercel deployment page: 1. mcp__playwriter-local__execute → navigate to Vercel URL 2. Take accessibility snapshot 3. Document: - Breadcrumb structure - Tab layout - Header section layout - Action button positions ``` #### Implement Phase ```typescript // Create: src/app/deployments/[uuid]/page.tsx import { DeploymentDashboard } from '@/components/DeploymentDashboard'; interface Props { params: { uuid: string }; } export default async function DeploymentPage({ params }: Props) { const { uuid } = params; // Fetch deployment data const res = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/deployments/${uuid}`, { cache: 'no-store' }); const deployment = await res.json(); return (
{/* Breadcrumb */} {/* Dashboard Component (placeholder for Wave 2A) */}
); } ``` ```typescript // Create placeholder: src/components/DeploymentDashboard.tsx interface DeploymentDashboardProps { deployment: any; } export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { return (

{deployment.deployment_uuid}

Dashboard coming in Wave 2...

); } ``` #### Verify Loop ```bash # Step 1: Build check npm run build # If fails → read error → fix imports/syntax → retry # Step 2: Dev server npm run dev & # Step 3: Playwright route test # Navigate to http://localhost:3000/deployments/test-uuid # Verify: page loads (not 404), shows deployment UUID # If 404 → check [uuid] folder structure → fix → retry ``` #### Pass Criteria - `npm run build` succeeds - Route `/deployments/[uuid]` loads without 404 - Page displays deployment UUID from URL #### Escalate If - Build fails after 3 attempts with same error - Next.js routing not recognizing dynamic segment --- ### Task 1B: Docker API Helpers **Type:** Backend | **Agent:** general-purpose #### Implement Phase ```typescript // Create: src/lib/docker.ts import { exec } from 'child_process'; import { promisify } from 'util'; const execAsync = promisify(exec); const SSH_HOST = 'nuc'; async function sshExec(command: string): Promise { const { stdout } = await execAsync(`ssh ${SSH_HOST} "${command}"`); return stdout.trim(); } export interface ContainerHealth { status: 'healthy' | 'unhealthy' | 'starting' | 'none' | 'unknown'; failingStreak: number; log: string | null; } export async function getContainerHealth(containerName: string): Promise { try { const format = '{{.State.Health.Status}}|{{.State.Health.FailingStreak}}|{{.State.Health.Log}}'; const result = await sshExec(`docker inspect --format='${format}' ${containerName} 2>/dev/null`); if (!result || result.includes('No such object')) { return { status: 'unknown', failingStreak: 0, log: null }; } const [status, streak, log] = result.split('|'); return { status: (status as ContainerHealth['status']) || 'none', failingStreak: parseInt(streak) || 0, log: log || null }; } catch (error) { return { status: 'unknown', failingStreak: 0, log: null }; } } export interface ContainerStats { cpuPercent: string; memoryUsage: string; memoryLimit: string; memoryPercent: string; netIO: string; blockIO: string; } export async function getContainerStats(containerName: string): Promise { try { const format = '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.NetIO}}|{{.BlockIO}}'; const result = await sshExec(`docker stats --no-stream --format='${format}' ${containerName} 2>/dev/null`); if (!result) return null; const [cpu, mem, memPerc, net, block] = result.split('|'); const [memUsage, memLimit] = mem.split(' / '); return { cpuPercent: cpu, memoryUsage: memUsage, memoryLimit: memLimit, memoryPercent: memPerc, netIO: net, blockIO: block }; } catch (error) { return null; } } export interface ContainerUptime { startedAt: string; uptime: string; // human readable } export async function getContainerUptime(containerName: string): Promise { try { const startedAt = await sshExec(`docker inspect --format='{{.State.StartedAt}}' ${containerName} 2>/dev/null`); if (!startedAt || startedAt.includes('No such object')) return null; const startDate = new Date(startedAt); const now = new Date(); const diffMs = now.getTime() - startDate.getTime(); const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); let uptime = ''; if (days > 0) uptime += `${days}d `; if (hours > 0) uptime += `${hours}h `; uptime += `${minutes}m`; return { startedAt, uptime: uptime.trim() }; } catch (error) { return null; } } // Helper to find container name from deployment export async function findContainerByAppName(appName: string): Promise { try { const result = await sshExec(`docker ps --format='{{.Names}}' | grep -i '${appName}' | head -1`); return result || null; } catch (error) { return null; } } ``` #### Verify Loop ```bash # Step 1: Get a real container name from NUC ssh nuc "docker ps --format='{{.Names}}' | head -1" # Example output: nuc-portal-t80w0cw0oooc4g0soswos4so # Step 2: Test getContainerHealth # Create test script or use Node REPL # Compare output to: ssh nuc "docker inspect --format='{{.State.Health.Status}}' " # Step 3: Test getContainerStats # Compare output to: ssh nuc "docker stats --no-stream " # Step 4: Test getContainerUptime # Compare output to: ssh nuc "docker inspect --format='{{.State.StartedAt}}' " # If mismatch → check parsing logic → fix regex/split → retry ``` #### Pass Criteria - All 3 functions return data for real NUC container - Values match direct docker CLI output - Handles missing/stopped containers gracefully (returns null, not throws) #### Escalate If - SSH connection consistently failing - Docker output format doesn't match expected structure --- ### Task 1C: Table UI - Expand Button **Type:** Frontend | **Agent:** general-purpose #### Research Phase ``` Use Playwright to study Vercel deployments list: 1. Navigate to Vercel dashboard deployments list 2. Snapshot the row structure 3. Note: - Expand/collapse button icon and position - Row hover states - What clicking row vs button does ``` #### Implement Phase ```typescript // Modify: src/components/DeploymentsTable.tsx // Add to imports import { ChevronDown, ChevronRight } from 'lucide-react'; import { useRouter } from 'next/navigation'; // Inside component: const router = useRouter(); const [expandedRows, setExpandedRows] = useState>(new Set()); const toggleExpand = (uuid: string, e: React.MouseEvent) => { e.stopPropagation(); // Prevent row click navigation setExpandedRows(prev => { const next = new Set(prev); if (next.has(uuid)) { next.delete(uuid); } else { next.add(uuid); } return next; }); }; const handleRowClick = (uuid: string) => { router.push(`/deployments/${uuid}`); }; // In table row: handleRowClick(deployment.deployment_uuid)} > {/* ... rest of cells ... */} // After row, conditionally render expanded content: {expandedRows.has(deployment.deployment_uuid) && ( )} ``` #### Verify Loop ``` 1. Start dev server: npm run dev 2. Playwright → navigate to http://localhost:3000 3. Go to Deployments tab 4. Verify: expand button (chevron) visible on each row 5. Click expand button → logs appear below row 6. Click expand button again → logs collapse 7. Click row (not button) → verify NO expansion (navigation will work in Wave 2D) 8. If button missing → check import/render → fix → retry 9. If click propagates → verify stopPropagation → fix → retry ``` #### Pass Criteria - Chevron button renders on each deployment row - Clicking button toggles log visibility - Clicking button does NOT trigger row click - Row click does nothing yet (navigation added in Wave 2D) #### Escalate If - Table component structure incompatible - State management conflicts with existing code --- ## Wave 2: Core Components (Parallel) **Dependency:** All Wave 1 tasks must pass verification ### Task 2A: Dashboard Component **Type:** Frontend | **Agent:** general-purpose #### Research Phase (Detailed Playwright Study) ``` Navigate to Vercel deployment page and capture: 1. Header Section: - Left: Preview thumbnail or icon - Right: Metadata grid (Created, Status, Duration, Environment, Domains, Source) - Status badge styling (color, text) - "Latest" badge if applicable 2. Build Logs Section: - Collapsible header with line count, warning count, search - Log line format (timestamp, content) - Warning highlighting (yellow background) - Scrollable container 3. Bottom Cards: - 4-column grid layout - Card structure (icon, title, description, link) Document exact spacing, colors, typography. ``` #### Implement Phase ```typescript // Replace placeholder: src/components/DeploymentDashboard.tsx 'use client'; import { useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ChevronDown, ChevronRight, ExternalLink, GitBranch, Clock, Server, Activity, RefreshCw } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; interface DeploymentDashboardProps { deployment: { deployment_uuid: string; application_name: string; status: string; commit: string; commit_message: string; git_branch?: string; created_at: string; finished_at: string | null; logs: Array<{ timestamp: string; message: string }>; deployment_url: string; fqdn?: string; }; } export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { const [logsExpanded, setLogsExpanded] = useState(true); const duration = deployment.finished_at ? Math.round((new Date(deployment.finished_at).getTime() - new Date(deployment.created_at).getTime()) / 1000) : null; const statusColor = { finished: 'bg-green-500', error: 'bg-red-500', in_progress: 'bg-yellow-500', queued: 'bg-gray-500', cancelled: 'bg-gray-400' }[deployment.status] || 'bg-gray-500'; return (
{/* Header Card */}
{/* Left: App Icon/Preview */}
{/* Right: Metadata Grid */}

Status

{deployment.status.replace('_', ' ')}

Duration

{duration ? `${Math.floor(duration / 60)}m ${duration % 60}s` : '—'}

Created

{formatDistanceToNow(new Date(deployment.created_at), { addSuffix: true })}

Environment

production
{deployment.fqdn && ( )}

Source

{deployment.git_branch || 'main'} · {deployment.commit?.slice(0, 7)} · {deployment.commit_message}
{/* Build Logs Section */} setLogsExpanded(!logsExpanded)} >
{logsExpanded ? : } Build Logs {deployment.logs?.length || 0} lines
{logsExpanded && (
{deployment.logs?.map((log, i) => (
{new Date(log.timestamp).toLocaleTimeString()} {log.message}
)) ||

No logs available

}
)}
{/* Action Cards Grid */}

Runtime Logs

View live container logs

Redeploy

Trigger new deployment

Rollback

Revert to previous

); } ``` #### Verify Loop ``` 1. npm run build → check for type errors 2. npm run dev 3. Playwright → navigate to /deployments/[real-uuid] 4. Verify all sections render: - Header with status, duration, domains, git info - Build logs (collapsible) - Action cards grid 5. Compare layout to Vercel reference 6. If missing data → check API response → fix data mapping → retry 7. If styling off → adjust Tailwind classes → retry ``` #### Pass Criteria - All sections render without errors - Data displays correctly from API - Logs expand/collapse works - Layout similar to Vercel reference --- ### Task 2B: Health Endpoint **Type:** Backend | **Agent:** general-purpose #### Implement Phase ```typescript // Create: src/app/api/deployments/[uuid]/health/route.ts import { NextResponse } from 'next/server'; import { getContainerHealth, findContainerByAppName } from '@/lib/docker'; export async function GET( request: Request, { params }: { params: { uuid: string } } ) { try { const { uuid } = params; // Get deployment info to find container name const deploymentRes = await fetch( `${process.env.COOLIFY_API_URL}/deployments/${uuid}`, { headers: { 'Authorization': `Bearer ${process.env.COOLIFY_API_TOKEN}` } } ); if (!deploymentRes.ok) { return NextResponse.json( { error: 'Deployment not found' }, { status: 404 } ); } const deployment = await deploymentRes.json(); const containerName = await findContainerByAppName(deployment.application_name); if (!containerName) { return NextResponse.json({ status: 'unknown', message: 'Container not found', lastCheck: new Date().toISOString() }); } const health = await getContainerHealth(containerName); return NextResponse.json({ ...health, containerName, lastCheck: new Date().toISOString() }); } catch (error) { return NextResponse.json( { error: 'Failed to get health status', details: String(error) }, { status: 500 } ); } } ``` #### Verify Loop ```bash # Step 1: Get a real deployment UUID curl http://localhost:3000/api/deployments | jq '.[0].deployment_uuid' # Step 2: Test health endpoint curl http://localhost:3000/api/deployments//health | jq # Expected response: # { # "status": "healthy", # "failingStreak": 0, # "log": null, # "containerName": "nuc-portal-xxx", # "lastCheck": "2026-02-06T18:00:00.000Z" # } # Step 3: Compare to direct docker command ssh nuc "docker inspect --format='{{.State.Health.Status}}' " # If mismatch → check container name resolution → fix → retry ``` #### Pass Criteria - Endpoint returns valid JSON - Status matches `docker inspect` output - Handles missing containers gracefully --- ### Task 2C: Stats Endpoint **Type:** Backend | **Agent:** general-purpose #### Implement Phase ```typescript // Create: src/app/api/deployments/[uuid]/stats/route.ts import { NextResponse } from 'next/server'; import { getContainerStats, getContainerUptime, findContainerByAppName } from '@/lib/docker'; export async function GET( request: Request, { params }: { params: { uuid: string } } ) { try { const { uuid } = params; // Get deployment info to find container name const deploymentRes = await fetch( `${process.env.COOLIFY_API_URL}/deployments/${uuid}`, { headers: { 'Authorization': `Bearer ${process.env.COOLIFY_API_TOKEN}` } } ); if (!deploymentRes.ok) { return NextResponse.json( { error: 'Deployment not found' }, { status: 404 } ); } const deployment = await deploymentRes.json(); const containerName = await findContainerByAppName(deployment.application_name); if (!containerName) { return NextResponse.json({ error: 'Container not found', stats: null, uptime: null }); } const [stats, uptime] = await Promise.all([ getContainerStats(containerName), getContainerUptime(containerName) ]); return NextResponse.json({ containerName, stats, uptime, timestamp: new Date().toISOString() }); } catch (error) { return NextResponse.json( { error: 'Failed to get stats', details: String(error) }, { status: 500 } ); } } ``` #### Verify Loop ```bash # Test stats endpoint curl http://localhost:3000/api/deployments//stats | jq # Expected: # { # "containerName": "nuc-portal-xxx", # "stats": { # "cpuPercent": "0.50%", # "memoryUsage": "156MiB", # "memoryLimit": "1.94GiB", # ... # }, # "uptime": { # "startedAt": "2026-02-06T10:00:00Z", # "uptime": "8h 0m" # } # } # Compare to: ssh nuc "docker stats --no-stream " # If values way off → check parsing → fix → retry ``` #### Pass Criteria - Returns CPU, memory, uptime data - Values reasonable compared to `docker stats` - Handles stopped containers --- ### Task 2D: Navigation Wiring **Type:** Frontend | **Agent:** general-purpose #### Implement Phase ```typescript // Update DeploymentsTable.tsx - add router navigation // The row click handler should already be added in Wave 1C // This task ensures it navigates correctly now that the route exists // Verify the handleRowClick function: const handleRowClick = (uuid: string) => { router.push(`/deployments/${uuid}`); }; // Ensure TableRow has the onClick: handleRowClick(deployment.deployment_uuid)} > ``` #### Verify Loop ``` 1. npm run dev 2. Playwright → navigate to http://localhost:3000 3. Go to Deployments tab 4. Click on a deployment row (NOT the expand button) 5. Verify: URL changes to /deployments/[uuid] 6. Verify: Dashboard page loads with correct data 7. If no navigation → check onClick handler → fix → retry 8. If wrong uuid → check data binding → fix → retry ``` #### Pass Criteria - Row click navigates to `/deployments/[uuid]` - Correct deployment data loads - Expand button still works independently --- ## Wave 3: Integration (Parallel) **Dependency:** All Wave 2 tasks must pass verification ### Task 3A: Dashboard Data Integration **Type:** Frontend | **Agent:** general-purpose #### Research Phase ``` Playwright → Vercel dashboard: - How health status is displayed (icon, color, text) - How metrics update (polling interval, loading states) - Placement of real-time data ``` #### Implement Phase ```typescript // Update DeploymentDashboard.tsx to fetch health/stats 'use client'; import useSWR from 'swr'; // Add fetcher const fetcher = (url: string) => fetch(url).then(r => r.json()); export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { // Add SWR hooks for real-time data const { data: health } = useSWR( `/api/deployments/${deployment.deployment_uuid}/health`, fetcher, { refreshInterval: 10000 } // 10 second refresh ); const { data: stats } = useSWR( `/api/deployments/${deployment.deployment_uuid}/stats`, fetcher, { refreshInterval: 10000 } ); // Add to header metadata grid:

Health

{health?.status || 'checking...'}
// Add Container Info section after logs: Container Info

CPU

{stats?.stats?.cpuPercent || '—'}

Memory

{stats?.stats?.memoryUsage || '—'}

Uptime

{stats?.uptime?.uptime || '—'}

Container

{stats?.containerName || '—'}

} ``` #### Verify Loop ``` 1. npm run dev 2. Playwright → navigate to /deployments/[uuid] 3. Verify: Health status shows (healthy/unhealthy/unknown) 4. Verify: CPU/Memory values display 5. Wait 15 seconds → verify values update (or stay same if unchanged) 6. If "checking..." persists → check API calls in network tab → fix → retry 7. If no refresh → check SWR config → fix → retry ``` #### Pass Criteria - Health status displays with correct color - CPU/Memory values show and update - No console errors --- ### Task 3B: External Links Card **Type:** Frontend | **Agent:** general-purpose #### Research Phase ``` Playwright → Vercel bottom cards: - Card hover effects - Link behavior (new tab, same tab) - Icon styling ``` #### Implement Phase ```typescript // Update action cards in DeploymentDashboard.tsx // Calculate Dozzle URL const dozzleUrl = stats?.containerName ? `http://192.168.1.3:9999/container/${stats.containerName}` : null; // Update Runtime Logs card: dozzleUrl && window.open(dozzleUrl, '_blank')} >

Runtime Logs

View live container logs in Dozzle

// Add Coolify link card: window.open(deployment.deployment_url, '_blank')} >

Coolify

Open in Coolify dashboard

// Add Visit link if fqdn exists: {deployment.fqdn && ( window.open(deployment.fqdn, '_blank')} >

Visit Site

{deployment.fqdn}

)} ``` #### Verify Loop ``` 1. Playwright → dashboard page 2. Click "Runtime Logs" card → verify Dozzle opens with correct container 3. Click "Coolify" card → verify Coolify deployment page opens 4. Click "Visit Site" → verify app opens 5. If wrong URL → check URL construction → fix → retry 6. If not opening → check onClick handler → fix → retry ``` #### Pass Criteria - All 3 link cards open correct URLs in new tabs - Hover effects work - Disabled state if URL not available --- ### Task 3C: Action Buttons (Redeploy/Rollback) **Type:** Backend + Frontend | **Agent:** general-purpose #### Implement Phase ```typescript // Create: src/app/api/deployments/[uuid]/redeploy/route.ts import { NextResponse } from 'next/server'; export async function POST( request: Request, { params }: { params: { uuid: string } } ) { try { const { uuid } = params; // Get deployment to find application UUID const deploymentRes = await fetch( `${process.env.COOLIFY_API_URL}/deployments/${uuid}`, { headers: { 'Authorization': `Bearer ${process.env.COOLIFY_API_TOKEN}` } } ); const deployment = await deploymentRes.json(); // Trigger deploy via Coolify API const deployRes = await fetch( `${process.env.COOLIFY_API_URL}/applications/${deployment.application_uuid}/deploy`, { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.COOLIFY_API_TOKEN}`, 'Content-Type': 'application/json' } } ); if (!deployRes.ok) { return NextResponse.json( { error: 'Failed to trigger deployment' }, { status: 500 } ); } const result = await deployRes.json(); return NextResponse.json({ success: true, deployment: result }); } catch (error) { return NextResponse.json( { error: 'Failed to redeploy', details: String(error) }, { status: 500 } ); } } ``` ```typescript // Update DeploymentDashboard.tsx - add action handlers const [isRedeploying, setIsRedeploying] = useState(false); 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) { // Could show toast notification alert('Deployment triggered!'); } else { alert('Failed to trigger deployment'); } } finally { setIsRedeploying(false); } }; // Update Redeploy card:

Redeploy

{isRedeploying ? 'Triggering...' : 'Trigger new deployment'}

``` #### Verify Loop ```bash # Step 1: Test redeploy endpoint curl -X POST http://localhost:3000/api/deployments//redeploy | jq # Step 2: Verify deployment triggered on NUC ssh nuc "docker exec coolify php artisan tinker --execute=\" use App\\Models\\ApplicationDeploymentQueue; echo ApplicationDeploymentQueue::latest()->first()->status; \"" # Step 3: Playwright → click Redeploy button # Verify: confirmation dialog, loading state, success message # If API fails → check Coolify auth → fix → retry # If no loading state → check useState → fix → retry ``` #### Pass Criteria - POST to redeploy endpoint triggers actual deployment - UI shows loading state while deploying - Confirmation before action --- ## Wave 4: Polish (Parallel) **Dependency:** All Wave 3 tasks must pass verification ### Task 4A: Error & Loading States **Type:** Frontend | **Agent:** general-purpose #### Research Phase ``` Playwright → Vercel: - Loading skeleton patterns - Error message styling - Empty states ``` #### Implement Phase ```typescript // Add loading skeletons and error boundaries // Create: src/components/DeploymentSkeleton.tsx export function DeploymentSkeleton() { return (
{[...Array(6)].map((_, i) => (
))}
); } // Update page.tsx to handle loading/error: import { Suspense } from 'react'; import { DeploymentSkeleton } from '@/components/DeploymentSkeleton'; export default function DeploymentPage({ params }: Props) { return ( }> ); } async function DeploymentContent({ uuid }: { uuid: string }) { const res = await fetch(`...`); if (!res.ok) { return (

Failed to load deployment

UUID: {uuid}

); } const deployment = await res.json(); return ; } ``` #### Verify Loop ``` 1. Playwright → navigate to /deployments/invalid-uuid 2. Verify: error state renders (not crash) 3. Slow network simulation → verify skeleton shows 4. If crash → add error boundary → retry 5. If no skeleton → check Suspense wrapper → retry ``` #### Pass Criteria - Loading skeleton displays during fetch - Error state for invalid/missing deployments - No unhandled errors in console --- ### Task 4B: Edge Cases **Type:** Full Stack | **Agent:** general-purpose #### Test Cases to Handle ``` 1. Deployment in_progress (no container yet) - Health: "pending" - Stats: null with message - No Dozzle link 2. Deployment failed (error status) - Show error badge prominently - Logs section auto-expanded - Highlight errors in red 3. Container stopped - Health: "exited" - Stats: null - Offer "Start" action 4. Very old deployment - Handle null finished_at gracefully - Show "unknown duration" 5. Missing git info - Show "—" for branch/commit - Don't crash on undefined ``` #### Implement Defensive Code ```typescript // Add null checks throughout DeploymentDashboard const duration = deployment.finished_at && deployment.created_at ? Math.round((new Date(deployment.finished_at).getTime() - new Date(deployment.created_at).getTime()) / 1000) : null; const displayDuration = duration !== null ? `${Math.floor(duration / 60)}m ${duration % 60}s` : 'In progress...'; // For git info: {deployment.git_branch || '—'} {deployment.commit?.slice(0, 7) || '—'} {deployment.commit_message || 'No commit message'} // For health when no container: {health?.status === 'unknown' && deployment.status === 'in_progress' && ( Building... )} ``` #### Verify Loop ``` 1. Find deployment with status "in_progress" → verify no crash 2. Find deployment with status "error" → verify error UI 3. Stop a container manually → verify dashboard handles it 4. Create deployment with missing git info → verify no crash 5. For each: if crash → add null check → retry ``` #### Pass Criteria - All edge cases handled without crashes - Appropriate UI for each state - Defensive coding throughout --- ## Verification Methods Reference | Check Type | Command/Method | Pass Criteria | |------------|---------------|---------------| | Build | `npm run build` | Exit 0, no errors | | Route exists | Playwright navigate | No 404, page renders | | API response | `curl ... \| jq` | Valid JSON, correct schema | | Docker match | Compare to `docker inspect/stats` | Values match | | UI render | Playwright snapshot | Elements visible | | Interaction | Playwright click/type | Expected state change | | Navigation | Playwright + URL check | Correct route | | Real-time | Wait + re-check | Values update | --- ## Escalation Template When verification fails 3 times, report: ```markdown ## ESCALATION: Task {ID} - {Name} ### What was attempted - Attempt 1: {action} → {error} - Attempt 2: {action} → {error} - Attempt 3: {action} → {error} ### Root cause analysis {Why the fixes didn't work} ### Partial progress - {What does work} - {Files created/modified} ### Suggested resolution {What might fix it, or alternative approaches} ### Files to review - {path/to/file.ts} ``` --- ## Launch Command To start implementation, launch Wave 1 with 3 parallel agents: ``` Wave 1A: Route & Page Structure (Frontend) Wave 1B: Docker API Helpers (Backend) Wave 1C: Table UI - Expand Button (Frontend) ``` Each agent receives this full plan + their specific task section.