# 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 (
);
}
```
```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 })}
{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.