- 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>
1451 lines
41 KiB
Markdown
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.
|