Files
nuc/.artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md
Alejandro Gutiérrez 36698dbc79 Add deployment dashboard docs and artifacts
- Add design doc for Vercel-style deployment dashboard
- Add wave-based implementation plan (4 waves, 11 agents)
- Add implementation summary artifact
- Update CLAUDE.md with CloudBeaver credentials

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:00:27 +01:00

1451 lines
41 KiB
Markdown

# 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 (
<div className="container mx-auto p-6">
{/* Breadcrumb */}
<nav className="mb-4">
<a href="/" className="text-muted-foreground hover:text-foreground">
Back to Deployments
</a>
</nav>
{/* Dashboard Component (placeholder for Wave 2A) */}
<DeploymentDashboard deployment={deployment} />
</div>
);
}
```
```typescript
// Create placeholder: src/components/DeploymentDashboard.tsx
interface DeploymentDashboardProps {
deployment: any;
}
export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{deployment.deployment_uuid}</h1>
<p>Dashboard coming in Wave 2...</p>
</div>
);
}
```
#### 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<string> {
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<ContainerHealth> {
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<ContainerStats | null> {
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<ContainerUptime | null> {
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<string | null> {
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}}' <container>"
# Step 3: Test getContainerStats
# Compare output to: ssh nuc "docker stats --no-stream <container>"
# Step 4: Test getContainerUptime
# Compare output to: ssh nuc "docker inspect --format='{{.State.StartedAt}}' <container>"
# 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<Set<string>>(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:
<TableRow
key={deployment.deployment_uuid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(deployment.deployment_uuid)}
>
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={(e) => toggleExpand(deployment.deployment_uuid, e)}
>
{expandedRows.has(deployment.deployment_uuid)
? <ChevronDown className="h-4 w-4" />
: <ChevronRight className="h-4 w-4" />
}
</Button>
</TableCell>
{/* ... rest of cells ... */}
</TableRow>
// After row, conditionally render expanded content:
{expandedRows.has(deployment.deployment_uuid) && (
<TableRow>
<TableCell colSpan={columns.length}>
<DeploymentLogs logs={deployment.logs} />
</TableCell>
</TableRow>
)}
```
#### 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 (
<div className="space-y-6">
{/* Header Card */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-6">
{/* Left: App Icon/Preview */}
<div className="w-24 h-24 bg-muted rounded-lg flex items-center justify-center">
<Server className="h-12 w-12 text-muted-foreground" />
</div>
{/* Right: Metadata Grid */}
<div className="flex-1 grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-muted-foreground">Status</p>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${statusColor}`} />
<span className="font-medium capitalize">{deployment.status.replace('_', ' ')}</span>
</div>
</div>
<div>
<p className="text-sm text-muted-foreground">Duration</p>
<p className="font-medium">
{duration ? `${Math.floor(duration / 60)}m ${duration % 60}s` : '—'}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{formatDistanceToNow(new Date(deployment.created_at), { addSuffix: true })}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Environment</p>
<Badge variant="outline">production</Badge>
</div>
{deployment.fqdn && (
<div className="col-span-2">
<p className="text-sm text-muted-foreground">Domains</p>
<a
href={deployment.fqdn}
target="_blank"
className="text-blue-500 hover:underline flex items-center gap-1"
>
{deployment.fqdn}
<ExternalLink className="h-3 w-3" />
</a>
</div>
)}
<div className="col-span-2">
<p className="text-sm text-muted-foreground">Source</p>
<div className="flex items-center gap-2 font-mono text-sm">
<GitBranch className="h-4 w-4" />
<span>{deployment.git_branch || 'main'}</span>
<span className="text-muted-foreground">·</span>
<span>{deployment.commit?.slice(0, 7)}</span>
<span className="text-muted-foreground">·</span>
<span className="truncate max-w-md">{deployment.commit_message}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Build Logs Section */}
<Card>
<CardHeader
className="cursor-pointer flex flex-row items-center justify-between"
onClick={() => setLogsExpanded(!logsExpanded)}
>
<div className="flex items-center gap-2">
{logsExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
<CardTitle className="text-lg">Build Logs</CardTitle>
<Badge variant="secondary">{deployment.logs?.length || 0} lines</Badge>
</div>
</CardHeader>
{logsExpanded && (
<CardContent>
<div className="bg-black rounded-lg p-4 font-mono text-sm text-green-400 max-h-96 overflow-auto">
{deployment.logs?.map((log, i) => (
<div key={i} className="flex gap-4">
<span className="text-gray-500 select-none">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className={log.message.toLowerCase().includes('warn') ? 'text-yellow-400' : ''}>
{log.message}
</span>
</div>
)) || <p className="text-gray-500">No logs available</p>}
</div>
</CardContent>
)}
</Card>
{/* Action Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Activity className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Runtime Logs</p>
<p className="text-sm text-muted-foreground">View live container logs</p>
</div>
</div>
</CardContent>
</Card>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<RefreshCw className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Redeploy</p>
<p className="text-sm text-muted-foreground">Trigger new deployment</p>
</div>
</div>
</CardContent>
</Card>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Rollback</p>
<p className="text-sm text-muted-foreground">Revert to previous</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
```
#### 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/<uuid>/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}}' <container>"
# 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/<uuid>/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 <container>"
# 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:
<TableRow
key={deployment.deployment_uuid}
className="cursor-pointer hover:bg-muted/50"
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:
<div>
<p className="text-sm text-muted-foreground">Health</p>
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${
health?.status === 'healthy' ? 'bg-green-500' :
health?.status === 'unhealthy' ? 'bg-red-500' :
'bg-gray-500'
}`} />
<span className="font-medium capitalize">{health?.status || 'checking...'}</span>
</div>
</div>
// Add Container Info section after logs:
<Card>
<CardHeader>
<CardTitle className="text-lg">Container Info</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm text-muted-foreground">CPU</p>
<p className="font-medium">{stats?.stats?.cpuPercent || '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Memory</p>
<p className="font-medium">{stats?.stats?.memoryUsage || '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Uptime</p>
<p className="font-medium">{stats?.uptime?.uptime || '—'}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Container</p>
<p className="font-mono text-sm truncate">{stats?.containerName || '—'}</p>
</div>
</div>
</CardContent>
</Card>
}
```
#### 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:
<Card
className="hover:border-primary transition-colors cursor-pointer"
onClick={() => dozzleUrl && window.open(dozzleUrl, '_blank')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Activity className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Runtime Logs</p>
<p className="text-sm text-muted-foreground">View live container logs in Dozzle</p>
</div>
<ExternalLink className="h-4 w-4 ml-auto text-muted-foreground" />
</div>
</CardContent>
</Card>
// Add Coolify link card:
<Card
className="hover:border-primary transition-colors cursor-pointer"
onClick={() => window.open(deployment.deployment_url, '_blank')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Server className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Coolify</p>
<p className="text-sm text-muted-foreground">Open in Coolify dashboard</p>
</div>
<ExternalLink className="h-4 w-4 ml-auto text-muted-foreground" />
</div>
</CardContent>
</Card>
// Add Visit link if fqdn exists:
{deployment.fqdn && (
<Card
className="hover:border-primary transition-colors cursor-pointer"
onClick={() => window.open(deployment.fqdn, '_blank')}
>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<Globe className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Visit Site</p>
<p className="text-sm text-muted-foreground">{deployment.fqdn}</p>
</div>
<ExternalLink className="h-4 w-4 ml-auto text-muted-foreground" />
</div>
</CardContent>
</Card>
)}
```
#### 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:
<Card
className={`hover:border-primary transition-colors cursor-pointer ${isRedeploying ? 'opacity-50' : ''}`}
onClick={handleRedeploy}
>
<CardContent className="pt-6">
<div className="flex items-center gap-3">
<RefreshCw className={`h-8 w-8 text-muted-foreground ${isRedeploying ? 'animate-spin' : ''}`} />
<div>
<p className="font-medium">Redeploy</p>
<p className="text-sm text-muted-foreground">
{isRedeploying ? 'Triggering...' : 'Trigger new deployment'}
</p>
</div>
</div>
</CardContent>
</Card>
```
#### Verify Loop
```bash
# Step 1: Test redeploy endpoint
curl -X POST http://localhost:3000/api/deployments/<uuid>/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 (
<div className="space-y-6 animate-pulse">
<Card>
<CardContent className="pt-6">
<div className="flex gap-6">
<div className="w-24 h-24 bg-muted rounded-lg" />
<div className="flex-1 grid grid-cols-2 gap-4">
{[...Array(6)].map((_, i) => (
<div key={i}>
<div className="h-4 w-20 bg-muted rounded mb-2" />
<div className="h-6 w-32 bg-muted rounded" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="h-6 w-32 bg-muted rounded" />
</CardHeader>
<CardContent>
<div className="h-64 bg-muted rounded" />
</CardContent>
</Card>
</div>
);
}
// Update page.tsx to handle loading/error:
import { Suspense } from 'react';
import { DeploymentSkeleton } from '@/components/DeploymentSkeleton';
export default function DeploymentPage({ params }: Props) {
return (
<Suspense fallback={<DeploymentSkeleton />}>
<DeploymentContent uuid={params.uuid} />
</Suspense>
);
}
async function DeploymentContent({ uuid }: { uuid: string }) {
const res = await fetch(`...`);
if (!res.ok) {
return (
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6 text-center">
<p className="text-red-600">Failed to load deployment</p>
<p className="text-sm text-red-400">UUID: {uuid}</p>
</CardContent>
</Card>
);
}
const deployment = await res.json();
return <DeploymentDashboard deployment={deployment} />;
}
```
#### 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:
<span>{deployment.git_branch || '—'}</span>
<span>{deployment.commit?.slice(0, 7) || '—'}</span>
<span>{deployment.commit_message || 'No commit message'}</span>
// For health when no container:
{health?.status === 'unknown' && deployment.status === 'in_progress' && (
<span className="text-yellow-500">Building...</span>
)}
```
#### 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.