From 36698dbc79983a5063105fcf0db210cbebe4b5cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:00:27 +0100 Subject: [PATCH] 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 --- ...02-06_17-30_deployment-dashboard-design.md | 245 +++ ...eployment-dashboard-implementation-plan.md | 1450 +++++++++++++++++ ...-30_deployment-dashboard-implementation.md | 173 ++ CLAUDE.md | 340 +++- 4 files changed, 2179 insertions(+), 29 deletions(-) create mode 100644 .artifacts/2026-02-06_17-30_deployment-dashboard-design.md create mode 100644 .artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md create mode 100644 .artifacts/2026-02-06_19-30_deployment-dashboard-implementation.md diff --git a/.artifacts/2026-02-06_17-30_deployment-dashboard-design.md b/.artifacts/2026-02-06_17-30_deployment-dashboard-design.md new file mode 100644 index 0000000..cda462e --- /dev/null +++ b/.artifacts/2026-02-06_17-30_deployment-dashboard-design.md @@ -0,0 +1,245 @@ +# NUC Portal - Deployment Dashboard Design + +**Date:** 2026-02-06 17:30 +**Context:** Reverse-engineered Vercel deployment dashboard to design similar feature for nuc-portal + +## Task Summary + +**Goal:** Add deployment detail page to nuc-portal +- Current: Click row in Deployments tab → expand/collapse logs inline +- New: Click row → navigate to `/deployments/[uuid]` dashboard page +- Keep: Add explicit expand button for inline log preview + +## Vercel Dashboard Structure (Reference) + +### Page Layout +``` +Breadcrumb: Deployments > [deployment-id] +Tabs: Deployment | Logs | Resources | Source | Open Graph +Actions: Share | Logs | Visit +``` + +### Deployment Tab Sections + +**1. Deployment Details Card (Top)** +| Left | Right | +|------|-------| +| Preview screenshot | Created: user + date | +| | Status: Ready + "Latest" badge | +| | Duration: 43s + "43d ago" | +| | Environment: Production + "Current" | +| | Domains: list with "+N" overflow | +| | Source: branch + commit hash + message | + +**2. Collapsible Sections:** + +| Section | Content | +|---------|---------| +| **Deployment Settings** | Recommendations cards, Build Settings (concurrent builds, machine specs, prioritize prod), Runtime Settings (fluid compute, function CPU, Node version, protections) | +| **Build Logs** | Header: line count, warnings, search. Body: timestamped log lines, warnings highlighted yellow | +| **Deployment Summary** | Framework badge, Edge Middleware count, Static Assets (filterable by type), Functions, ISR Functions, Cron Jobs | +| **Deployment Checks** | External check integrations or "No checks configured" | +| **Assigning Custom Domains** | Domain list with status checkmarks and manage links | + +**3. Bottom Cards (4-column grid):** +- Runtime Logs | Observability | Speed Insights | BotID + +--- + +## Coolify Data Available + +### From Deployment Table +```typescript +interface CoolifyDeployment { + deployment_uuid: string; + application_id: string; + application_name: string; + server_name: string; + status: 'finished' | 'error' | 'in_progress' | 'queued' | 'cancelled'; + commit: string; // SHA + commit_message: string; // Full message + is_webhook: boolean; + is_api: boolean; + force_rebuild: boolean; + rollback: boolean; + created_at: string; + updated_at: string; + finished_at: string | null; + logs: DeploymentLog[]; // JSON array with timestamps + deployment_url: string; // Coolify UI link +} +``` + +### From Application Table +```typescript +interface CoolifyApp { + uuid: string; + name: string; + fqdn: string; // e.g., "http://nuc.lan" + status: string; // e.g., "running:unknown" + git_repository: string; + git_branch: string; + build_pack: string; // nixpacks, dockerfile, etc. + ports_exposes: string; + created_at: string; + updated_at: string; +} +``` + +### Direct Database Query (via tinker) +```bash +ssh nuc "docker exec coolify php artisan tinker --execute=\" +use App\\\\Models\\\\Application; +use App\\\\Models\\\\ApplicationDeploymentQueue; +\\\$app = Application::where('uuid', '')->first(); +\\\$d = ApplicationDeploymentQueue::where('application_id', \\\$app->id)->latest()->first(); +echo json_encode(\\\$d->toArray(), JSON_PRETTY_PRINT); +\"" +``` + +--- + +## Gap Analysis + +### What Coolify Provides (Direct Mapping) +- Deployment UUID, status, timestamps, duration (computed) +- Git branch, commit SHA, commit message +- Build logs with timestamps +- App name, FQDN/domains, build pack, ports +- Webhook/API trigger info + +### Gaps - Ranked by Priority + +**Tier 1 - High Impact, Easy** +1. **Healthcheck Status** - Docker API: `docker inspect --format='{{.State.Health.Status}}'` +2. **Container Metrics** - Docker stats: CPU%, Memory usage +3. **Environment Label** - Parse from Coolify project/environment structure + +**Tier 2 - High Impact, Medium Effort** +4. **Preview Screenshot** - Playwright screenshot service triggered on deploy success +5. **Runtime Logs Link** - Deep link to Dozzle: `http://dozzle.nuc.lan/container/` +6. **Rollback Button** - Coolify API supports rollback + +**Tier 3 - Nice to Have** +7. **Build Cache Status** - Parse "Restored build cache" from logs +8. **Image Size** - `docker images --format` after build +9. **Uptime Since Deploy** - Container start time from Docker + +**Tier 4 - Future** +10. **Response Time** - Uptime Kuma integration +11. **Error Rate** - Log parsing or APM +12. **Git Diff Link** - Construct Gitea compare URL + +--- + +## Proposed Implementation + +### Route Structure +``` +/deployments/[uuid] → Deployment dashboard page +``` + +### UI Changes to Deployments Table +- Add expand/collapse button (chevron icon) on each row +- Row click → navigate to dashboard +- Button click → expand logs inline (current behavior) + +### Dashboard Page Sections (Simplified for Coolify) + +``` +┌─────────────────────────────────────────────────────────┐ +│ ← Back to Deployments rok0w0gg [Actions] │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────┐ Created: alezmad · Feb 6, 2026 │ +│ │ Preview │ Status: ● Ready Health: ● Healthy │ +│ │ or Icon │ Duration: 2m 51s · 2h ago │ +│ └──────────┘ Environment: production │ +│ │ +│ Domains: http://nuc.lan │ +│ Source: main · f7c57ca · "Use domain-based URLs..." │ +├─────────────────────────────────────────────────────────┤ +│ ▼ Build Logs 43s ⚠2 ✓ │ +│ [Timestamped log content...] │ +├─────────────────────────────────────────────────────────┤ +│ ▶ Container Info │ +│ Build: nixpacks · Ports: 3000 │ +│ CPU: 2.3% · Memory: 156MB │ +├─────────────────────────────────────────────────────────┤ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Runtime │ │ Redeploy │ │ Rollback │ │ +│ │ Logs → │ │ │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### API Endpoints Needed + +```typescript +// Existing (nuc-portal) +GET /api/deployments // List all +GET /api/deployments/[uuid] // Get one with logs + +// New endpoints +GET /api/deployments/[uuid]/health // Container healthcheck +GET /api/deployments/[uuid]/stats // CPU/Memory from docker stats +POST /api/deployments/[uuid]/rollback // Trigger rollback +``` + +### Docker Commands for New Features + +```bash +# Healthcheck status +docker inspect --format='{{.State.Health.Status}}' + +# Container stats (one-shot) +docker stats --no-stream --format='{{.CPUPerc}},{{.MemUsage}}' + +# Container start time (for uptime) +docker inspect --format='{{.State.StartedAt}}' +``` + +--- + +## Files to Modify + +``` +nuc-portal/ +├── src/app/deployments/[uuid]/page.tsx # NEW - Dashboard page +├── src/components/DeploymentsTable.tsx # Add expand button, row click nav +├── src/components/DeploymentDashboard.tsx # NEW - Dashboard component +├── src/app/api/deployments/[uuid]/ +│ ├── route.ts # Existing - add more fields +│ ├── health/route.ts # NEW - healthcheck endpoint +│ └── stats/route.ts # NEW - container stats +└── src/lib/docker.ts # NEW - Docker API helpers +``` + +--- + +## Implementation Order + +### Phase 1: Basic Dashboard (MVP) +1. Create `/deployments/[uuid]` route and page +2. Add expand button to table rows +3. Make row click navigate to dashboard +4. Display existing deployment data in dashboard layout + +### Phase 2: Enhanced Data (Tier 1 gaps) +5. Add healthcheck status endpoint +6. Add container metrics endpoint +7. Add environment label parsing + +### Phase 3: Actions & Links (Tier 2 gaps) +8. Add Dozzle deep link +9. Add rollback button +10. (Optional) Screenshot service + +--- + +## Related Documentation + +- Coolify API: `http://192.168.1.3:8000/api/v1/` +- Existing nuc-portal code: `/Users/agutierrez/Desktop/nuc/nuc-portal/` +- Deployment types: `nuc-portal/src/lib/deployments.ts` +- Coolify helpers: `nuc-portal/src/lib/coolify.ts` diff --git a/.artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md b/.artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md new file mode 100644 index 0000000..8a1ca5e --- /dev/null +++ b/.artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md @@ -0,0 +1,1450 @@ +# Deployment Dashboard Implementation Plan + +**Date:** 2026-02-06 18:00 +**Project:** nuc-portal +**Goal:** Add Vercel-style deployment detail pages with parallel wave-based implementation + +--- + +## Quick Start + +```bash +# Launch Wave 1 (3 parallel agents) +# Each agent follows: Research → Implement → Verify Loop pattern +``` + +--- + +## Reference Resources + +| Resource | Location | +|----------|----------| +| **Vercel Reference** | https://vercel.com/intentisenough/intentisenough/FtWsb3pocwJDAsvqbrWUnkb8Biz8 | +| **nuc-portal codebase** | `/Users/agutierrez/Desktop/nuc/nuc-portal/` | +| **Design doc** | `.artifacts/2026-02-06_17-30_deployment-dashboard-design.md` | +| **Playwright MCP** | `mcp__playwriter-local__*` (local) or `mcp__playwriter-nuc-01__*` (remote) | + +--- + +## Architecture Overview + +``` +Current Flow: + Deployments Tab → Click Row → Expand/Collapse Logs Inline + +New Flow: + Deployments Tab → Click Row → Navigate to /deployments/[uuid] + → Click Expand Button → Expand Logs Inline (preserved) +``` + +### New Files Structure +``` +nuc-portal/src/ +├── app/deployments/[uuid]/ +│ └── page.tsx # Dashboard page (Wave 1A) +├── components/ +│ └── DeploymentDashboard.tsx # Dashboard component (Wave 2A) +├── lib/ +│ └── docker.ts # Docker API helpers (Wave 1B) +└── app/api/deployments/[uuid]/ + ├── health/route.ts # Healthcheck endpoint (Wave 2B) + └── stats/route.ts # Container stats endpoint (Wave 2C) +``` + +--- + +## Agent Execution Pattern + +All agents follow this pattern: + +``` +┌──────────┐ ┌───────────┐ ┌──────────┐ +│ RESEARCH │ ──→ │ IMPLEMENT │ ──→ │ VERIFY │ +└──────────┘ └───────────┘ └────┬─────┘ + │ │ + │ (Frontend only) ┌─────┴─────┐ + ▼ YES NO +┌──────────┐ │ │ +│Playwright│ ▼ ▼ +│ Snapshot │ [DONE] ┌────────┐ +└──────────┘ │ANALYZE │ + │ & FIX │ + └───┬────┘ + │ + ┌──────┴──────┐ + │ retry < 3? │ + └──────┬──────┘ + YES │ NO + │ │ │ + ▼ │ ▼ + [VERIFY] │ [ESCALATE] + ◄──────┘ +``` + +### Verification Loop Rules +1. **Max 3 retries** per verification step +2. On failure: analyze error → apply fix → re-verify +3. After 3 failures: **ESCALATE** to parent with error details +4. Never skip verification - all tasks must pass before wave completes + +--- + +## Wave 1: Foundation (Parallel) + +### Task 1A: Route & Page Structure +**Type:** Frontend | **Agent:** general-purpose + +#### Research Phase +``` +Use Playwright MCP to study Vercel deployment page: +1. mcp__playwriter-local__execute → navigate to Vercel URL +2. Take accessibility snapshot +3. Document: + - Breadcrumb structure + - Tab layout + - Header section layout + - Action button positions +``` + +#### Implement Phase +```typescript +// Create: src/app/deployments/[uuid]/page.tsx + +import { DeploymentDashboard } from '@/components/DeploymentDashboard'; + +interface Props { + params: { uuid: string }; +} + +export default async function DeploymentPage({ params }: Props) { + const { uuid } = params; + + // Fetch deployment data + const res = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/deployments/${uuid}`, { + cache: 'no-store' + }); + const deployment = await res.json(); + + return ( +
+ {/* Breadcrumb */} + + + {/* Dashboard Component (placeholder for Wave 2A) */} + +
+ ); +} +``` + +```typescript +// Create placeholder: src/components/DeploymentDashboard.tsx + +interface DeploymentDashboardProps { + deployment: any; +} + +export function DeploymentDashboard({ deployment }: DeploymentDashboardProps) { + return ( +
+

{deployment.deployment_uuid}

+

Dashboard coming in Wave 2...

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

Status

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

Duration

+

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

+
+ +
+

Created

+

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

+
+ +
+

Environment

+ production +
+ + {deployment.fqdn && ( + + )} + +
+

Source

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

No logs available

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

Runtime Logs

+

View live container logs

+
+
+
+
+ + + +
+ +
+

Redeploy

+

Trigger new deployment

+
+
+
+
+ + + +
+ +
+

Rollback

+

Revert to previous

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

Health

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

CPU

+

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

+
+
+

Memory

+

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

+
+
+

Uptime

+

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

+
+
+

Container

+

{stats?.containerName || '—'}

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

Runtime Logs

+

View live container logs in Dozzle

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

Coolify

+

Open in Coolify dashboard

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

Visit Site

+

{deployment.fqdn}

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

Redeploy

+

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

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

Failed to load deployment

+

UUID: {uuid}

+
+
+ ); + } + + const deployment = await res.json(); + return ; +} +``` + +#### Verify Loop +``` +1. Playwright → navigate to /deployments/invalid-uuid +2. Verify: error state renders (not crash) +3. Slow network simulation → verify skeleton shows +4. If crash → add error boundary → retry +5. If no skeleton → check Suspense wrapper → retry +``` + +#### Pass Criteria +- Loading skeleton displays during fetch +- Error state for invalid/missing deployments +- No unhandled errors in console + +--- + +### Task 4B: Edge Cases +**Type:** Full Stack | **Agent:** general-purpose + +#### Test Cases to Handle +``` +1. Deployment in_progress (no container yet) + - Health: "pending" + - Stats: null with message + - No Dozzle link + +2. Deployment failed (error status) + - Show error badge prominently + - Logs section auto-expanded + - Highlight errors in red + +3. Container stopped + - Health: "exited" + - Stats: null + - Offer "Start" action + +4. Very old deployment + - Handle null finished_at gracefully + - Show "unknown duration" + +5. Missing git info + - Show "—" for branch/commit + - Don't crash on undefined +``` + +#### Implement Defensive Code +```typescript +// Add null checks throughout DeploymentDashboard + +const duration = deployment.finished_at && deployment.created_at + ? Math.round((new Date(deployment.finished_at).getTime() - new Date(deployment.created_at).getTime()) / 1000) + : null; + +const displayDuration = duration !== null + ? `${Math.floor(duration / 60)}m ${duration % 60}s` + : 'In progress...'; + +// For git info: +{deployment.git_branch || '—'} +{deployment.commit?.slice(0, 7) || '—'} +{deployment.commit_message || 'No commit message'} + +// For health when no container: +{health?.status === 'unknown' && deployment.status === 'in_progress' && ( + Building... +)} +``` + +#### Verify Loop +``` +1. Find deployment with status "in_progress" → verify no crash +2. Find deployment with status "error" → verify error UI +3. Stop a container manually → verify dashboard handles it +4. Create deployment with missing git info → verify no crash +5. For each: if crash → add null check → retry +``` + +#### Pass Criteria +- All edge cases handled without crashes +- Appropriate UI for each state +- Defensive coding throughout + +--- + +## Verification Methods Reference + +| Check Type | Command/Method | Pass Criteria | +|------------|---------------|---------------| +| Build | `npm run build` | Exit 0, no errors | +| Route exists | Playwright navigate | No 404, page renders | +| API response | `curl ... \| jq` | Valid JSON, correct schema | +| Docker match | Compare to `docker inspect/stats` | Values match | +| UI render | Playwright snapshot | Elements visible | +| Interaction | Playwright click/type | Expected state change | +| Navigation | Playwright + URL check | Correct route | +| Real-time | Wait + re-check | Values update | + +--- + +## Escalation Template + +When verification fails 3 times, report: + +```markdown +## ESCALATION: Task {ID} - {Name} + +### What was attempted +- Attempt 1: {action} → {error} +- Attempt 2: {action} → {error} +- Attempt 3: {action} → {error} + +### Root cause analysis +{Why the fixes didn't work} + +### Partial progress +- {What does work} +- {Files created/modified} + +### Suggested resolution +{What might fix it, or alternative approaches} + +### Files to review +- {path/to/file.ts} +``` + +--- + +## Launch Command + +To start implementation, launch Wave 1 with 3 parallel agents: + +``` +Wave 1A: Route & Page Structure (Frontend) +Wave 1B: Docker API Helpers (Backend) +Wave 1C: Table UI - Expand Button (Frontend) +``` + +Each agent receives this full plan + their specific task section. diff --git a/.artifacts/2026-02-06_19-30_deployment-dashboard-implementation.md b/.artifacts/2026-02-06_19-30_deployment-dashboard-implementation.md new file mode 100644 index 0000000..8348018 --- /dev/null +++ b/.artifacts/2026-02-06_19-30_deployment-dashboard-implementation.md @@ -0,0 +1,173 @@ +# Deployment Dashboard Implementation + +**Date:** 2026-02-06 19:30 +**Project:** nuc-portal +**Context:** Implemented Vercel-style deployment detail pages with parallel wave-based execution + +--- + +## Overview + +Added a comprehensive deployment dashboard to nuc-portal that displays detailed information about individual deployments, including real-time container health, stats, build logs, and quick action buttons. + +## Architecture + +``` +User Flow: + Deployments Tab → Click Row → /deployments/[uuid] (Dashboard) + → Click Chevron → Expand Logs Inline (preserved) + +API Flow: + Dashboard Page + ├── /api/deployments/[uuid] → Deployment details + ├── /api/deployments/[uuid]/health → Container health (SWR 10s) + ├── /api/deployments/[uuid]/stats → Container stats (SWR 10s) + └── /api/deployments/[uuid]/redeploy → Trigger new deployment +``` + +## Files Created + +| File | Purpose | Lines | +|------|---------|-------| +| `src/app/deployments/[uuid]/page.tsx` | Dynamic route for deployment details | ~150 | +| `src/components/DeploymentDashboard.tsx` | Main dashboard component with tabs | ~800 | +| `src/components/DeploymentSkeleton.tsx` | Loading, error, empty state components | ~200 | +| `src/lib/docker.ts` | Docker API helpers via SSH | ~150 | +| `src/app/api/deployments/[uuid]/health/route.ts` | Container health endpoint | ~50 | +| `src/app/api/deployments/[uuid]/stats/route.ts` | Container stats endpoint | ~60 | +| `src/app/api/deployments/[uuid]/redeploy/route.ts` | Redeploy trigger endpoint | ~50 | + +## Files Modified + +| File | Changes | +|------|---------| +| `src/components/DeploymentsTable.tsx` | Added expand button, row click navigation | +| `src/components/Icons.tsx` | Added missing icons (user, clock, share, etc.) | +| `src/components/index.ts` | Exported new components | + +## Features + +### Dashboard Tabs +1. **Deployment** - Main view with metadata, preview, and collapsible sections +2. **Logs** - Build logs with warning highlighting +3. **Resources** - CPU, Memory, Network I/O, Block I/O +4. **Source** - Git branch, commit, message + +### Real-Time Data (SWR) +- Health status polling every 10 seconds +- Container stats polling every 10 seconds +- Auto-refresh with loading states + +### Action Cards +| Card | Action | URL | +|------|--------|-----| +| Runtime Logs | Opens Dozzle | `http://192.168.1.3:9999/container/{name}` | +| Coolify | Opens Coolify deployment | `http://coolify.nuc.lan:8000/...` | +| Visit Site | Opens app FQDN | `{deployment.fqdn}` | +| Redeploy | Triggers new deployment | POST `/api/.../redeploy` | + +### Edge Cases Handled +- `in_progress` - Shows "Building..." with amber banner +- `error` - Shows error banner, auto-expands logs +- `cancelled` - Shows grey cancelled state +- Missing container - Graceful degradation with messages +- Missing git info - Shows "—" instead of crashing +- Null duration - Shows "In progress..." + +### Loading & Error States +- `DeploymentSkeleton` - Animated loading skeleton +- `DeploymentError` - Error with retry button +- `DeploymentEmpty` - Empty state for edge cases + +## Docker API Helpers + +`src/lib/docker.ts` provides: + +```typescript +// Execute command via SSH to NUC +sshExec(command: string): Promise + +// Get container health status +getContainerHealth(containerName: string): Promise<'healthy' | 'unhealthy' | 'starting' | 'none' | null> + +// Get container resource stats +getContainerStats(containerName: string): Promise<{ + cpuPercent: number; + memoryUsage: string; + memoryLimit: string; + memoryPercent: number; + netIO: { rx: string; tx: string }; + blockIO: { read: string; write: string }; +} | null> + +// Get container uptime +getContainerUptime(containerName: string): Promise<{ + startedAt: string; + seconds: number; + formatted: string; +} | null> + +// Find container by app name or UUID +findContainerByAppName(appName: string): Promise +findContainerByUuid(appUuid: string): Promise +``` + +## Implementation Method + +Used parallel wave-based execution with 11 total agents: + +| Wave | Tasks | Agents | Duration | +|------|-------|--------|----------| +| Wave 1 | Route, Docker helpers, Table UI | 3 parallel | ~3.5 min | +| Wave 2 | Dashboard, Health API, Stats API, Navigation | 4 parallel | ~4.5 min | +| Wave 3 | Data integration, Links, Redeploy | 3 parallel | ~5 min | +| Wave 4 | Loading states, Edge cases | 2 parallel | ~3 min | + +**Total implementation time:** ~16 minutes + +## Testing + +```bash +# Start dev server +cd /Users/agutierrez/Desktop/nuc/nuc-portal +npm run dev + +# Get a deployment UUID +curl http://localhost:3000/api/deployments | jq '.[0].deployment_uuid' + +# Test dashboard page +open http://localhost:3000/deployments/ + +# Test API endpoints +curl http://localhost:3000/api/deployments//health | jq +curl http://localhost:3000/api/deployments//stats | jq +curl -X POST http://localhost:3000/api/deployments//redeploy | jq +``` + +## Dependencies Added + +```json +{ + "swr": "^2.x" // For data fetching with auto-refresh +} +``` + +## URLs Configuration + +Per user request, Coolify URLs use domain name: +- ✅ `http://coolify.nuc.lan:8000` (not IP) +- Dozzle still uses IP (no domain configured): `http://192.168.1.3:9999` + +## Screenshots + +Dashboard follows Vercel's deployment page design: +- Header with app icon, metadata grid +- Collapsible sections (Settings, Build Logs, Container Stats, Summary) +- Action cards grid at bottom +- Tab navigation (Deployment, Logs, Resources, Source) + +## Related + +- Design doc: `.artifacts/2026-02-06_17-30_deployment-dashboard-design.md` +- Implementation plan: `.artifacts/2026-02-06_18-00_deployment-dashboard-implementation-plan.md` +- nuc-portal repo: `/Users/agutierrez/Desktop/nuc/nuc-portal/` diff --git a/CLAUDE.md b/CLAUDE.md index 21adef0..18a6d28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,80 @@ ssh nuc ``` **Connection Details:** -- Hostname: `192.168.1.3` +- Hostname: `192.168.1.3` (local) or `100.113.153.45` (Tailscale) - User: `alezmad` - SSH Key: `~/.ssh/id_ed25519_nuc` +## DNS & Tailscale Setup + +### Why Tailscale IP for DNS + +All `.nuc.lan` domains resolve to the **Tailscale IP** (`100.113.153.45`) instead of the local IP (`192.168.1.3`). This ensures services work from **anywhere** regardless of your current network's subnet. + +**Problem solved:** When connecting from a remote network that also uses `192.168.x.x`, traffic to `192.168.1.3` stays local instead of going through Tailscale. Using Tailscale IP (`100.x.x.x`) avoids this conflict. + +### Configured Domains (OpenWrt Router DNS) + +| Domain | Resolves To | Service | +|--------|-------------|---------| +| `nuc.lan` | `100.113.153.45` | NUC Portal | +| `nuc.local` | `100.113.153.45` | NUC Portal | +| `coolify.nuc.lan` | `100.113.153.45` | Coolify | +| `gitea.nuc.lan` | `100.113.153.45` | Gitea | +| `outline.nuc.lan` | `100.113.153.45` | Outline Wiki | +| `files.nuc.lan` | `100.113.153.45` | FileBrowser | +| `mail.nuc.lan` | `100.113.153.45` | Snappymail | +| `vault.nuc.lan` | `100.113.153.45` | Vaultwarden | +| `homepage.nuc.lan` | `100.113.153.45` | NUC Portal | +| `brand.nuc.lan` | `100.113.153.45` | Whyrating Brand | +| `templates.nuc.lan` | `100.113.153.45` | Whyrating Templates | +| `whyrating.nuc.lan` | `100.113.153.45` | Whyrating Hub | + +### Traefik Routing (Dynamic Config) + +Traefik routes domain-based requests to the correct backend. Config location: `/data/coolify/proxy/dynamic/nuc-services.yaml` + +```yaml +# Routes for port-based services via domain names +http: + routers: + coolify: + rule: Host(`coolify.nuc.lan`) + service: coolify + services: + coolify: + loadBalancer: + servers: + - url: http://host.docker.internal:8000 +``` + +### Adding a New Domain + +```bash +# 1. Add DNS entry on router (via NUC jump host) +ssh nuc "ssh -i ~/.ssh/id_ed25519_nuc root@192.168.1.1 ' +uci add dhcp domain +uci set dhcp.@domain[-1].name=\"newservice.nuc.lan\" +uci set dhcp.@domain[-1].ip=\"100.113.153.45\" +uci commit dhcp +/etc/init.d/dnsmasq restart +'" + +# 2. Add Traefik route (if needed for port-based service) +# Edit /data/coolify/proxy/dynamic/nuc-services.yaml +``` + +### Always-On Tailscale + +**Keep Tailscale running** - it's designed to be always-on: +- When on home network: Uses direct connection (no relay, same performance as local) +- When remote: Routes through Tailscale mesh +- Minimal resource usage (~0% CPU when idle) + ## Service Management ### Coolify (Primary Service Manager) -All services are managed through Coolify at `http://192.168.1.3:8000` +All services are managed through Coolify at `http://coolify.nuc.lan` (or `http://100.113.153.45:8000`) **Prefer using Coolify MCP** (`mcp__coolify__*`) for service management - it's faster and more reliable than SSH commands. @@ -128,11 +194,114 @@ Task(subagent_type="general-purpose", prompt="Add services to Homepage...", desc | MCP | Purpose | |-----|---------| | `mcp__coolify__*` | Service management, deployments, env vars | +| `mcp__stalwart-mail__*` | Email server management (users, domains, queue) | +| `mcp__email-client__*` | Read/send emails via IMAP/SMTP (see below) | | `mcp__nocodb__*` | Database operations, table management | | `mcp__ssh-manager__*` | Direct SSH commands, file transfers | | `mcp__n8n__*` | Workflow automation (if configured) | | `mcp__playwriter__*` | Browser automation fallback (see below) | +### Stalwart Mail MCP (Quick Guide) + +**Location:** `~/mcp-servers/stalwart-mail/` + +Manage the self-hosted Stalwart mail server via natural language. + +**Available Tools:** + +| Category | Tools | +|----------|-------| +| **Users** | `list_users`, `get_user`, `create_user`, `update_user_password`, `delete_user`, `add_email_alias` | +| **Domains** | `create_domain`, `generate_dkim` | +| **Queue** | `list_queue`, `get_queue_status`, `delete_queued_message`, `retry_queued_message` | +| **Monitoring** | `get_metrics`, `get_dmarc_reports`, `get_server_logs` | +| **DNS** | `check_dns_records`, `troubleshoot_delivery` | +| **Spam** | `train_spam`, `train_ham`, `update_spam_filter` | + +**Usage Examples:** +``` +"List all mail users" +"Create user sales with email sales@whyrating.com and password Secret123" +"Check the mail queue" +"Verify DNS records for whyrating.com" +"Show server metrics" +"Delete user john" +``` + +**Direct API Test (if MCP not responding):** +```bash +curl -s -u "admin:QfKYjCJdxu" "http://192.168.1.3:8081/api/principal" | jq . +``` + +**Reconfigure MCP:** +```bash +claude mcp remove stalwart-mail +claude mcp add stalwart-mail \ + -e STALWART_URL=http://192.168.1.3:8081 \ + -e STALWART_USER=admin \ + -e STALWART_PASS=QfKYjCJdxu \ + --scope user \ + -- ~/mcp-servers/stalwart-mail/.venv/bin/python ~/mcp-servers/stalwart-mail/server.py +``` + +**⚠️ SMTP Authentication Requirements:** +1. **Password format:** Must be SHA-512 hashed (not plaintext). When creating users via API: + ```python + import crypt + hashed = crypt.crypt('password', crypt.mksalt(crypt.METHOD_SHA512)) + # Use hashed value in 'secrets' field + ``` +2. **SMTP login:** Use username only (e.g., `info`), NOT full email (`info@whyrating.com`) +3. **Port 465 (SMTPS):** Supports PLAIN/LOGIN auth with implicit TLS +4. **Port 587 (Submission):** Requires STARTTLS, only OAuth supported without TLS + +**Send email via Python (from NUC):** +```python +import smtplib, ssl +from email.mime.text import MIMEText + +context = ssl.create_default_context() +context.check_hostname = False +context.verify_mode = ssl.CERT_NONE + +with smtplib.SMTP_SSL('localhost', 465, context=context) as server: + server.login('info', 'whyrating2026') # Username only! + server.sendmail('info@whyrating.com', 'recipient@example.com', msg.as_string()) +``` + +### Email Client MCP (Read/Send Emails) + +**Package:** [mcp-email-server](https://github.com/ai-zerolab/mcp-email-server) + +Read and send emails via IMAP/SMTP directly from Claude. + +**Configured for:** `info@whyrating.com` on Stalwart + +**Usage Examples:** +``` +"Check my inbox" +"Read the latest email" +"Send an email to john@example.com with subject Hello" +"Search emails from support@" +"List email folders" +``` + +**Reconfigure:** +```bash +claude mcp remove email-client +claude mcp add email-client \ + -e MCP_EMAIL_SERVER_EMAIL_ADDRESS=info@whyrating.com \ + -e MCP_EMAIL_SERVER_PASSWORD=whyrating2026 \ + -e MCP_EMAIL_SERVER_IMAP_HOST=192.168.1.3 \ + -e MCP_EMAIL_SERVER_IMAP_PORT=143 \ + -e MCP_EMAIL_SERVER_SMTP_HOST=192.168.1.3 \ + -e MCP_EMAIL_SERVER_SMTP_PORT=587 \ + -e MCP_EMAIL_SERVER_SMTP_VERIFY_SSL=false \ + -e MCP_EMAIL_SERVER_ENABLE_ATTACHMENT_DOWNLOAD=true \ + --scope user \ + -- uvx mcp-email-server@latest stdio +``` + ### Adding Remote MCP Servers (HTTP Transport) **Use `claude mcp add --transport http` for remote MCP endpoints** - this is the recommended method for services with native MCP support. @@ -261,24 +430,31 @@ ssh nuc "docker exec " ## Services & Ports -| Service | Port | URL | Container | -|---------|------|-----|-----------| -| Homepage | 3000 | http://192.168.1.3:3000 | homepage-* | -| Coolify | 8000 | http://192.168.1.3:8000 | coolify | -| Gitea | 3030 | http://192.168.1.3:3030 | gitea-* | -| Outline | 3080 | http://192.168.1.3:3080 | outline-* | -| NocoDB | 8084 | http://192.168.1.3:8084 | nocodb-* | -| n8n | 5678 | http://192.168.1.3:5678 | n8n-* | -| Vaultwarden | 8222 | http://192.168.1.3:8222 | vaultwarden-* | -| Ntfy | 8333 | http://192.168.1.3:8333 | ntfy-* | -| MinIO Console | 9001 | http://192.168.1.3:9001 | minio-* | -| MinIO API | 9000 | http://192.168.1.3:9000 | minio-* | -| Authentik | 9090 | http://192.168.1.3:9090 | authentik-* | -| FileBrowser | 8085 | http://192.168.1.3:8085 | filebrowser-* | -| Adminer | 8088 | http://192.168.1.3:8088 | adminer | -| Uptime Kuma | 3001 | http://192.168.1.3:3001 | uptime-kuma | -| Kopia | 51515 | http://192.168.1.3:51515 | kopia | -| Dozzle | 9999 | http://192.168.1.3:9999 | dozzle | +**Preferred access via domain names** (works from anywhere via Tailscale): + +| Service | Domain | Port-based URL | Container | +|---------|--------|----------------|-----------| +| NUC Portal | `http://nuc.lan` | - | nuc-portal-* | +| Coolify | `http://coolify.nuc.lan` | `http://100.113.153.45:8000` | coolify | +| Gitea | `http://gitea.nuc.lan` | `http://100.113.153.45:3030` | gitea-* | +| Outline | `http://outline.nuc.lan` | `http://100.113.153.45:3080` | outline-* | +| FileBrowser | `http://files.nuc.lan` | `http://100.113.153.45:8085` | filebrowser-* | +| Snappymail | `http://mail.nuc.lan` | `http://100.113.153.45:8082` | snappymail-* | +| Vaultwarden | `http://vault.nuc.lan` | `http://100.113.153.45:8222` | vaultwarden-* | +| Homepage | `http://homepage.nuc.lan` | `http://100.113.153.45:3000` | nuc-portal-* | +| NocoDB | - | `http://100.113.153.45:8084` | nocodb-* | +| n8n | - | `http://100.113.153.45:5678` | n8n-* | +| Ntfy | - | `http://100.113.153.45:8333` | ntfy-* | +| MinIO Console | - | `http://100.113.153.45:9001` | minio-* | +| MinIO API | - | `http://100.113.153.45:9000` | minio-* | +| Authentik | - | `http://100.113.153.45:9090` | authentik-* | +| Adminer | - | `http://100.113.153.45:8088` | adminer | +| Uptime Kuma | - | `http://100.113.153.45:3001` | uptime-kuma | +| Kopia | - | `http://100.113.153.45:51515` | kopia | +| Dozzle | - | `http://100.113.153.45:9999` | dozzle | +| CloudBeaver | - | `http://100.113.153.45:8978` | cloudbeaver-* | + +**Note:** Use Tailscale IP (`100.113.153.45`) instead of `192.168.1.3` to avoid subnet conflicts when remote. ## Port Forwarding @@ -527,6 +703,63 @@ mcp__chrome-devtools__fill(uid="", value="") - Token URL: `http://192.168.1.3:3030/login/oauth/access_token` - Userinfo URL: `http://192.168.1.3:3030/login/oauth/userinfo` +## ⚠️ Critical Credentials & Access + +### CloudBeaver (Database Manager) + +| Property | Value | +|----------|-------| +| **URL** | `http://192.168.1.3:8978` | +| **Admin User** | `cbadmin` | +| **Admin Password** | `CloudBeaver2026!` | +| **Service UUID** | `joo4g4k0w08k8kcosgsgswc0` | + +**Pre-configured connections:** 9 databases across 3 folders. Turbostarter DB is now in service `v4gogwwc8wkk4888ksscc4k4` (container: `db-v4gogwwc8wkk4888ksscc4k4`). +Connected to 7 Docker networks for direct container-to-container access. + +### Vaultwarden (Password Manager) + +**⚠️ CRITICAL: Vaultwarden REQUIRES HTTPS** - The Web Crypto API needs a secure context for client-side encryption. HTTP access will NOT work (blank page/loading forever). + +| Property | Value | +|----------|-------| +| **HTTPS URL** | `https://nuc-tailscale.tail58f5ad.ts.net:8443` | +| **HTTP URL** | `http://192.168.1.3:8222` (won't load - HTTPS required) | +| **Admin Email** | `admin@nuc.lan` | +| **Admin Password** | `NucVault2026!Secure` | + +**Access via Tailscale Funnel:** +```bash +# Vaultwarden is exposed on port 8443 via Tailscale Funnel +open "https://nuc-tailscale.tail58f5ad.ts.net:8443" +``` + +### Stalwart Mail Server + +| Property | Value | +|----------|-------| +| **Admin URL** | `http://192.168.1.3:8081` | +| **Username** | `admin` | +| **Password** | `QfKYjCJdxu` | +| **Webmail (Snappymail)** | `http://192.168.1.3:8082` | +| **Service UUID** | `kw00kok0w0s8gcok008gk04k` | +| **MCP Server** | `mcp__stalwart-mail__*` (see Quick Guide above) | + +**Mail Users:** +| Email | Username | Password | +|-------|----------|----------| +| `info@whyrating.com` | `info` | `whyrating2026` | + +**DNS Records Configured:** SPF, DKIM (Ed25519 + RSA), DMARC, MX for `whyrating.com` + +**Container Status:** Running + +### Gitea Users (for Outline OIDC) + +| Username | Password | Notes | +|----------|----------|-------| +| `nedas` | `NedasNUC2026!` | Regular user account | + ## Gitea-Coolify Integration (Git Auto-Deploy) Deploy Next.js apps from self-hosted Gitea with auto-deploy on push. Full docs: `docs/gitea-coolify-auto-deploy.md` @@ -594,7 +827,7 @@ SSH config is set up for direct git operations: ```bash # Clone a repo -git clone gitea:nuc/nuc-portal.git +git clone gitea:alezmad/nuc-portal.git # Push changes (triggers auto-deploy via webhook) git push origin main @@ -634,7 +867,7 @@ mcp__coolify__application( project_uuid="a8484ggc88c40w4g4k004ow0", environment_name="production", server_uuid="qk84w0goo4w48g4ggsoo0oss", - git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:nuc/.git", + git_repository="git@gitea-ho0cwgcwos88cwc48g84c0g8:alezmad/.git", git_branch="main", build_pack="nixpacks", ports_exposes="3000", @@ -681,7 +914,7 @@ Add via Gitea: Repository → Settings → Deploy Keys → **Enable Write Access When creating a new repo that should auto-deploy: 1. **[ ] Add deploy key to Gitea repo** - - Go to: `http://192.168.1.3:3030/nuc//settings/keys` + - Go to: `http://192.168.1.3:3030/alezmad//settings/keys` - Add the deploy key above with **Write Access** enabled 2. **[ ] Create Coolify application** (use `mcp__coolify__application` with `action="create_key"`) @@ -691,7 +924,7 @@ When creating a new repo that should auto-deploy: 4. **[ ] Set webhook secret** via tinker command (use shared secret above) 5. **[ ] Create Gitea webhook** - - Go to: `http://192.168.1.3:3030/nuc//settings/hooks` + - Go to: `http://192.168.1.3:3030/alezmad//settings/hooks` - Add Webhook → Gitea - **URL:** `http://coolify:8080/webhooks/source/gitea/events/manual?uuid=` - **Secret:** `9eb07a77964563378c5d66d99006e06ba3da39d232905d4b12554ff91ca39718` @@ -728,10 +961,59 @@ Coolify's "Gitea Source" uses GitHub App-style OAuth with JWT - **this doesn't w | App | URL | Repository | UUID | |-----|-----|------------|------| -| nuc-portal | http://nuc.lan | `nuc/nuc-portal` | `t80w0cw0oooc4g0soswos4so` | -| whyrating-hub | http://whyrating.nuc.lan | `nuc/whyrating-hub` | `vw4ggc40socwkgwg4osc8wg8` | -| whyrating-brand | http://brand.nuc.lan | `nuc/whyrating-brand` | `r80gk0ccgg0okos8cw848kkk` | -| whyrating-templates | http://templates.nuc.lan | `nuc/whyrating-templates` | `qw80g4sog0kk8cc4wkcs8sgc` | +| nuc-portal | http://nuc.lan | `alezmad/nuc-portal` | `t80w0cw0oooc4g0soswos4so` | +| whyrating-hub | http://whyrating.nuc.lan | `alezmad/whyrating-hub` | `vw4ggc40socwkgwg4osc8wg8` | +| whyrating-brand | http://brand.nuc.lan | `alezmad/whyrating-brand` | `r80gk0ccgg0okos8cw848kkk` | +| whyrating-templates | http://templates.nuc.lan | `alezmad/whyrating-templates` | `qw80g4sog0kk8cc4wkcs8sgc` | +| turbostarter | https://alezmad-nuc.tail58f5ad.ts.net | `alezmad/turbostarter` | `v4gogwwc8wkk4888ksscc4k4` (service) | + +### Turbostarter (Knosia) - Build & Deploy + +Turbostarter is deployed as a **Coolify Service** (not a standalone app) with full docker-compose infrastructure: web + pgvector + minio. + +**Architecture:** Tailscale Funnel (HTTPS) → Traefik (HTTP:80) → web container + +**FQDN (Traefik):** `http://alezmad-nuc.tail58f5ad.ts.net` (HTTP internally — Tailscale handles TLS termination) + +**Build & Deploy workflow:** +```bash +# 1. Build image locally (ARM→AMD cross-compile) +cd /Users/agutierrez/Desktop/turbostarter-export +docker build --platform linux/amd64 \ + --build-arg NEXT_PUBLIC_URL=https://alezmad-nuc.tail58f5ad.ts.net \ + -t 192.168.1.3:3030/alezmad/turbostarter:latest . + +# 2. Push to Gitea registry +docker push 192.168.1.3:3030/alezmad/turbostarter:latest + +# 3. Redeploy via Coolify (stop + start for full container recreation) +mcp__coolify__control(resource="service", action="stop", uuid="v4gogwwc8wkk4888ksscc4k4") +mcp__coolify__control(resource="service", action="start", uuid="v4gogwwc8wkk4888ksscc4k4") +``` + +**Containers:** +| Container | Image | Purpose | +|-----------|-------|---------| +| `web-v4gogwwc8wkk4888ksscc4k4` | `localhost:3030/alezmad/turbostarter:latest` | Next.js app | +| `db-v4gogwwc8wkk4888ksscc4k4` | `pgvector/pgvector:pg17` | PostgreSQL + pgvector | +| `minio-v4gogwwc8wkk4888ksscc4k4` | `minio/minio:latest` | Object storage | +| `minio-init-v4gogwwc8wkk4888ksscc4k4` | `minio/mc:latest` | One-time bucket init | + +**Database access (via SSH tunnel):** +```bash +# Get DB container IP first +ssh nuc "docker inspect db-v4gogwwc8wkk4888ksscc4k4 --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'" +# Then tunnel to IP (not container name) +ssh -L 5440::5432 nuc +# Connect: postgres://turbostarter:turbostarter@localhost:5440/core +``` + +**Seeded users:** `me+admin@turbostarter.dev` / `Pa$$w0rd` (admin), `me+user@turbostarter.dev` / `Pa$$w0rd` + +**Key env vars:** +- `BETTER_AUTH_TRUSTED_ORIGINS` — comma-separated list of allowed origins (CSRF protection) +- `NEXT_PUBLIC_URL` — build-time arg baked into Next.js static output (must rebuild to change) +- `DATABASE_URL` — internal docker network connection to pgvector ### New Site from nuc-portal Template @@ -751,7 +1033,7 @@ rm -rf .git .next node_modules npm install && npm run build # verify it builds git init && git add -A && git commit -m "Initial commit" # Create repo in Gitea first, then: -git remote add origin gitea:nuc/.git +git remote add origin gitea:alezmad/.git git push -u origin main ```