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

41 KiB

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

# 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

// 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>
  );
}
// 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

# 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

// 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

# 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

// 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

// 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

// 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

# 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

// 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

# 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

// 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

// 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

Type: Frontend | Agent: general-purpose

Research Phase

Playwright → Vercel bottom cards:
- Card hover effects
- Link behavior (new tab, same tab)
- Icon styling

Implement Phase

// 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

// 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 }
    );
  }
}
// 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

# 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

// 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

// 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:

## 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.