From 7399315185624ea0557f6874b261943c9da66044 Mon Sep 17 00:00:00 2001 From: Wyatt <59319679+0xWheatyz@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:34:43 -0400 Subject: [PATCH] Replace Alpine frontend with Next.js Claude Activity Dashboard (#7) Rebuild the bundled web UI as a Next.js (React + TypeScript) static export implementing the Claude Activity Dashboard design: a left-nav "Control Center" hub over Runs, Repositories, Agents, Approvals, Git Servers, Activity, and Shared, styled with the Leeworks design-system tokens (flat, dark, border-led). The dashboard is a pure client of the existing API (same contract as curl): the browser prompts for the token once, stores it in localStorage, attaches it to every call, and renders all API values as React text so agent-authored strings stay inert. Control actions enqueue a command and poll it to a terminal state, matching the worker model. The build output is committed to src/handler/api/static/ so the wheel ships it and FastAPI serves it same-origin. app.py now mounts the export at "/" after the API routers (a non-shadowing fallback: unmatched paths 404, no SPA rewrite). UI-serving tests updated for the export; frontend source lives in frontend/. Claude-Session: https://claude.ai/code/session_01ATgVWRjFzG8nHEnwgZpJWD Co-authored-by: Claude --- README.md | 25 + frontend/.gitignore | 8 + frontend/app/globals.css | 911 ++++++++++++++++++ frontend/app/icon.svg | 10 + frontend/app/layout.tsx | 15 + frontend/app/page.tsx | 49 + frontend/components/Dashboard.tsx | 102 ++ frontend/components/TokenGate.tsx | 48 + .../components/sections/ActivitySection.tsx | 63 ++ .../components/sections/AgentsSection.tsx | 156 +++ .../components/sections/ApprovalsSection.tsx | 115 +++ .../components/sections/GitServersSection.tsx | 129 +++ .../sections/RepositoriesSection.tsx | 141 +++ frontend/components/sections/RunsSection.tsx | 306 ++++++ .../components/sections/SharedSection.tsx | 114 +++ frontend/components/store.tsx | 665 +++++++++++++ frontend/components/ui.tsx | 282 ++++++ frontend/next.config.mjs | 12 + frontend/package-lock.json | 499 ++++++++++ frontend/package.json | 22 + frontend/tsconfig.json | 21 + src/handler/api/app.py | 18 +- src/handler/api/static/404.html | 1 + .../GQbm47pHcnN5aChqrNLgi/_buildManifest.js | 1 + .../GQbm47pHcnN5aChqrNLgi/_ssgManifest.js | 1 + .../static/chunks/117-e7bb738621b70d3f.js | 2 + .../app/_not-found/page-b54079f2332706f3.js | 1 + .../chunks/app/layout-b3a2b6165ef8a4d0.js | 1 + .../chunks/app/page-67c3ee7b71a251cf.js | 1 + .../chunks/fd9d1056-8edc3f3573e7d5e5.js | 1 + .../chunks/framework-f66176bb897dc684.js | 1 + .../static/chunks/main-0be53fa9e2379caf.js | 1 + .../chunks/main-app-8321800782c5a11e.js | 1 + .../chunks/pages/_app-72b849fbd24ac258.js | 1 + .../chunks/pages/_error-7ba65e1336b92748.js | 1 + .../chunks/polyfills-42372ed130431b0a.js | 1 + .../static/chunks/webpack-29957205745576aa.js | 1 + .../_next/static/css/bbd6ee1e7265be74.css | 1 + src/handler/api/static/alpine.min.js | 8 - src/handler/api/static/app.js | 572 ----------- src/handler/api/static/icon.svg | 10 + src/handler/api/static/index.html | 412 +------- src/handler/api/static/index.txt | 7 + src/handler/api/static/styles.css | 204 ---- tests/test_api_ui.py | 45 +- 45 files changed, 3763 insertions(+), 1223 deletions(-) create mode 100644 frontend/.gitignore create mode 100644 frontend/app/globals.css create mode 100644 frontend/app/icon.svg create mode 100644 frontend/app/layout.tsx create mode 100644 frontend/app/page.tsx create mode 100644 frontend/components/Dashboard.tsx create mode 100644 frontend/components/TokenGate.tsx create mode 100644 frontend/components/sections/ActivitySection.tsx create mode 100644 frontend/components/sections/AgentsSection.tsx create mode 100644 frontend/components/sections/ApprovalsSection.tsx create mode 100644 frontend/components/sections/GitServersSection.tsx create mode 100644 frontend/components/sections/RepositoriesSection.tsx create mode 100644 frontend/components/sections/RunsSection.tsx create mode 100644 frontend/components/sections/SharedSection.tsx create mode 100644 frontend/components/store.tsx create mode 100644 frontend/components/ui.tsx create mode 100644 frontend/next.config.mjs create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/tsconfig.json create mode 100644 src/handler/api/static/404.html create mode 100644 src/handler/api/static/_next/static/GQbm47pHcnN5aChqrNLgi/_buildManifest.js create mode 100644 src/handler/api/static/_next/static/GQbm47pHcnN5aChqrNLgi/_ssgManifest.js create mode 100644 src/handler/api/static/_next/static/chunks/117-e7bb738621b70d3f.js create mode 100644 src/handler/api/static/_next/static/chunks/app/_not-found/page-b54079f2332706f3.js create mode 100644 src/handler/api/static/_next/static/chunks/app/layout-b3a2b6165ef8a4d0.js create mode 100644 src/handler/api/static/_next/static/chunks/app/page-67c3ee7b71a251cf.js create mode 100644 src/handler/api/static/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js create mode 100644 src/handler/api/static/_next/static/chunks/framework-f66176bb897dc684.js create mode 100644 src/handler/api/static/_next/static/chunks/main-0be53fa9e2379caf.js create mode 100644 src/handler/api/static/_next/static/chunks/main-app-8321800782c5a11e.js create mode 100644 src/handler/api/static/_next/static/chunks/pages/_app-72b849fbd24ac258.js create mode 100644 src/handler/api/static/_next/static/chunks/pages/_error-7ba65e1336b92748.js create mode 100644 src/handler/api/static/_next/static/chunks/polyfills-42372ed130431b0a.js create mode 100644 src/handler/api/static/_next/static/chunks/webpack-29957205745576aa.js create mode 100644 src/handler/api/static/_next/static/css/bbd6ee1e7265be74.css delete mode 100644 src/handler/api/static/alpine.min.js delete mode 100644 src/handler/api/static/app.js create mode 100644 src/handler/api/static/icon.svg create mode 100644 src/handler/api/static/index.txt delete mode 100644 src/handler/api/static/styles.css diff --git a/README.md b/README.md index a013270..6b313fd 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,29 @@ covered. The seams — `control.tmux`, `control.forge`, `control.gitops`, `hooks and `control.spawn.resume` — are the mock points that stand in for live `claude`/`tmux`/`mise`/`forge`/`git`, and the drop-in points for wiring them up for real. +### Frontend + +The dashboard (`frontend/`) is a **Next.js** app (React + TypeScript) that builds to a +**static export** — the `Claude Activity` Control Center: a left-nav hub over Runs, +Repositories, Agents, Approvals, Git Servers, Activity, and Shared. It is a pure client of +the API (same contract as `curl`): the browser prompts for the token once, stores it in +`localStorage`, and attaches it to every call. All API values render as React text +(never `dangerouslySetInnerHTML`) so agent-authored strings can't inject markup. + +The build output is committed to `src/handler/api/static/` so the Python wheel ships it and +FastAPI serves it same-origin — there is no separate frontend server and no node step in the +Docker image. Rebuild after changing the UI: + +```bash +cd frontend +npm install +npm run build # static export → frontend/out/ +npm run export # build, then sync frontend/out/ → src/handler/api/static/ +``` + +`npm run dev` runs the UI against a live API on another origin — set +`NEXT_PUBLIC_API_BASE=http://127.0.0.1:8000` and enable `CORS_ORIGINS` on the API. + ## Project layout ``` @@ -348,6 +371,8 @@ src/handler/ # forge/gitops seams, credentials, skills_gen, CI poller hooks/ # Stop/SessionEnd, PreToolUse gate (push + approval), Notification migrations/ # Alembic env + versions + api/static/ # built Next.js export (generated — see frontend/) +frontend/ # Next.js dashboard source (builds to api/static/) tests/ # DB, API, hook, and control tests (SQLite) docs/PLAN.md # full design + phased roadmap (the original plan of action) ``` diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..dbc14e7 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,8 @@ +# Frontend build artifacts. The *built* static export is committed under +# src/handler/api/static/ (that is what the Python package ships and FastAPI serves); +# everything below is regenerated by `npm install` / `npm run build`. +/node_modules +/.next +/out +/next-env.d.ts +*.tsbuildinfo diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..7e25881 --- /dev/null +++ b/frontend/app/globals.css @@ -0,0 +1,911 @@ +/* ============================================================ + * Handler dashboard — visual language ported from the Leeworks + * design system (flat, dark, border-led "developer-native"). + * Token hex values are copied verbatim from the design system. + * ============================================================ */ + +:root { + /* ---- surfaces ---- */ + --lw-bg: #0f1117; + --lw-surface: #1a1d27; + --lw-surface-2: #232733; + --lw-surface-3: #2b3040; + /* ---- borders ---- */ + --lw-border: #2d3748; + --lw-border-strong: #3a4861; + /* ---- text ---- */ + --lw-white: #ffffff; + --lw-text: #e2e8f0; + --lw-text-muted: #a0aec0; + --lw-text-faint: #718096; + /* ---- accents ---- */ + --lw-blue-200: #bee3f8; + --lw-blue-300: #90cdf4; + --lw-blue-400: #63b3ed; + --lw-blue-500: #4299e1; + --lw-indigo-400: #7f9cf5; + --lw-indigo-500: #667eea; + --lw-indigo-600: #5a67d8; + --lw-indigo-700: #4c51bf; + /* ---- status chip pairs ---- */ + --lw-success-bg: #1a4731; + --lw-success-fg: #9ae6b4; + --lw-warning-bg: #744210; + --lw-warning-fg: #fbd38d; + --lw-danger-bg: #63171b; + --lw-danger-fg: #feb2b2; + --lw-info-bg: #1a365d; + --lw-info-fg: #90cdf4; + --lw-neutral-bg: #232733; + --lw-neutral-fg: #a0aec0; + + /* ---- semantic aliases ---- */ + --surface-page: var(--lw-bg); + --surface-card: var(--lw-surface); + --surface-raised: var(--lw-surface-2); + --surface-inset: var(--lw-surface-3); + --border-default: var(--lw-border); + --border-strong: var(--lw-border-strong); + --text-heading: var(--lw-white); + --text-body: var(--lw-text); + --text-muted: var(--lw-text-muted); + --text-faint: var(--lw-text-faint); + --link: var(--lw-blue-300); + --link-hover: var(--lw-white); + --accent: var(--lw-blue-300); + --accent-hover: var(--lw-blue-400); + --action-bg: var(--lw-indigo-500); + --action-bg-hover: var(--lw-indigo-600); + --action-bg-active: var(--lw-indigo-700); + --action-fg: var(--lw-white); + --focus-ring: var(--lw-blue-400); + + /* ---- type ---- */ + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; + --fw-regular: 400; + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + --fw-black: 800; + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-md: 0.95rem; + --text-base: 1rem; + --text-lg: 1.25rem; + --text-xl: 1.5rem; + --text-2xl: 2rem; + --lh-tight: 1.15; + --lh-snug: 1.3; + --lh-normal: 1.6; + --ls-tight: -0.02em; + --ls-wide: 0.04em; + + /* ---- radii / motion ---- */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55); + --shadow-focus: 0 0 0 3px rgba(99, 179, 237, 0.45); + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --dur-fast: 120ms; + --dur-normal: 180ms; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: var(--lh-normal); + color: var(--text-body); + background: var(--surface-page); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: var(--link); + text-decoration: none; + transition: color var(--dur-fast) var(--ease-standard); +} +a:hover { + color: var(--link-hover); +} + +::selection { + background: rgba(102, 126, 234, 0.4); + color: #fff; +} +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} +::-webkit-scrollbar { + width: 9px; + height: 9px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: var(--radius-full); +} +::-webkit-scrollbar-track { + background: transparent; +} + +.mono { + font-family: var(--font-mono); +} +.muted { + color: var(--text-muted); +} +.faint { + color: var(--text-faint); +} +.nowrap { + white-space: nowrap; +} +.truncate { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ---------------- Badge ---------------- */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: var(--text-xs); + font-weight: var(--fw-semibold); + line-height: 1; + padding: 4px 9px; + border-radius: var(--radius-sm); + white-space: nowrap; +} +.badge.pill { + border-radius: var(--radius-full); +} +.badge-success { + background: var(--lw-success-bg); + color: var(--lw-success-fg); +} +.badge-warning { + background: var(--lw-warning-bg); + color: var(--lw-warning-fg); +} +.badge-danger { + background: var(--lw-danger-bg); + color: var(--lw-danger-fg); +} +.badge-info { + background: var(--lw-info-bg); + color: var(--lw-info-fg); +} +.badge-neutral { + background: var(--lw-neutral-bg); + color: var(--lw-neutral-fg); +} +.badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* ---------------- Card ---------------- */ +.card { + background: var(--surface-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 20px 22px; + transition: border-color var(--dur-normal) var(--ease-standard); +} +.card.interactive { + cursor: pointer; +} +.card.interactive:hover { + border-color: var(--border-strong); +} + +/* ---------------- Button ---------------- */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--fw-semibold); + line-height: 1; + padding: 0 16px; + height: 40px; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: background var(--dur-normal) var(--ease-standard), + border-color var(--dur-normal) var(--ease-standard), color var(--dur-normal) var(--ease-standard); +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn-sm { + height: 32px; + padding: 0 12px; + font-size: var(--text-xs); +} +.btn-primary { + background: var(--action-bg); + color: var(--action-fg); +} +.btn-primary:not(:disabled):hover { + background: var(--action-bg-hover); +} +.btn-primary:not(:disabled):active { + background: var(--action-bg-active); +} +.btn-secondary { + background: transparent; + border-color: var(--border-strong); + color: var(--text-body); +} +.btn-secondary:not(:disabled):hover { + border-color: var(--accent); + color: var(--text-heading); +} +.btn-ghost { + background: transparent; + color: var(--accent); +} +.btn-ghost:not(:disabled):hover { + color: var(--link-hover); + background: rgba(255, 255, 255, 0.04); +} +.btn-danger { + background: transparent; + border-color: var(--border-strong); + color: var(--lw-danger-fg); +} +.btn-danger:not(:disabled):hover { + border-color: var(--lw-danger-fg); + background: rgba(99, 23, 27, 0.35); +} + +/* ---------------- Field (Input / Select / Textarea) ---------------- */ +.field { + display: flex; + flex-direction: column; + gap: 6px; +} +.field-label { + font-size: var(--text-xs); + font-weight: var(--fw-semibold); + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: var(--ls-wide); +} +.input, +.select, +.textarea { + width: 100%; + font-family: inherit; + font-size: var(--text-sm); + color: var(--text-body); + background: var(--surface-page); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 0 12px; + height: 40px; + transition: border-color var(--dur-normal) var(--ease-standard), + box-shadow var(--dur-normal) var(--ease-standard); +} +.textarea { + height: auto; + padding: 10px 12px; + line-height: var(--lh-normal); + resize: vertical; + min-height: 68px; +} +.input::placeholder, +.textarea::placeholder { + color: var(--text-faint); +} +.input:focus, +.select:focus, +.textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: var(--shadow-focus); +} +.select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23718096' stroke-width='2.5'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + padding-right: 32px; +} +.select option { + background: var(--surface-card); + color: var(--text-body); +} + +/* ---------------- Tabs ---------------- */ +.tabs { + display: inline-flex; + gap: 4px; + background: var(--surface-page); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + padding: 3px; +} +.tab { + font-family: inherit; + font-size: var(--text-sm); + font-weight: var(--fw-semibold); + color: var(--text-muted); + background: transparent; + border: 0; + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + transition: background var(--dur-fast), color var(--dur-fast); +} +.tab:hover { + color: var(--text-body); +} +.tab.active { + background: var(--surface-inset); + color: var(--text-heading); +} + +/* ---------------- Stat ---------------- */ +.stat-value { + font-size: var(--text-2xl); + font-weight: var(--fw-bold); + line-height: 1.1; + color: var(--text-heading); + letter-spacing: var(--ls-tight); +} +.stat-value.accent { + color: var(--accent); +} +.stat-label { + font-size: var(--text-sm); + color: var(--text-muted); + margin-top: 4px; +} +.stat-sub { + font-size: var(--text-xs); + color: var(--text-faint); + font-family: var(--font-mono); +} + +/* ---------------- Callout ---------------- */ +.callout { + border-radius: var(--radius-md); + padding: 12px 14px; + font-size: var(--text-sm); + border: 1px solid transparent; +} +.callout-info { + background: rgba(26, 54, 93, 0.35); + border-color: rgba(144, 205, 244, 0.25); + color: var(--lw-blue-200); +} +.callout-danger { + background: rgba(99, 23, 27, 0.35); + border-color: rgba(254, 178, 178, 0.25); + color: var(--lw-danger-fg); +} +.callout-success { + background: rgba(26, 71, 49, 0.35); + border-color: rgba(154, 230, 180, 0.25); + color: var(--lw-success-fg); +} + +/* ---------------- CodeBlock ---------------- */ +.codeblock { + background: var(--surface-page); + border: 1px solid var(--border-default); + border-radius: var(--radius-md); + overflow: hidden; +} +.codeblock-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 7px 12px; + border-bottom: 1px solid var(--border-default); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-faint); + background: var(--surface-card); +} +.codeblock pre { + margin: 0; + padding: 14px 16px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.55; + color: var(--text-body); +} +.codeblock .diff-add { + color: var(--lw-success-fg); +} +.codeblock .diff-del { + color: var(--lw-danger-fg); +} + +/* ---------------- Toggle ---------------- */ +.toggle { + width: 38px; + height: 22px; + border-radius: var(--radius-full); + background: var(--surface-inset); + border: 0; + position: relative; + cursor: pointer; + flex-shrink: 0; + transition: background var(--dur-normal); + padding: 0; +} +.toggle.on { + background: var(--action-bg); +} +.toggle .knob { + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: left var(--dur-normal); +} +.toggle.on .knob { + left: 18px; +} + +/* ---------------- App shell ---------------- */ +.app { + display: flex; + min-height: 100vh; +} +.sidebar { + width: 216px; + flex-shrink: 0; + border-right: 1px solid var(--border-default); + padding: 20px 12px; + display: flex; + flex-direction: column; + gap: 3px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} +.brand { + display: flex; + align-items: center; + gap: 9px; + font-size: var(--text-md); + font-weight: var(--fw-bold); + color: var(--text-heading); + padding: 2px 10px 16px; +} +.brand .logo { + width: 22px; + height: 22px; + border-radius: 6px; + background: linear-gradient(135deg, #90cdf4, #667eea); + flex-shrink: 0; +} +.nav-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 9px 10px; + border-radius: var(--radius-md); + color: var(--text-muted); + font-size: var(--text-sm); + font-weight: var(--fw-semibold); + background: transparent; + border: 0; + width: 100%; + cursor: pointer; + text-align: left; + transition: background var(--dur-fast), color var(--dur-fast); +} +.nav-item:hover { + background: var(--surface-raised); + color: var(--text-body); +} +.nav-item.active { + background: var(--surface-inset); + color: var(--text-heading); +} +.nav-item .count { + font-size: var(--text-xs); + font-family: var(--font-mono); + color: var(--text-faint); +} +.nav-item.active .count { + color: var(--accent); +} +.sidebar-spacer { + flex: 1; +} +.sidebar-foot { + border-top: 1px solid var(--border-default); + padding-top: 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} +.main-scroll { + flex: 1; + overflow-y: auto; +} + +/* section header */ +.section-head { + padding: 26px 32px 18px; +} +.section-title { + font-size: var(--text-xl); + font-weight: var(--fw-bold); + color: var(--text-heading); + letter-spacing: var(--ls-tight); +} +.section-desc { + font-size: var(--text-sm); + color: var(--text-muted); + margin-top: 4px; +} +.section-body { + padding: 0 32px 40px; + display: flex; + flex-direction: column; + gap: 18px; +} + +/* stat row */ +.stat-row { + display: flex; + background: var(--surface-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + flex-wrap: wrap; +} +.stat-cell { + flex: 1; + min-width: 150px; + padding: 18px 22px; + border-left: 1px solid var(--border-default); +} +.stat-cell:first-child { + border-left: 0; +} + +/* toolbar / form grid */ +.row { + display: flex; + gap: 12px; + align-items: flex-end; + flex-wrap: wrap; +} +.form-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 12px; +} +.grid-2 { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 14px; +} +.grow { + flex: 1; + min-width: 180px; +} +.eyebrow { + font-size: var(--text-xs); + font-weight: var(--fw-semibold); + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: var(--ls-wide); +} + +/* inbox split (Runs) */ +.runs { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} +.runs-stats { + padding: 16px 20px; + border-bottom: 1px solid var(--border-default); + flex-shrink: 0; +} +.split { + display: flex; + flex: 1; + min-height: 0; +} +.split-list { + width: 340px; + flex-shrink: 0; + border-right: 1px solid var(--border-default); + display: flex; + flex-direction: column; + height: 100%; +} +.split-list-head { + padding: 22px 20px 12px; + display: flex; + flex-direction: column; + gap: 12px; +} +.split-list-scroll { + flex: 1; + overflow-y: auto; + padding: 10px; + display: flex; + flex-direction: column; + gap: 4px; +} +.split-detail { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.run-row { + cursor: pointer; + padding: 10px 12px; + border-radius: var(--radius-md); + border-left: 3px solid transparent; + display: flex; + flex-direction: column; + gap: 5px; + background: transparent; + border-top: 0; + border-right: 0; + border-bottom: 0; + width: 100%; + text-align: left; + font: inherit; + color: inherit; + transition: background var(--dur-fast); +} +.run-row:hover { + background: var(--surface-raised); +} +.run-row.selected { + background: var(--surface-inset); + border-left-color: var(--accent); +} +.run-row-top { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.run-project { + color: var(--accent); + font-size: var(--text-sm); + font-weight: var(--fw-semibold); +} + +/* key/value */ +.kv { + display: grid; + grid-template-columns: 150px 1fr; + gap: 8px 16px; + margin: 0; +} +.kv dt { + color: var(--text-faint); + font-size: var(--text-sm); +} +.kv dd { + margin: 0; + color: var(--text-body); + font-size: var(--text-sm); +} +.kv dd ul { + margin: 0; + padding-left: 18px; +} + +/* table */ +.table-wrap { + overflow-x: auto; + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); +} +table.tbl { + width: 100%; + border-collapse: collapse; + font-size: var(--text-sm); +} +table.tbl th { + text-align: left; + font-size: var(--text-xs); + font-weight: var(--fw-semibold); + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: var(--ls-wide); + padding: 11px 14px; + border-bottom: 1px solid var(--border-default); + background: var(--surface-card); + white-space: nowrap; +} +table.tbl td { + padding: 11px 14px; + border-bottom: 1px solid var(--border-default); + color: var(--text-body); + vertical-align: top; +} +table.tbl tr:last-child td { + border-bottom: 0; +} +table.tbl tbody tr:hover { + background: rgba(255, 255, 255, 0.02); +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border-radius: var(--radius-sm); + background: var(--surface-card); + border: 1px solid var(--border-default); + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-body); + cursor: pointer; + transition: border-color var(--dur-fast); +} +.chip:hover { + border-color: var(--border-strong); +} +.chip.selected { + border-color: var(--accent); +} + +.card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.card-title { + color: var(--accent); + font-weight: var(--fw-bold); + font-size: var(--text-lg); +} +.stack { + display: flex; + flex-direction: column; + gap: 10px; +} +.divider { + height: 1px; + background: var(--border-default); + border: 0; + margin: 0; +} +.empty { + color: var(--text-muted); + font-size: var(--text-sm); + padding: 4px 0; +} +.pager { + display: flex; + align-items: center; + gap: 12px; +} + +/* banner */ +.banner { + margin: 0 32px; + padding: 10px 14px; + border-radius: var(--radius-md); + font-size: var(--text-sm); +} +.banner.ok { + background: rgba(26, 71, 49, 0.35); + color: var(--lw-success-fg); + border: 1px solid rgba(154, 230, 180, 0.25); +} +.banner.err { + background: rgba(99, 23, 27, 0.35); + color: var(--lw-danger-fg); + border: 1px solid rgba(254, 178, 178, 0.25); +} + +/* ---------------- Token gate ---------------- */ +.gate { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} +.gate-card { + width: 100%; + max-width: 420px; + background: var(--surface-card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 32px; + display: flex; + flex-direction: column; + gap: 14px; + box-shadow: var(--shadow-lg); +} +.gate-brand { + display: flex; + align-items: center; + gap: 10px; + font-size: var(--text-xl); + font-weight: var(--fw-bold); + color: var(--text-heading); +} + +/* small helpers */ +.hstack { + display: flex; + align-items: center; + gap: 10px; +} +.hstack.wrap { + flex-wrap: wrap; +} +.spacer { + flex: 1; +} +.mt8 { + margin-top: 8px; +} +.mt14 { + margin-top: 14px; +} + +@media (max-width: 860px) { + .split-list { + width: 280px; + } + .grid-2 { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/icon.svg b/frontend/app/icon.svg new file mode 100644 index 0000000..d300948 --- /dev/null +++ b/frontend/app/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..8fc1d73 --- /dev/null +++ b/frontend/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Handler · Claude Activity", + description: "Monitor and manage Claude Code agents across projects.", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx new file mode 100644 index 0000000..5aaf390 --- /dev/null +++ b/frontend/app/page.tsx @@ -0,0 +1,49 @@ +/* Root page: token gate → dashboard. Client-only; the exported HTML is a shell and every + * byte of data is fetched by the browser from the authed API after the token is supplied. */ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { DashboardProvider } from "@/components/store"; +import { Dashboard } from "@/components/Dashboard"; +import { TokenGate } from "@/components/TokenGate"; + +const TOKEN_KEY = "handler_token"; + +export default function Home() { + const [token, setToken] = useState(null); + const [error, setError] = useState(""); + + // Read the stored token after mount (localStorage is client-only). + useEffect(() => { + const stored = window.localStorage.getItem(TOKEN_KEY); + if (stored) setToken(stored); + }, []); + + const saveToken = useCallback((t: string) => { + window.localStorage.setItem(TOKEN_KEY, t); + setError(""); + setToken(t); + }, []); + + const signOut = useCallback(() => { + window.localStorage.removeItem(TOKEN_KEY); + setToken(null); + }, []); + + // A 401 from any call clears the token and re-prompts with an error. + const onUnauthorized = useCallback(() => { + window.localStorage.removeItem(TOKEN_KEY); + setToken(null); + setError("Invalid token — please try again."); + }, []); + + if (!token) { + return ; + } + + return ( + + + + ); +} diff --git a/frontend/components/Dashboard.tsx b/frontend/components/Dashboard.tsx new file mode 100644 index 0000000..2744d5e --- /dev/null +++ b/frontend/components/Dashboard.tsx @@ -0,0 +1,102 @@ +/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git + * Servers / Activity / Shared) and the active section on the right, matching the design's + * hub layout. Command feedback and load errors surface as banners at the top of main. */ +"use client"; + +import { useDashboard, type Section } from "@/components/store"; +import { RunsSection } from "@/components/sections/RunsSection"; +import { RepositoriesSection } from "@/components/sections/RepositoriesSection"; +import { AgentsSection } from "@/components/sections/AgentsSection"; +import { ApprovalsSection } from "@/components/sections/ApprovalsSection"; +import { GitServersSection } from "@/components/sections/GitServersSection"; +import { ActivitySection } from "@/components/sections/ActivitySection"; +import { SharedSection } from "@/components/sections/SharedSection"; + +interface NavDef { + key: Section; + label: string; + count: (s: ReturnType) => number; + accent?: (s: ReturnType) => boolean; +} + +const NAV: NavDef[] = [ + { + key: "runs", + label: "Runs", + count: (s) => s.agents.length, + accent: (s) => s.agents.some((a) => a.status === "paused_for_input"), + }, + { key: "repositories", label: "Repositories", count: (s) => s.projects.length }, + { key: "agents", label: "Agents", count: (s) => s.agents.length }, + { key: "approvals", label: "Approvals", count: (s) => s.approvals.length }, + { key: "servers", label: "Git Servers", count: (s) => s.hosts.length }, + { key: "activity", label: "Activity", count: (s) => s.commands.length }, + { key: "shared", label: "Shared", count: (s) => s.shared.context.length }, +]; + +export function Dashboard({ onSignOut }: { onSignOut: () => void }) { + const s = useDashboard(); + + return ( +
+ + +
+ {s.cmd.text && ( +

+ {s.cmd.text} +

+ )} + {s.lastError && ( +

+ {s.lastError} +

+ )} + + {s.section === "runs" ? ( + + ) : ( +
+ {s.section === "repositories" && } + {s.section === "agents" && } + {s.section === "approvals" && } + {s.section === "servers" && } + {s.section === "activity" && } + {s.section === "shared" && } +
+ )} +
+
+ ); +} diff --git a/frontend/components/TokenGate.tsx b/frontend/components/TokenGate.tsx new file mode 100644 index 0000000..52a18f1 --- /dev/null +++ b/frontend/components/TokenGate.tsx @@ -0,0 +1,48 @@ +/* Token gate: shown until an API token is supplied. Holds no data. Management actions + * (spawn, approve, edit repos/servers) need the admin token; read-only views need the + * plain auth token. The token lives only in localStorage on this device. */ +"use client"; + +import { useState } from "react"; + +export function TokenGate({ error, onSubmit }: { error?: string; onSubmit: (token: string) => void }) { + const [value, setValue] = useState(""); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const t = value.trim(); + if (t) onSubmit(t); + }; + + return ( +
+
+
+ + Claude Monitor +
+

+ Paste your API token to continue. Management actions require the admin token; read-only + views work with the plain auth token. +

+ setValue(e.target.value)} + autoFocus + /> + {error && ( +

+ {error} +

+ )} + +
+
+ ); +} diff --git a/frontend/components/sections/ActivitySection.tsx b/frontend/components/sections/ActivitySection.tsx new file mode 100644 index 0000000..7029f67 --- /dev/null +++ b/frontend/components/sections/ActivitySection.tsx @@ -0,0 +1,63 @@ +/* Activity — the control-command queue: every enqueued action and its status + * (queued → running → done/failed). The audit log of what the dashboard triggered. */ +"use client"; + +import { useDashboard } from "@/components/store"; +import { Button, StatusBadge } from "@/components/ui"; +import { fmtFull } from "@/lib/format"; + +export function ActivitySection() { + const s = useDashboard(); + + return ( + <> +
+
+
+
Activity
+
Control commands the worker drains from the queue.
+
+ +
+
+
+ {s.commands.length === 0 ? ( +
No commands yet.
+ ) : ( +
+ + + + + + + + + + + + + {s.commands.map((c) => ( + + + + + + + + + ))} + +
WhenTypeRepositoryAgentStatusResult / Error
{fmtFull(c.created_at)}{c.type}{c.project_id || "—"}{c.agent_name || "—"} + + + {c.error || (c.result ? JSON.stringify(c.result) : "—")} +
+
+ )} +
+ + ); +} diff --git a/frontend/components/sections/AgentsSection.tsx b/frontend/components/sections/AgentsSection.tsx new file mode 100644 index 0000000..d8661da --- /dev/null +++ b/frontend/components/sections/AgentsSection.tsx @@ -0,0 +1,156 @@ +/* Agents — spawn a new agent into a repository and manage the ones already running. + * Spawning enqueues a control command that the worker turns into a tmux + claude process. */ +"use client"; + +import { useMemo, useState } from "react"; +import { useDashboard } from "@/components/store"; +import { Badge, Button, Card, Input, Select, StatusBadge, Textarea } from "@/components/ui"; +import { fmtFull } from "@/lib/format"; + +const ROLE_OPTS = [ + { value: "", label: "Role — none" }, + { value: "junior", label: "junior" }, + { value: "senior", label: "senior" }, + { value: "deploy", label: "deploy" }, +]; +const PLACEMENT_OPTS = [ + { value: "worktree", label: "git worktree on branch" }, + { value: "subdir", label: "subdir under root" }, +]; + +const emptySpawn = { + name: "", + role: "", + placement: "worktree" as "worktree" | "subdir", + worktree: "", + subdir: "", + task: "", +}; + +export function AgentsSection() { + const s = useDashboard(); + const [form, setForm] = useState(emptySpawn); + + const projectOpts = useMemo( + () => s.projects.map((p) => ({ value: p.id, label: p.id })), + [s.projects], + ); + const agents = useMemo( + () => s.agents.filter((a) => a.project_id === s.selectedProjectId), + [s.agents, s.selectedProjectId], + ); + + const spawn = async () => { + const ok = await s.spawnAgent(form); + if (ok) setForm(emptySpawn); + }; + + return ( + <> +
+
Agents
+
Spawn agents into a repository and manage running sessions.
+
+
+ {s.projects.length === 0 ? ( +
Register a repository first.
+ ) : ( + <> +
+
+ setForm({ ...form, name: v })} placeholder="junior" /> + setForm({ ...form, placement: v as "worktree" | "subdir" })} + options={PLACEMENT_OPTS} + /> + {form.placement === "worktree" ? ( + setForm({ ...form, worktree: v })} placeholder="feat/auth" /> + ) : ( + setForm({ ...form, subdir: v })} placeholder="api" /> + )} +
+
+ -
- -
-
- -

No agents in this project.

-
    - -
- - - -
-

- - - - - -

- - -
-

Checkmark

-

No checkpoint recorded yet.

-
-
Status
-
-
Where it stopped
-
-
Open question
-
-
Next steps
-
-
    - -
- -
-
Tests
-
- - -
-
Build
-
- - -
-
Checkpoint at
-
-
-
- - -
-

Answer this question

-

- -
- - -
-

-
- - -
-

Log (newest first)

-

No log entries.

-
- - - - - - - -
WhenStatusSummaryQ / AVisPushCI
-
-
- - - -
-
-
- - - -
-
-

Approvals

-

Select a project.

-
-

Record a verdict

-
- - - - -
- -
- -
-
-

No approvals recorded.

-
- - - - - -
WhenBranchVerdictBySHANote
-
-
-
- - -
-
-

Projects

-
-

-
- - - - -
-

credential_ref is a pointer, never the token. cmd: is CLI-only.

-
- - -
-
-
- - - - - -
IDroot_dirgit_remotecredential_ref
-
-
-
- - -
-
-

Forge hosts

-

Maps a git host to the token env var to inject at spawn (and the credential-helper scope). - Holds no secrets — only the env-var name.

-
-

-
- - - - -
-
- - -
-
-

No hosts registered (built-in host map still applies).

-
- - - - - -
HostnameTypeToken env varBase URL
-
-
-
- - -
-
-

Activity · control commands - - -

-

No commands yet.

-
- - - - - -
WhenTypeProjectAgentStatusResult / Error
-
-
-
- - -
-
-

Global feed

-

No global log entries.

-
- - - - - -
WhenAgentStatusSummaryCI
-
-
-
-

Shared context

-
-

Set a key

-
- - -
-

Requires the shared-context write token (or admin/global if unset).

-
- -
-
-

No shared context keys.

-
- - - - - -
KeyValueUpdated
-
-
-
-
- - - - +Handler · Claude Activity
Claude Monitor

Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.

\ No newline at end of file diff --git a/src/handler/api/static/index.txt b/src/handler/api/static/index.txt new file mode 100644 index 0000000..7e8908d --- /dev/null +++ b/src/handler/api/static/index.txt @@ -0,0 +1,7 @@ +2:I[9107,[],"ClientPageRoot"] +3:I[3815,["931","static/chunks/app/page-67c3ee7b71a251cf.js"],"default",1] +4:I[4707,[],""] +5:I[6423,[],""] +0:["GQbm47pHcnN5aChqrNLgi",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{"props":{"params":{},"searchParams":{}},"Component":"$3"}],null],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bbd6ee1e7265be74.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L6",null]]]] +6:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"Handler · Claude Activity"}],["$","meta","3",{"name":"description","content":"Monitor and manage Claude Code agents across projects."}],["$","link","4",{"rel":"icon","href":"/icon.svg?bab509b45a421e43","type":"image/svg+xml","sizes":"any"}]] +1:null diff --git a/src/handler/api/static/styles.css b/src/handler/api/static/styles.css deleted file mode 100644 index 6ded846..0000000 --- a/src/handler/api/static/styles.css +++ /dev/null @@ -1,204 +0,0 @@ -/* Handler web UI — plain CSS, no preprocessor. */ - -:root { - --bg: #0f1419; - --panel: #1a2029; - --card: #212936; - --border: #2d3644; - --text: #e6e9ee; - --muted: #8b96a5; - --accent: #4c8dff; - --blue: #4c8dff; - --green: #35c26a; - --amber: #e8a33d; - --red: #e5484d; - --grey: #5a6572; -} - -[x-cloak] { display: none !important; } - -* { box-sizing: border-box; } - -body { - margin: 0; - font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background: var(--bg); - color: var(--text); -} - -h2, h3, h4 { margin: 0 0 0.5rem; font-weight: 600; } -h3 { font-size: 1rem; } -h4 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); } - -.muted { color: var(--muted); } -.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; } -.nowrap { white-space: nowrap; } -.error { color: var(--red); } - -button { - background: var(--card); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.4rem 0.8rem; - cursor: pointer; - font: inherit; -} -button:hover:not(:disabled) { border-color: var(--accent); } -button:disabled { opacity: 0.4; cursor: not-allowed; } -button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } -button.ghost { background: transparent; } - -input, textarea, select { - background: var(--bg); - color: var(--text); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.4rem 0.6rem; - font: inherit; - width: 100%; -} -textarea { resize: vertical; } - -/* --- token modal --- */ -.modal-backdrop { - position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; align-items: center; justify-content: center; - z-index: 50; -} -.modal { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1.5rem; - width: min(360px, 90vw); - display: flex; flex-direction: column; gap: 0.75rem; -} - -/* --- topbar --- */ -.topbar { - display: flex; align-items: center; justify-content: space-between; - gap: 1rem; flex-wrap: wrap; - padding: 0.75rem 1rem; - background: var(--panel); - border-bottom: 1px solid var(--border); -} -.brand { font-weight: 700; font-size: 1.05rem; } -.topbar-controls { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; } -.tabs { display: flex; gap: 0.25rem; } -.tabs button.active { background: var(--accent); border-color: var(--accent); color: #fff; } - -.banner { margin: 0; padding: 0.5rem 1rem; background: rgba(229,72,77,0.12); } -.banner.ok { background: rgba(53,194,106,0.12); color: var(--green); } - -/* --- layout --- */ -.layout { - display: grid; - grid-template-columns: 300px 1fr; - gap: 1rem; - padding: 1rem; - align-items: start; -} -@media (max-width: 780px) { .layout { grid-template-columns: 1fr; } } - -.panel { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - padding: 1rem; -} - -/* --- agent list --- */ -.agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; } -.agent-list li { - display: flex; align-items: center; gap: 0.5rem; - padding: 0.5rem 0.6rem; - border: 1px solid transparent; - border-radius: 6px; - cursor: pointer; -} -.agent-list li:hover { background: var(--card); } -.agent-list li.selected { background: var(--card); border-color: var(--accent); } -.agent-name { flex: 1; font-weight: 500; } -.needs-answer { font-size: 0.7rem; color: var(--amber); text-transform: uppercase; letter-spacing: 0.03em; } - -/* --- detail cards --- */ -.detail { display: flex; flex-direction: column; gap: 1rem; } -.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; } - -.kv { display: grid; grid-template-columns: 130px 1fr; gap: 0.4rem 1rem; margin: 0; } -.kv dt { color: var(--muted); } -.kv dd { margin: 0; } -.next-steps { margin: 0; padding-left: 1.1rem; } - -.answer .question { background: var(--bg); border-left: 3px solid var(--amber); padding: 0.5rem 0.75rem; border-radius: 4px; } -.answer-actions { display: flex; gap: 0.5rem; margin-top: 0.6rem; } -.answer-msg { margin: 0.5rem 0 0; color: var(--green); } -.answer-msg.error { color: var(--red); } - -/* --- tables --- */ -.table-wrap { overflow-x: auto; } -table.log { width: 100%; border-collapse: collapse; } -table.log th, table.log td { - text-align: left; padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border); - vertical-align: top; -} -table.log th { color: var(--muted); font-weight: 500; font-size: 0.8rem; } - -.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.6rem; } - -/* --- badges --- */ -.badge { - display: inline-block; - padding: 0.1rem 0.5rem; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; - border: 1px solid transparent; - white-space: nowrap; -} -/* fallback for unknown/new vocabulary values */ -.badge { background: rgba(90,101,114,0.2); color: var(--grey); } - -.badge-status-working { background: rgba(76,141,255,0.18); color: var(--blue); } -.badge-status-paused_for_input { background: rgba(232,163,61,0.18); color: var(--amber); } -.badge-status-blocked { background: rgba(229,72,77,0.18); color: var(--red); } -.badge-status-done { background: rgba(53,194,106,0.18); color: var(--green); } - -.badge-gate-pass { background: rgba(53,194,106,0.18); color: var(--green); } -.badge-gate-fail { background: rgba(229,72,77,0.18); color: var(--red); } -.badge-gate-unknown { background: rgba(90,101,114,0.2); color: var(--grey); } - -.badge-ci-not_applicable { background: rgba(90,101,114,0.2); color: var(--grey); } -.badge-ci-pending { background: rgba(232,163,61,0.18); color: var(--amber); } -.badge-ci-pass { background: rgba(53,194,106,0.18); color: var(--green); } -.badge-ci-fail { background: rgba(229,72,77,0.18); color: var(--red); } - -.badge-visibility-project { background: rgba(90,101,114,0.2); color: var(--grey); } -.badge-visibility-global { background: transparent; color: var(--blue); border-color: var(--blue); } - -.badge-role-junior { background: rgba(76,141,255,0.14); color: var(--blue); } -.badge-role-senior { background: rgba(232,163,61,0.16); color: var(--amber); } -.badge-role-deploy { background: rgba(53,194,106,0.16); color: var(--green); } - -.badge-approval-approved { background: rgba(53,194,106,0.18); color: var(--green); } -.badge-approval-rejected { background: rgba(229,72,77,0.18); color: var(--red); } - -.badge-cmd-queued { background: rgba(90,101,114,0.2); color: var(--grey); } -.badge-cmd-running { background: rgba(232,163,61,0.18); color: var(--amber); } -.badge-cmd-done { background: rgba(53,194,106,0.18); color: var(--green); } -.badge-cmd-failed { background: rgba(229,72,77,0.18); color: var(--red); } - -/* --- management forms --- */ -.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.5rem; margin-bottom: 0.5rem; } -.toolbar { display: flex; gap: 0.5rem; margin-top: 0.6rem; align-items: center; } -.spacer { flex: 1; } -h3 { display: flex; align-items: center; gap: 0.5rem; } -button.small { padding: 0.2rem 0.55rem; font-size: 0.8rem; } -button.danger { color: var(--red); border-color: var(--border); } -button.danger:hover:not(:disabled) { border-color: var(--red); } -.small { font-size: 0.8rem; } -.card > .form-grid + .muted { margin: 0.25rem 0 0; } -.card code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; } diff --git a/tests/test_api_ui.py b/tests/test_api_ui.py index 039690f..5232792 100644 --- a/tests/test_api_ui.py +++ b/tests/test_api_ui.py @@ -1,14 +1,18 @@ -"""Phase 3 UI serving: the bundled web UI is served same-origin and, critically, is -*additive* — it must not shadow any existing API route, and both the toggle (UI_ENABLED) -and the optional CORS behave as documented. The frontend JS itself has no test runner -(by design) and is verified via the manual e2e walkthrough in docs/PLAN.md. +"""Phase 3 UI serving: the bundled web UI (a Next.js static export) is served same-origin +and, critically, is *additive* — it must not shadow any existing API route, and both the +toggle (UI_ENABLED) and the optional CORS behave as documented. The frontend itself has no +test runner (by design) and is verified via the manual e2e walkthrough in docs/PLAN.md. """ from __future__ import annotations -import pytest +import re +from pathlib import Path + from fastapi.testclient import TestClient +_STATIC_DIR = Path(__file__).resolve().parents[1] / "src" / "handler" / "api" / "static" + def _reset_caches() -> None: from handler import config @@ -41,18 +45,22 @@ def test_index_served_unauthenticated(client): assert "Bearer" not in res.text -@pytest.mark.parametrize( - "path, marker", - [ - ("/static/app.js", "function app("), - ("/static/styles.css", ".badge"), - ("/static/alpine.min.js", "Alpine.js"), - ], -) -def test_static_assets_served_unauthenticated(client, path, marker): - res = client.get(path) # no auth +def test_next_assets_served_unauthenticated(client): + # The export references its hashed bundles under /_next/. Discover one from the shell + # and confirm it's served same-origin without auth (filenames are content-hashed, so + # we can't hardcode a path). + index = client.get("/").text + asset = re.search(r"/_next/static/[^\"']+\.js", index) + assert asset, "index.html should reference a /_next/static JS bundle" + res = client.get(asset.group(0)) # no auth assert res.status_code == 200 - assert marker in res.text + assert res.headers["content-type"].startswith(("application/javascript", "text/javascript")) + + +def test_static_export_is_bundled(): + # The built export ships inside the package tree so `pip install .` bundles it. + assert (_STATIC_DIR / "index.html").is_file() + assert (_STATIC_DIR / "_next").is_dir() # --- the static surface must NOT shadow the API ------------------------------------ @@ -66,7 +74,8 @@ def test_api_routes_not_shadowed(client, auth): res = client.get("/projects", headers=auth) assert res.status_code == 200 assert res.json() == [] - # "/" is an explicit route, not a catch-all: unknown paths still 404 + # The "/" static mount is a fallback, not a catch-all rewrite: a path with no matching + # file still 404s (it does not fall back to index.html), so the API contract is intact. assert client.get("/does-not-exist").status_code == 404 @@ -93,7 +102,7 @@ def test_cors_present_when_configured(env, monkeypatch): def test_ui_disabled_serves_no_shell_but_api_works(env, monkeypatch, auth): client = _fresh_client(monkeypatch, UI_ENABLED="false") assert client.get("/").status_code == 404 - assert client.get("/static/app.js").status_code == 404 + assert client.get("/_next/static/anything.js").status_code == 404 # API is untouched assert client.get("/health").status_code == 200 assert client.get("/projects", headers=auth).status_code == 200