diff --git a/apps/frontend/src/app/app/deployments/[id]/page.tsx b/apps/frontend/src/app/app/deployments/[id]/page.tsx new file mode 100644 index 0000000..3043bb6 --- /dev/null +++ b/apps/frontend/src/app/app/deployments/[id]/page.tsx @@ -0,0 +1,451 @@ +'use client'; + +/** + * Deployment Detail Page — /app/deployments/[id] + * + * Sections: + * 1. Metadata header — name, status, environment, trigger, timestamps, URLs + * 2. Actions toolbar — Redeploy, Delete (with confirmation) + * 3. Health — current health check result and response time + * 4. Analytics — page views, uptime %, transaction count + * 5. Logs — paginated, filterable build/runtime log stream + * 6. History — previous deployments for the same project (diff entry point) + * + * Data contracts: + * GET /api/deployments/[id] → deployment metadata + * GET /api/deployments/[id]/health → { isHealthy, responseTime, statusCode, lastChecked } + * GET /api/deployments/[id]/analytics → { analytics[], summary } + * GET /api/deployments/[id]/logs → { data: DeploymentLogResponse[], pagination } + * DELETE /api/deployments/[id] → { success, deploymentId } + * + * See docs/deployment-detail-design.md for full design rationale. + */ + +import React, { useState } from 'react'; +import { AppShell } from '@/components/app'; +import { DeploymentStatusBadge } from '@/components/deployments'; +import type { User, NavItem } from '@/types/navigation'; +import type { DeploymentStatus, DeploymentEnvironment, DeploymentTrigger } from '@/types/deployment'; +import type { LogLevel } from '@craft/types'; + +/* ─── Shell fixtures (replace with session/SWR in production) ─── */ + +const mockUser: User = { + id: '1', + name: 'John Doe', + email: 'john@example.com', + role: 'user', +}; + +const navItems: NavItem[] = [ + { + id: 'deployments', + label: 'Deployments', + icon: ( + + + + ), + path: '/app/deployments', + }, +]; + +/* ─── Mock detail data (replace with SWR / React Query) ─────── */ + +interface DeploymentDetail { + id: string; + name: string; + status: DeploymentStatus; + environment: DeploymentEnvironment; + trigger: DeploymentTrigger; + commit: { sha: string; message: string; author: string; branch: string }; + region: { label: string; flag: string }; + createdAt: string; + completedAt?: string; + durationSeconds?: number; + url?: string; + repositoryUrl?: string; + templateId: string; +} + +interface HealthData { + isHealthy: boolean; + responseTime: number; + statusCode: number; + lastChecked: string; +} + +interface AnalyticsSummary { + totalPageViews: number; + uptimePercentage: number; + totalTransactions: number; + lastChecked: string; +} + +interface LogEntry { + id: string; + timestamp: string; + level: LogLevel; + message: string; +} + +interface HistoryEntry { + id: string; + status: DeploymentStatus; + createdAt: string; + commit: { sha: string; message: string }; + durationSeconds?: number; +} + +const MOCK_DETAIL: DeploymentDetail = { + id: 'dep-001', + name: 'stellar-dex-template', + status: 'success', + environment: 'production', + trigger: 'push', + commit: { sha: 'a3f9c12', message: 'feat: add liquidity pool calculation logic', author: 'jana.m', branch: 'main' }, + region: { label: 'US East', flag: '🇺🇸' }, + createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + completedAt: new Date(Date.now() - 2.8 * 60 * 60 * 1000).toISOString(), + durationSeconds: 127, + url: 'https://stellar-dex.craft.app', + repositoryUrl: 'https://github.com/acme/stellar-dex', + templateId: 'stellar-dex', +}; + +const MOCK_HEALTH: HealthData = { + isHealthy: true, + responseTime: 245, + statusCode: 200, + lastChecked: new Date(Date.now() - 5 * 60 * 1000).toISOString(), +}; + +const MOCK_ANALYTICS: AnalyticsSummary = { + totalPageViews: 1_482, + uptimePercentage: 99.9, + totalTransactions: 312, + lastChecked: new Date(Date.now() - 5 * 60 * 1000).toISOString(), +}; + +const MOCK_LOGS: LogEntry[] = [ + { id: 'l1', timestamp: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), level: 'info', message: 'Deployment pipeline started' }, + { id: 'l2', timestamp: new Date(Date.now() - 2.95 * 60 * 60 * 1000).toISOString(), level: 'info', message: 'Generating template files…' }, + { id: 'l3', timestamp: new Date(Date.now() - 2.9 * 60 * 60 * 1000).toISOString(), level: 'info', message: 'Creating GitHub repository stellar-dex' }, + { id: 'l4', timestamp: new Date(Date.now() - 2.85 * 60 * 60 * 1000).toISOString(), level: 'info', message: 'Pushing generated code (47 files)' }, + { id: 'l5', timestamp: new Date(Date.now() - 2.82 * 60 * 60 * 1000).toISOString(), level: 'warn', message: 'Vercel build warning: unused variable in swap.ts:42' }, + { id: 'l6', timestamp: new Date(Date.now() - 2.8 * 60 * 60 * 1000).toISOString(), level: 'info', message: 'Deployment completed — https://stellar-dex.craft.app' }, +]; + +const MOCK_HISTORY: HistoryEntry[] = [ + { id: 'dep-001', status: 'success', createdAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), commit: { sha: 'a3f9c12', message: 'feat: add liquidity pool calculation logic' }, durationSeconds: 127 }, + { id: 'dep-prev-1', status: 'failed', createdAt: new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(), commit: { sha: 'b7e2d45', message: 'fix: handle edge-case in invoice reconciliation' }, durationSeconds: 43 }, + { id: 'dep-prev-2', status: 'success', createdAt: new Date(Date.now() - 50 * 60 * 60 * 1000).toISOString(), commit: { sha: 'c1a8b90', message: 'chore: bump soroban-sdk to v21' }, durationSeconds: 98 }, +]; + +/* ─── Sub-components ─────────────────────────────────────────── */ + +const LOG_LEVEL_CLASSES: Record = { + info: 'text-on-surface-variant', + warn: 'text-amber-600', + error: 'text-red-600', +}; + +function formatRelative(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function formatDuration(s: number): string { + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = s % 60; + return rem > 0 ? `${m}m ${rem}s` : `${m}m`; +} + +function SectionHeading({ children }: { children: React.ReactNode }) { + return ( +

{children}

+ ); +} + +function MetaRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/* ─── Page ───────────────────────────────────────────────────── */ + +export default function DeploymentDetailPage({ params }: { params: { id: string } }) { + const [logLevel, setLogLevel] = useState('all'); + const [deleteConfirm, setDeleteConfirm] = useState(false); + + const dep = MOCK_DETAIL; // TODO: replace with useSWR(`/api/deployments/${params.id}`) + const health = MOCK_HEALTH; // TODO: replace with useSWR(`/api/deployments/${params.id}/health`) + const analytics = MOCK_ANALYTICS; // TODO: replace with useSWR(`/api/deployments/${params.id}/analytics`) + const logs = MOCK_LOGS; // TODO: replace with useSWR(`/api/deployments/${params.id}/logs`) + const history = MOCK_HISTORY; // TODO: replace with useSWR(`/api/deployments/${params.id}/history`) + + const filteredLogs = logLevel === 'all' ? logs : logs.filter((l) => l.level === logLevel); + + const canRedeploy = dep.status === 'success' || dep.status === 'failed' || dep.status === 'cancelled'; + + return ( + +
+ + {/* ── 1. Header ── */} +
+
+

+ {dep.name} +

+ +
+ + {/* ── 2. Actions toolbar ── */} +
+ {dep.url && ( + + + Visit + + )} + + {canRedeploy && ( + + )} + + {!deleteConfirm ? ( + + ) : ( +
+ Confirm delete? + + +
+ )} +
+
+ + {/* ── 3. Metadata ── */} +
+ Details + + {dep.environment} + + + {dep.trigger} + + + {dep.commit.sha} + {dep.commit.message} + + + {dep.commit.branch} + + {dep.commit.author} + {dep.region.flag} {dep.region.label} + {formatRelative(dep.createdAt)} + {dep.durationSeconds !== undefined && ( + {formatDuration(dep.durationSeconds)} + )} + {dep.url && ( + + {dep.url} + + )} + {dep.repositoryUrl && ( + + {dep.repositoryUrl} + + )} +
+ + {/* ── 4. Health ── */} +
+ Health +
+
+ Status + + {health.isHealthy ? '✓ Healthy' : '✗ Unhealthy'} + +
+
+ HTTP status + {health.statusCode} +
+
+ Response time + {health.responseTime}ms +
+
+ Last checked + {formatRelative(health.lastChecked)} +
+
+
+ + {/* ── 5. Analytics ── */} +
+ Analytics +
+ {[ + { label: 'Page views', value: analytics.totalPageViews.toLocaleString() }, + { label: 'Uptime', value: `${analytics.uptimePercentage.toFixed(1)}%` }, + { label: 'Transactions', value: analytics.totalTransactions.toLocaleString() }, + ].map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+
+ + {/* ── 6. Logs ── */} +
+
+ Logs +
+ {(['all', 'info', 'warn', 'error'] as const).map((lvl) => ( + + ))} +
+
+ +
+ {filteredLogs.length === 0 ? ( +

No log entries for this filter.

+ ) : ( + filteredLogs.map((entry) => ( +
+ + {new Date(entry.timestamp).toISOString().slice(11, 19)} + + + {entry.level === 'warn' ? 'WARN' : entry.level.toUpperCase()} + + {entry.message} +
+ )) + )} +
+
+ + {/* ── 7. History ── */} +
+ History +
    + {history.map((h) => ( +
  • + +
    + {h.commit.sha} + {h.commit.message} +
    +
    + {h.durationSeconds !== undefined && {formatDuration(h.durationSeconds)}} + {formatRelative(h.createdAt)} + {h.id !== dep.id && ( + + View + + )} + {h.id === dep.id && ( + Current + )} +
    +
  • + ))} +
+
+ +
+
+ ); +} diff --git a/docs/deployment-detail-design.md b/docs/deployment-detail-design.md new file mode 100644 index 0000000..5c7817e --- /dev/null +++ b/docs/deployment-detail-design.md @@ -0,0 +1,170 @@ +# Deployment Detail — Design Document + +**Issue:** #024 +**Route:** `/app/deployments/[id]` +**Page file:** `apps/frontend/src/app/app/deployments/[id]/page.tsx` + +--- + +## Overview + +The deployment detail page gives users a single-pane view of everything relevant to one deployment: its current state, build output, runtime health, analytics, and the history of prior deployments for the same project. It also surfaces the two primary destructive actions — redeploy and delete — with appropriate confirmation guards. + +--- + +## Page Sections + +### 1. Header + +Displays the deployment name and current `DeploymentStatusBadge` side-by-side. The name comes from `GET /api/deployments/[id]` → `name`. + +### 2. Actions Toolbar + +| Action | Condition | API call | +|--------|-----------|----------| +| **Visit** | `url` present | Opens `deployment.url` in new tab | +| **Redeploy** | `status` is `success`, `failed`, or `cancelled` | `POST /api/deployments/[id]/redeploy` (future) | +| **Delete** | Always visible | `DELETE /api/deployments/[id]` — two-step confirmation inline | + +Delete uses an inline confirm pattern (no modal) to keep the interaction lightweight. The confirm state is local React state; on confirmation the page redirects to `/app/deployments`. + +### 3. Metadata + +Tabular key/value layout. Fields: + +| Field | Source | +|-------|--------| +| Environment | `deployment.environment` | +| Trigger | `deployment.trigger` | +| Commit SHA + message | `deployment.commit` | +| Branch | `deployment.commit.branch` | +| Author | `deployment.commit.author` | +| Region | `deployment.region` | +| Started | `deployment.createdAt` (relative) | +| Duration | `deployment.durationSeconds` (formatted) | +| URL | `deployment.url` (link) | +| Repository | `deployment.repositoryUrl` (link) | + +### 4. Health + +Source: `GET /api/deployments/[id]/health` + +Displays: healthy/unhealthy indicator, HTTP status code, response time (ms), last-checked timestamp. Refreshes on a 5-minute interval (Vercel Cron drives the backend check; the UI polls or uses SWR revalidation). + +### 5. Analytics + +Source: `GET /api/deployments/[id]/analytics` → `summary` + +Displays three headline metrics: total page views, uptime percentage, total transactions. A "Export CSV" link points to `GET /api/deployments/[id]/analytics/export`. + +### 6. Logs + +Source: `GET /api/deployments/[id]/logs?order=asc&limit=200` + +- Rendered in a fixed-height scrollable dark terminal pane. +- Client-side level filter (`all` / `info` / `warn` / `error`) — no extra API call. +- Each row: `HH:MM:SS LEVEL message`. +- `role="log"` + `aria-live="polite"` for screen-reader compatibility. +- Pagination: "Load more" button appends the next page (`?page=2`). + +### 7. History + +Source: `GET /api/deployments?name=[deployment.name]&limit=10` (same project, ordered by `createdAt desc`) + +Lists prior deployments for the same project name. The current deployment is highlighted. Each row links to its own detail page (`/app/deployments/[id]`), enabling diff navigation between versions. + +--- + +## Action Affordances + +### Redeploy + +- **Trigger:** User clicks "Redeploy" in the actions toolbar. +- **Precondition:** `status` ∈ `{success, failed, cancelled}`. +- **Effect:** Creates a new deployment record with the same `templateId` and `customizationConfig`. The new deployment appears at the top of the history list. +- **API:** `POST /api/deployments/[id]/redeploy` (to be implemented; see issue #025). +- **UI feedback:** Button shows spinner while in-flight; on success, redirect to the new deployment's detail page. + +### Delete + +- **Trigger:** User clicks "Delete", then confirms inline. +- **Effect:** Calls `DELETE /api/deployments/[id]`, which removes the GitHub repo, Vercel project, and DB record (cascade to logs and analytics). +- **UI feedback:** Two-step inline confirm (no modal). On success, redirect to `/app/deployments`. +- **Edge case:** If the deployment is `running`, the delete button is still shown but the API will reject it with a 409 until the build completes. The UI should surface this error inline. + +--- + +## Customization Updates and History Diffs + +Customization changes are applied via `PATCH /api/deployments/[id]` (updating `customization_config`), which triggers a new deployment pipeline run. The resulting new deployment record appears in the history list. + +To surface diffs between versions: +- The history list links each entry to its own detail page. +- The metadata section shows the commit SHA and message for each version. +- A future "Compare" affordance (issue #026) will render a side-by-side diff of `customization_config` between two history entries. + +--- + +## Data Contracts + +```typescript +// GET /api/deployments/[id] +{ + id: string; + name: string; + status: DeploymentStatusType; + templateId: string; + vercelProjectId?: string; + deploymentUrl?: string; + repositoryUrl?: string; + customizationConfig: CustomizationConfig; + errorMessage?: string; + timestamps: { created: string; updated: string; deployed?: string }; +} + +// GET /api/deployments/[id]/health +{ + isHealthy: boolean; + responseTime: number; // ms + statusCode: number; + error: string | null; + lastChecked: string; // ISO 8601 +} + +// GET /api/deployments/[id]/analytics +{ + analytics: Array<{ id, metricType, metricValue, recordedAt }>; + summary: { + totalPageViews: number; + uptimePercentage: number; + totalTransactions: number; + lastChecked: string; + }; +} + +// GET /api/deployments/[id]/logs +{ + data: Array<{ id, deploymentId, timestamp, level, message }>; + pagination: { page, limit, total, hasNextPage }; +} +``` + +--- + +## Accessibility + +- Each section uses `
` for landmark navigation. +- The log pane uses `role="log"` and `aria-live="polite"`. +- Action buttons have `aria-label` attributes that include the deployment name. +- The delete confirm flow is keyboard-navigable (no focus trap needed for inline confirm). + +--- + +## Follow-up Work + +| Issue | Description | +|-------|-------------| +| #025 | Implement `POST /api/deployments/[id]/redeploy` endpoint | +| #026 | Side-by-side customization diff between history entries | +| #027 | Replace mock fixtures with SWR data fetching hooks | +| #028 | Real-time log streaming via Supabase Realtime subscription |