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 <noreply@anthropic.com>
This commit is contained in:
Wyatt
2026-07-10 14:34:43 -04:00
committed by GitHub
parent 6668d14c34
commit 7399315185
45 changed files with 3763 additions and 1223 deletions
+25
View File
@@ -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)
```
+8
View File
@@ -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
+911
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#90cdf4"/>
<stop offset="1" stop-color="#667eea"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="#0f1117"/>
<rect x="6" y="6" width="20" height="20" rx="6" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

+15
View File
@@ -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 (
<html lang="en">
<body>{children}</body>
</html>
);
}
+49
View File
@@ -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<string | null>(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 <TokenGate error={error} onSubmit={saveToken} />;
}
return (
<DashboardProvider token={token} onUnauthorized={onUnauthorized}>
<Dashboard onSignOut={signOut} />
</DashboardProvider>
);
}
+102
View File
@@ -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<typeof useDashboard>) => number;
accent?: (s: ReturnType<typeof useDashboard>) => 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 (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span className="logo" />
Claude Monitor
</div>
{NAV.map((n) => {
const c = n.count(s);
const isAccent = n.accent?.(s) ?? false;
return (
<button
key={n.key}
className={`nav-item${s.section === n.key ? " active" : ""}`}
onClick={() => s.setSection(n.key)}
>
<span>{n.label}</span>
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
{c || ""}
</span>
</button>
);
})}
<div className="sidebar-spacer" />
<div className="sidebar-foot">
<button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span>
<span className="count"></span>
</button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
<span>Sign out</span>
</button>
</div>
</aside>
<main className="main">
{s.cmd.text && (
<p className={`banner ${s.cmd.error ? "err" : "ok"}`} style={{ marginTop: 16 }}>
{s.cmd.text}
</p>
)}
{s.lastError && (
<p className="banner err" style={{ marginTop: 12 }}>
{s.lastError}
</p>
)}
{s.section === "runs" ? (
<RunsSection />
) : (
<div className="main-scroll">
{s.section === "repositories" && <RepositoriesSection />}
{s.section === "agents" && <AgentsSection />}
{s.section === "approvals" && <ApprovalsSection />}
{s.section === "servers" && <GitServersSection />}
{s.section === "activity" && <ActivitySection />}
{s.section === "shared" && <SharedSection />}
</div>
)}
</main>
</div>
);
}
+48
View File
@@ -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 (
<div className="gate">
<form className="gate-card" onSubmit={submit}>
<div className="gate-brand">
<span className="logo" style={{ width: 26, height: 26, borderRadius: 7 }} />
Claude Monitor
</div>
<p className="muted" style={{ fontSize: "var(--text-sm)", margin: 0 }}>
Paste your API token to continue. Management actions require the admin token; read-only
views work with the plain auth token.
</p>
<input
className="input"
type="password"
autoComplete="current-password"
placeholder="API token"
value={value}
onChange={(e) => setValue(e.target.value)}
autoFocus
/>
{error && (
<p className="callout callout-danger" style={{ margin: 0 }}>
{error}
</p>
)}
<button className="btn btn-primary" type="submit">
Continue
</button>
</form>
</div>
);
}
@@ -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 (
<>
<div className="section-head">
<div className="hstack" style={{ justifyContent: "space-between" }}>
<div>
<div className="section-title">Activity</div>
<div className="section-desc">Control commands the worker drains from the queue.</div>
</div>
<Button variant="secondary" disabled={s.cmd.busy} onClick={() => s.pollCi()}>
Sweep CI now
</Button>
</div>
</div>
<div className="section-body">
{s.commands.length === 0 ? (
<div className="empty">No commands yet.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Type</th>
<th>Repository</th>
<th>Agent</th>
<th>Status</th>
<th>Result / Error</th>
</tr>
</thead>
<tbody>
{s.commands.map((c) => (
<tr key={c.id}>
<td className="faint nowrap">{fmtFull(c.created_at)}</td>
<td className="mono">{c.type}</td>
<td className="mono">{c.project_id || "—"}</td>
<td className="mono">{c.agent_name || "—"}</td>
<td>
<StatusBadge status={c.status} />
</td>
<td className="mono faint" style={{ fontSize: "var(--text-xs)" }}>
{c.error || (c.result ? JSON.stringify(c.result) : "—")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
@@ -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 (
<>
<div className="section-head">
<div className="section-title">Agents</div>
<div className="section-desc">Spawn agents into a repository and manage running sessions.</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Spawn an agent
</span>
</div>
<div className="form-grid">
<Input label="Name" value={form.name} onChange={(v) => setForm({ ...form, name: v })} placeholder="junior" />
<Select label="Role" value={form.role} onChange={(v) => setForm({ ...form, role: v })} options={ROLE_OPTS} />
<Select
label="Placement"
value={form.placement}
onChange={(v) => setForm({ ...form, placement: v as "worktree" | "subdir" })}
options={PLACEMENT_OPTS}
/>
{form.placement === "worktree" ? (
<Input label="Branch" value={form.worktree} onChange={(v) => setForm({ ...form, worktree: v })} placeholder="feat/auth" />
) : (
<Input label="Subdir" value={form.subdir} onChange={(v) => setForm({ ...form, subdir: v })} placeholder="api" />
)}
</div>
<div className="mt14">
<Textarea
label="Initial task"
value={form.task}
onChange={(v) => setForm({ ...form, task: v })}
rows={2}
placeholder="initial task / prompt (optional)"
/>
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.name.trim()} onClick={spawn}>
Spawn
</Button>
</div>
</Card>
{agents.length === 0 ? (
<div className="empty">No agents in this repository.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th>Working dir</th>
<th>Created</th>
<th />
</tr>
</thead>
<tbody>
{agents.map((a) => (
<tr key={a.id}>
<td className="mono">{a.name}</td>
<td>{a.role ? <Badge tone="info">{a.role}</Badge> : "—"}</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="mono faint">{a.working_dir}</td>
<td className="faint nowrap">{fmtFull(a.created_at)}</td>
<td className="nowrap">
<div className="hstack">
<Button size="sm" variant="ghost" onClick={() => s.selectRun(a.project_id, a.name)}>
Open
</Button>
<Button size="sm" variant="secondary" onClick={() => s.killAgent(a.project_id, a.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(a.project_id, a.name)}>
Delete
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,115 @@
/* Approvals — record a per-branch verdict (the review gate). A verdict is enqueued as a
* control command so the worker can read the reviewed HEAD and pin the approval. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select, StatusBadge } from "@/components/ui";
import { fmtFull, shortSha } from "@/lib/format";
const STATUS_OPTS = [
{ value: "approved", label: "approve" },
{ value: "rejected", label: "reject" },
];
const empty = { branch: "", status: "approved", agent_name: "", sha: "", note: "" };
export function ApprovalsSection() {
const s = useDashboard();
const [form, setForm] = useState(empty);
const projectOpts = useMemo(
() => s.projects.map((p) => ({ value: p.id, label: p.id })),
[s.projects],
);
const submit = async () => {
await s.submitApproval(form);
setForm(empty);
};
return (
<>
<div className="section-head">
<div className="section-title">Approvals</div>
<div className="section-desc">
A merge is denied unless a standing approval exists made by a different agent, pinned to
the reviewed commit.
</div>
</div>
<div className="section-body">
{s.projects.length === 0 ? (
<div className="empty">Register a repository first.</div>
) : (
<>
<div className="row">
<div style={{ width: 260 }}>
<Select
label="Repository"
value={s.selectedProjectId}
onChange={s.selectProject}
options={projectOpts}
/>
</div>
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Record a verdict
</span>
</div>
<div className="form-grid">
<Input label="Branch" value={form.branch} onChange={(v) => setForm({ ...form, branch: v })} placeholder="feat/auth" />
<Select label="Verdict" value={form.status} onChange={(v) => setForm({ ...form, status: v })} options={STATUS_OPTS} />
<Input label="Agent" value={form.agent_name} onChange={(v) => setForm({ ...form, agent_name: v })} placeholder="reads its HEAD (optional)" />
<Input label="SHA" value={form.sha} onChange={(v) => setForm({ ...form, sha: v })} placeholder="pins the approval (optional)" />
</div>
<div className="mt14">
<Input label="Note" value={form.note} onChange={(v) => setForm({ ...form, note: v })} placeholder="optional" />
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.branch.trim()} onClick={submit}>
Enqueue verdict
</Button>
</div>
</Card>
{s.approvals.length === 0 ? (
<div className="empty">No approvals recorded.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Branch</th>
<th>Verdict</th>
<th>By</th>
<th>SHA</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{s.approvals.map((ap) => (
<tr key={ap.id}>
<td className="faint nowrap">{fmtFull(ap.created_at)}</td>
<td className="mono">{ap.branch}</td>
<td>
<StatusBadge status={ap.status} />
</td>
<td>{ap.approved_by_agent_id ? `agent ${ap.approved_by_agent_id}` : ap.actor || "—"}</td>
<td className="mono">{shortSha(ap.approved_sha)}</td>
<td>{ap.note || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -0,0 +1,129 @@
/* Git Servers — the forge host registry. Each row maps a host to the token env var to
* inject at spawn (and the credential-helper scope). Holds no secrets, only the var name. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input, Select } from "@/components/ui";
import type { Host } from "@/lib/api";
const FORGE_OPTS = [
{ value: "github", label: "github" },
{ value: "gitlab", label: "gitlab" },
{ value: "gitea", label: "gitea" },
{ value: "forgejo", label: "forgejo" },
{ value: "bitbucket", label: "bitbucket" },
];
const empty = { hostname: "", forge_type: "github", token_env_var: "", base_url: "" };
export function GitServersSection() {
const s = useDashboard();
const [form, setForm] = useState(empty);
const [editing, setEditing] = useState(false);
const reset = () => {
setForm(empty);
setEditing(false);
};
const save = async () => {
const ok = editing ? await s.updateHost(form.hostname, form) : await s.createHost(form);
if (ok) reset();
};
const edit = (h: Host) => {
setForm({
hostname: h.hostname,
forge_type: h.forge_type,
token_env_var: h.token_env_var ?? "",
base_url: h.base_url ?? "",
});
setEditing(true);
};
return (
<>
<div className="section-head">
<div className="section-title">Git Servers</div>
<div className="section-desc">
Maps a git host to the token env var injected at spawn. The built-in host map is the
fallback when no row matches.
</div>
</div>
<div className="section-body">
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editing ? `Edit server · ${form.hostname}` : "Add a git server"}
</span>
</div>
<div className="form-grid">
<Input
label="Hostname"
value={form.hostname}
onChange={(v) => setForm({ ...form, hostname: v })}
placeholder="git.corp.internal"
disabled={editing}
/>
<Select
label="Type"
value={form.forge_type}
onChange={(v) => setForm({ ...form, forge_type: v })}
options={FORGE_OPTS}
/>
<Input
label="Token env var"
value={form.token_env_var}
onChange={(v) => setForm({ ...form, token_env_var: v })}
placeholder="GITEA_TOKEN"
/>
<Input
label="Base URL"
value={form.base_url}
onChange={(v) => setForm({ ...form, base_url: v })}
placeholder="https://git.corp.internal (optional)"
/>
</div>
<div className="hstack mt14">
<Button variant="primary" disabled={s.cmd.busy || !form.hostname.trim()} onClick={save}>
{editing ? "Save changes" : "Add server"}
</Button>
{editing && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.hosts.length === 0 && (
<div className="empty">No git servers registered (built-in host map still applies).</div>
)}
{s.hosts.map((h) => (
<Card key={h.hostname}>
<div className="card-head">
<span className="mono" style={{ fontWeight: "var(--fw-bold)", fontSize: "var(--text-lg)", color: "var(--text-heading)" }}>
{h.hostname}
</span>
<div className="hstack">
<Badge tone="info">{h.forge_type}</Badge>
<Button size="sm" variant="secondary" onClick={() => edit(h)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteHost(h.hostname)}>
Remove
</Button>
</div>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 8 }}>
token env {h.token_env_var || "—"}
{h.base_url ? ` · ${h.base_url}` : ""}
</div>
</Card>
))}
</div>
</>
);
}
@@ -0,0 +1,141 @@
/* Repositories — register / edit / remove the projects (repos) Handler manages.
* Maps the design's "Repositories" pane to Handler's project registry. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Card, Input } from "@/components/ui";
import { fmtFull } from "@/lib/format";
import type { Project } from "@/lib/api";
const CRED_HELP = "credential_ref is a pointer, never the token (env: / file: / db:). cmd: is CLI-only.";
const empty = { id: "", root_dir: "", git_remote: "", credential_ref: "" };
export function RepositoriesSection() {
const s = useDashboard();
const [form, setForm] = useState(empty);
const [editing, setEditing] = useState(false);
const agentCount = useMemo(() => {
const m = new Map<string, number>();
for (const a of s.agents) m.set(a.project_id, (m.get(a.project_id) ?? 0) + 1);
return m;
}, [s.agents]);
const reset = () => {
setForm(empty);
setEditing(false);
};
const save = async () => {
const ok = editing
? await s.updateProject(form.id, form)
: await s.createProject(form);
if (ok) reset();
};
const edit = (p: Project) => {
setForm({
id: p.id,
root_dir: p.root_dir,
git_remote: p.git_remote ?? "",
credential_ref: p.credential_ref ?? "",
});
setEditing(true);
};
return (
<>
<div className="section-head">
<div className="section-title">Repositories</div>
<div className="section-desc">
Repos Handler manages. Each carries its own agents, history, and credentials.
</div>
</div>
<div className="section-body">
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
{editing ? `Edit repository · ${form.id}` : "Register a repository"}
</span>
</div>
<div className="form-grid">
<Input
label="ID / slug"
value={form.id}
onChange={(v) => setForm({ ...form, id: v })}
placeholder="leeworks-api"
disabled={editing}
/>
<Input
label="Root dir"
value={form.root_dir}
onChange={(v) => setForm({ ...form, root_dir: v })}
placeholder="/var/lib/handler/projects/leeworks"
/>
<Input
label="Git remote"
value={form.git_remote}
onChange={(v) => setForm({ ...form, git_remote: v })}
placeholder="git@github.com:user/repo.git (optional)"
/>
<Input
label="Credential ref"
value={form.credential_ref}
onChange={(v) => setForm({ ...form, credential_ref: v })}
placeholder="env:VAR / file:/path / db:id"
/>
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
{CRED_HELP}
</p>
<div className="hstack mt14">
<Button
variant="primary"
disabled={s.cmd.busy || !form.id.trim() || !form.root_dir.trim()}
onClick={save}
>
{editing ? "Save changes" : "Register"}
</Button>
{editing && (
<Button variant="ghost" onClick={reset}>
Cancel
</Button>
)}
</div>
</Card>
{s.projects.length === 0 && <div className="empty">No repositories registered.</div>}
{s.projects.map((p) => (
<Card key={p.id}>
<div className="card-head">
<span className="card-title">{p.id}</span>
<Badge tone="info" pill>
{agentCount.get(p.id) ?? 0} {(agentCount.get(p.id) ?? 0) === 1 ? "agent" : "agents"}
</Badge>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)", marginTop: 4 }}>
{p.root_dir}
{p.git_remote ? ` · ${p.git_remote}` : ""}
</div>
<div className="hstack" style={{ marginTop: 12, justifyContent: "space-between" }}>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
cred {p.credential_ref || "—"} · added {fmtFull(p.created_at)}
</span>
<div className="hstack">
<Button size="sm" variant="secondary" onClick={() => edit(p)}>
Edit
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteProject(p.id)}>
Remove
</Button>
</div>
</div>
</Card>
))}
</div>
</>
);
}
@@ -0,0 +1,306 @@
/* Runs — the inbox: a flat list of every agent across every project on the left, the
* selected agent's checkmark + log + answer/resume flow on the right. Maps the design's
* "Runs" pane to Handler's agent / checkmark / log model. */
"use client";
import { useMemo, useState } from "react";
import { useDashboard } from "@/components/store";
import { Badge, Button, Callout, Stat, StatusBadge, Tabs, Textarea } from "@/components/ui";
import { fmtFull, shortSha, statusTone, timeAgo } from "@/lib/format";
import type { Agent } from "@/lib/api";
const FILTERS = [
{ value: "all", label: "All" },
{ value: "needs", label: "Needs Input" },
{ value: "working", label: "Working" },
{ value: "done", label: "Done" },
];
function matches(filter: string, status: string): boolean {
if (filter === "all") return true;
if (filter === "needs") return status === "paused_for_input";
if (filter === "working") return status === "working" || status === "running";
if (filter === "done") return status === "done" || status === "completed";
return true;
}
export function RunsSection() {
const s = useDashboard();
const [filter, setFilter] = useState("all");
const runs = useMemo(() => {
const list = s.agents.filter((a) => matches(filter, a.status));
return [...list].sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
}, [s.agents, filter]);
const selected = s.selectedRun;
const needs = s.agents.filter((a) => a.status === "paused_for_input").length;
const working = s.agents.filter((a) => a.status === "working" || a.status === "running").length;
return (
<div className="runs">
<div className="runs-stats">
<div className="stat-row">
<div className="stat-cell">
<Stat value={s.agents.length} label="Runs tracked" />
</div>
<div className="stat-cell">
<Stat value={needs} label="Needs input" accent />
</div>
<div className="stat-cell">
<Stat value={working} label="Working" />
</div>
<div className="stat-cell">
<Stat value={s.projects.length} label="Repositories" />
</div>
</div>
</div>
<div className="split">
<div className="split-list">
<div className="split-list-head">
<div className="section-title" style={{ fontSize: "var(--text-lg)" }}>
Runs
</div>
<Tabs tabs={FILTERS} value={filter} onChange={setFilter} />
</div>
<div className="split-list-scroll">
{runs.length === 0 && <Callout tone="info">No runs match this filter.</Callout>}
{runs.map((a) => (
<RunRow
key={`${a.project_id}/${a.name}`}
agent={a}
selected={selected?.projectId === a.project_id && selected?.name === a.name}
onSelect={() => s.selectRun(a.project_id, a.name)}
/>
))}
</div>
</div>
<div className="split-detail">
{selected ? <RunDetail /> : <RunEmpty />}
</div>
</div>
</div>
);
}
function RunRow({
agent,
selected,
onSelect,
}: {
agent: Agent;
selected: boolean;
onSelect: () => void;
}) {
return (
<button className={`run-row${selected ? " selected" : ""}`} onClick={onSelect}>
<div className="run-row-top">
<span className="run-project">{agent.project_id}</span>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{timeAgo(agent.created_at)}
</span>
</div>
<div className="truncate muted" style={{ fontSize: "var(--text-sm)" }}>
{agent.name}
{agent.role ? ` · ${agent.role}` : ""}
</div>
<div className="hstack" style={{ gap: 8 }}>
<StatusBadge status={agent.status} />
</div>
</button>
);
}
function RunEmpty() {
return (
<div style={{ padding: "60px 32px", color: "var(--text-muted)" }}>
Select a run to see its checkmark, log, and any open question.
</div>
);
}
function RunDetail() {
const s = useDashboard();
const run = s.selectedRun!;
const agent = s.agents.find((a) => a.project_id === run.projectId && a.name === run.name);
const cm = s.checkmark;
const [answer, setAnswer] = useState("");
const [busy, setBusy] = useState(false);
const isPaused = agent?.status === "paused_for_input";
const doAnswer = async (resume: boolean) => {
if (!answer.trim()) return;
setBusy(true);
const ok = await s.submitAnswer(answer.trim(), resume);
setBusy(false);
if (ok) setAnswer("");
};
return (
<>
<div
style={{
padding: "24px 28px",
borderBottom: "1px solid var(--border-default)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<div className="hstack">
<span style={{ color: "var(--accent)", fontWeight: "var(--fw-bold)", fontSize: "var(--text-xl)" }}>
{run.projectId}
</span>
<span className="faint">/</span>
<span style={{ color: "var(--text-heading)", fontWeight: "var(--fw-semibold)", fontSize: "var(--text-lg)" }}>
{run.name}
</span>
<StatusBadge status={agent?.status} />
{agent?.role && <Badge tone="info">{agent.role}</Badge>}
<span className="spacer" />
<Button size="sm" variant="secondary" onClick={() => s.killAgent(run.projectId, run.name)}>
Kill
</Button>
<Button size="sm" variant="danger" onClick={() => s.deleteAgent(run.projectId, run.name)}>
Delete row
</Button>
</div>
<div className="mono faint" style={{ fontSize: "var(--text-xs)" }}>
{agent?.working_dir ?? "—"} · created {fmtFull(agent?.created_at)}
</div>
</div>
<div style={{ padding: "20px 28px", display: "flex", flexDirection: "column", gap: 16 }}>
{/* Checkmark */}
<div>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Checkmark
</div>
{s.checkmarkMissing && <Callout tone="info">No checkpoint recorded yet.</Callout>}
{cm && !s.checkmarkMissing && (
<dl className="kv">
<dt>Status</dt>
<dd>
<StatusBadge status={cm.status} />
</dd>
<dt>Where it stopped</dt>
<dd>{cm.where_it_stopped || "—"}</dd>
<dt>Open question</dt>
<dd>{cm.open_question || "—"}</dd>
<dt>Next steps</dt>
<dd>
{cm.next_steps && cm.next_steps.length > 0 ? (
<ul>
{cm.next_steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
) : (
"—"
)}
</dd>
<dt>Tests</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.tests_status)}>{cm.tests_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.tested_at ? fmtFull(cm.tested_at) : ""}
</span>
</dd>
<dt>Build</dt>
<dd className="hstack">
<Badge tone={statusTone(cm.build_status)}>{cm.build_status}</Badge>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
{cm.built_at ? fmtFull(cm.built_at) : ""}
</span>
</dd>
<dt>Checkpoint at</dt>
<dd className="faint">{fmtFull(cm.checkpoint_at)}</dd>
</dl>
)}
</div>
{/* Answer / resume */}
{isPaused && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Answer this question</div>
<Callout tone="danger">{cm?.open_question || "(no question text on the checkmark)"}</Callout>
<Textarea value={answer} onChange={setAnswer} rows={3} placeholder="Your answer…" />
<div className="hstack">
<Button variant="secondary" disabled={busy || !answer.trim()} onClick={() => doAnswer(false)}>
Answer
</Button>
<Button variant="primary" disabled={busy || !answer.trim()} onClick={() => doAnswer(true)}>
Answer &amp; Resume
</Button>
</div>
</div>
)}
{/* Log */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div className="eyebrow">Log · newest first</div>
{s.log.length === 0 ? (
<div className="empty">No log entries.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Status</th>
<th>Summary</th>
<th>Q / A</th>
<th>Push</th>
<th>CI</th>
</tr>
</thead>
<tbody>
{s.log.map((e) => (
<tr key={e.id}>
<td className="faint nowrap">{fmtFull(e.created_at)}</td>
<td>
<StatusBadge status={e.status} />
</td>
<td>{e.summary || "—"}</td>
<td>
{e.question && (
<div>
<strong>Q:</strong> {e.question}
</div>
)}
{e.answer && (
<div>
<strong>A:</strong> {e.answer}
</div>
)}
{!e.question && !e.answer && "—"}
</td>
<td className="mono">{shortSha(e.push_sha)}</td>
<td>
<Badge tone={statusTone(e.ci_status)}>{e.ci_status}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="pager">
<Button size="sm" variant="ghost" disabled={s.logOffset === 0} onClick={() => s.pageLog(-1)}>
Newer
</Button>
<span className="faint mono" style={{ fontSize: "var(--text-xs)" }}>
offset {s.logOffset}
</span>
<Button size="sm" variant="ghost" disabled={s.log.length < 100} onClick={() => s.pageLog(1)}>
Older
</Button>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,114 @@
/* Shared — the cross-project global feed and the shared key/value context store.
* Writing a context key needs the higher-trust shared-write token. */
"use client";
import { useState } from "react";
import { useDashboard } from "@/components/store";
import { Button, Card, Input, StatusBadge } from "@/components/ui";
import { fmtFull } from "@/lib/format";
export function SharedSection() {
const s = useDashboard();
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const set = async () => {
if (!key.trim() || !value.trim()) return;
const ok = await s.setSharedKey(key.trim(), value.trim());
if (ok) {
setKey("");
setValue("");
}
};
return (
<>
<div className="section-head">
<div className="section-title">Shared</div>
<div className="section-desc">The cross-project global feed and shared facts.</div>
</div>
<div className="section-body">
<div>
<div className="eyebrow" style={{ marginBottom: 10 }}>
Global feed
</div>
{s.shared.log.length === 0 ? (
<div className="empty">No global log entries.</div>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>When</th>
<th>Agent</th>
<th>Status</th>
<th>Summary</th>
<th>CI</th>
</tr>
</thead>
<tbody>
{s.shared.log.map((e) => (
<tr key={e.id}>
<td className="faint nowrap">{fmtFull(e.created_at)}</td>
<td className="mono">{e.agent_id}</td>
<td>
<StatusBadge status={e.status} />
</td>
<td>{e.summary || "—"}</td>
<td>
<StatusBadge status={e.ci_status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<Card>
<div className="card-head" style={{ marginBottom: 14 }}>
<span className="card-title" style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}>
Set a shared key
</span>
</div>
<div className="form-grid">
<Input label="Key" value={key} onChange={setKey} placeholder="key" />
<Input label="Value" value={value} onChange={setValue} placeholder="value" />
</div>
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
Requires the shared-context write token (or admin/global if unset).
</p>
<div className="hstack mt14">
<Button variant="primary" disabled={!key.trim() || !value.trim()} onClick={set}>
Set
</Button>
</div>
</Card>
{s.shared.context.length > 0 && (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>Key</th>
<th>Value</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{s.shared.context.map((c) => (
<tr key={c.key}>
<td className="mono">{c.key}</td>
<td>{c.value}</td>
<td className="faint nowrap">{fmtFull(c.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+665
View File
@@ -0,0 +1,665 @@
/* Dashboard state: one store owns the API client, the loaded data for every section,
* the 5s polling loop, and all mutating actions. Control actions enqueue a command and
* poll it to done/failed, surfacing the outcome in the command banner (`cmd`). */
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
AuthError,
createClient,
type Agent,
type ApiError,
type Approval,
type Checkmark,
type Command,
type Host,
type LogEntry,
type Project,
type SharedContext,
} from "@/lib/api";
export type Section =
| "runs"
| "repositories"
| "agents"
| "approvals"
| "servers"
| "activity"
| "shared";
export interface RunAgent extends Agent {}
export interface CmdState {
text: string;
error: boolean;
busy: boolean;
}
const LOG_LIMIT = 100;
const POLL_MS = 5000;
interface StoreValue {
section: Section;
setSection: (s: Section) => void;
projects: Project[];
agents: RunAgent[]; // every agent across every project
selectedProjectId: string;
selectProject: (id: string) => void;
// Runs inbox
selectedRun: { projectId: string; name: string } | null;
selectRun: (projectId: string, name: string) => void;
checkmark: Checkmark | null;
checkmarkMissing: boolean;
log: LogEntry[];
logOffset: number;
pageLog: (dir: 1 | -1) => void;
approvals: Approval[];
hosts: Host[];
commands: Command[];
shared: { log: LogEntry[]; context: SharedContext[] };
cmd: CmdState;
lastError: string;
loading: boolean;
refresh: () => void;
// actions
spawnAgent: (body: SpawnBody) => Promise<boolean>;
killAgent: (projectId: string, name: string) => Promise<void>;
deleteAgent: (projectId: string, name: string) => Promise<void>;
submitAnswer: (answer: string, resume: boolean) => Promise<boolean>;
createProject: (b: ProjectBody) => Promise<boolean>;
updateProject: (id: string, b: Omit<ProjectBody, "id">) => Promise<boolean>;
deleteProject: (id: string) => Promise<void>;
submitApproval: (b: ApprovalBody) => Promise<void>;
createHost: (b: HostBody) => Promise<boolean>;
updateHost: (hostname: string, b: Omit<HostBody, "hostname">) => Promise<boolean>;
deleteHost: (hostname: string) => Promise<void>;
pollCi: () => Promise<void>;
setSharedKey: (key: string, value: string) => Promise<boolean>;
}
export interface SpawnBody {
name: string;
role: string;
placement: "worktree" | "subdir";
worktree: string;
subdir: string;
task: string;
}
export interface ProjectBody {
id: string;
root_dir: string;
git_remote: string;
credential_ref: string;
}
export interface ApprovalBody {
branch: string;
status: string;
agent_name: string;
sha: string;
note: string;
}
export interface HostBody {
hostname: string;
forge_type: string;
token_env_var: string;
base_url: string;
}
const Ctx = createContext<StoreValue | null>(null);
export function useDashboard(): StoreValue {
const v = useContext(Ctx);
if (!v) throw new Error("useDashboard outside provider");
return v;
}
export function DashboardProvider({
token,
onUnauthorized,
children,
}: {
token: string;
onUnauthorized: () => void;
children: ReactNode;
}) {
const client = useMemo(() => createClient(token, onUnauthorized), [token, onUnauthorized]);
const clientRef = useRef(client);
clientRef.current = client;
const [section, setSectionRaw] = useState<Section>("runs");
const [projects, setProjects] = useState<Project[]>([]);
const [agents, setAgents] = useState<RunAgent[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
const [selectedRun, setSelectedRun] = useState<{ projectId: string; name: string } | null>(null);
const [checkmark, setCheckmark] = useState<Checkmark | null>(null);
const [checkmarkMissing, setCheckmarkMissing] = useState(false);
const [log, setLog] = useState<LogEntry[]>([]);
const [logOffset, setLogOffset] = useState(0);
const [approvals, setApprovals] = useState<Approval[]>([]);
const [hosts, setHosts] = useState<Host[]>([]);
const [commands, setCommands] = useState<Command[]>([]);
const [shared, setShared] = useState<{ log: LogEntry[]; context: SharedContext[] }>({
log: [],
context: [],
});
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
const [lastError, setLastError] = useState("");
const [loading, setLoading] = useState(true);
// Keep polling loop reading fresh values without re-subscribing every render.
const sectionRef = useRef(section);
sectionRef.current = section;
const selectedProjectRef = useRef(selectedProjectId);
selectedProjectRef.current = selectedProjectId;
const selectedRunRef = useRef(selectedRun);
selectedRunRef.current = selectedRun;
const logOffsetRef = useRef(logOffset);
logOffsetRef.current = logOffset;
const swallow = (e: unknown) => {
if (!(e instanceof AuthError)) setLastError((e as Error).message);
};
const loadProjects = useCallback(async () => {
try {
const ps = await clientRef.current.api<Project[]>("/projects");
setProjects(ps);
setLastError("");
setSelectedProjectId((cur) => cur || (ps[0]?.id ?? ""));
} catch (e) {
swallow(e);
}
}, []);
const loadAgents = useCallback(async (projectList: Project[]) => {
try {
const results = await Promise.all(
projectList.map((p) =>
clientRef.current
.api<Agent[]>(`/projects/${encodeURIComponent(p.id)}/agents`)
.catch(() => [] as Agent[]),
),
);
setAgents(results.flat());
} catch (e) {
swallow(e);
}
}, []);
const loadRun = useCallback(async (projectId: string, name: string) => {
const path = `/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(name)}`;
try {
const cm = await clientRef.current.api<Checkmark>(`${path}/checkmark`);
setCheckmark(cm);
setCheckmarkMissing(false);
} catch (e) {
if (e instanceof AuthError) return;
if ((e as ApiError).status === 404) {
setCheckmark(null);
setCheckmarkMissing(true);
} else swallow(e);
}
try {
const entries = await clientRef.current.api<LogEntry[]>(
`${path}/log?limit=${LOG_LIMIT}&offset=${logOffsetRef.current}`,
);
setLog(entries);
} catch (e) {
swallow(e);
}
}, []);
const loadApprovals = useCallback(async (projectId: string) => {
if (!projectId) {
setApprovals([]);
return;
}
try {
setApprovals(
await clientRef.current.api<Approval[]>(
`/projects/${encodeURIComponent(projectId)}/approvals`,
),
);
} catch (e) {
swallow(e);
}
}, []);
const loadHosts = useCallback(async () => {
try {
setHosts(await clientRef.current.api<Host[]>("/hosts"));
} catch (e) {
swallow(e);
}
}, []);
const loadCommands = useCallback(async () => {
try {
setCommands(await clientRef.current.api<Command[]>("/commands?limit=50"));
} catch (e) {
swallow(e);
}
}, []);
const loadShared = useCallback(async () => {
try {
const [logRows, context] = await Promise.all([
clientRef.current.api<LogEntry[]>("/shared/log"),
clientRef.current.api<SharedContext[]>("/shared/context"),
]);
setShared({ log: logRows, context });
} catch (e) {
swallow(e);
}
}, []);
/* One refresh cycle for whatever section is active (plus always-cheap projects/agents
* so the nav counts and inbox stay live). */
const tick = useCallback(async () => {
const ps = await clientRef.current
.api<Project[]>("/projects")
.catch((e) => {
swallow(e);
return null;
});
if (ps) {
setProjects(ps);
setSelectedProjectId((cur) => cur || (ps[0]?.id ?? ""));
await loadAgents(ps);
}
const s = sectionRef.current;
const run = selectedRunRef.current;
if (run) await loadRun(run.projectId, run.name);
if (s === "approvals") await loadApprovals(selectedProjectRef.current);
if (s === "servers") await loadHosts();
if (s === "activity") await loadCommands();
if (s === "shared") await loadShared();
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadShared]);
// Initial load + polling loop. The first tick populates projects *and* agents (and the
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
useEffect(() => {
let alive = true;
(async () => {
setLoading(true);
await tick();
if (alive) setLoading(false);
})();
const id = setInterval(() => {
if (!document.hidden) void tick();
}, POLL_MS);
return () => {
alive = false;
clearInterval(id);
};
}, [tick]);
const setSection = useCallback(
(s: Section) => {
setSectionRaw(s);
setCmd({ text: "", error: false, busy: false });
if (s === "approvals") void loadApprovals(selectedProjectRef.current);
if (s === "servers") void loadHosts();
if (s === "activity") void loadCommands();
if (s === "shared") void loadShared();
},
[loadApprovals, loadHosts, loadCommands, loadShared],
);
const selectProject = useCallback(
(id: string) => {
setSelectedProjectId(id);
if (sectionRef.current === "approvals") void loadApprovals(id);
},
[loadApprovals],
);
const selectRun = useCallback(
(projectId: string, name: string) => {
setSelectedRun({ projectId, name });
setLogOffset(0);
logOffsetRef.current = 0;
setCheckmark(null);
setCheckmarkMissing(false);
setLog([]);
void loadRun(projectId, name);
},
[loadRun],
);
const pageLog = useCallback(
(dir: 1 | -1) => {
const next = Math.max(0, logOffset + dir * LOG_LIMIT);
if (next === logOffset) return;
setLogOffset(next);
logOffsetRef.current = next;
const run = selectedRunRef.current;
if (run) void loadRun(run.projectId, run.name);
},
[logOffset, loadRun],
);
const refresh = useCallback(() => {
void tick();
}, [tick]);
// ---- control actions (enqueue + track) ----
const enqueueAndTrack = useCallback(
async (path: string, body: unknown, label: string): Promise<Command | null> => {
setCmd({ text: `${label}: queued…`, error: false, busy: true });
try {
const command = await clientRef.current.api<Command>(path, { method: "POST", body });
const final = await clientRef.current.trackCommand(command.id);
if (!final) {
setCmd({
text: `${label}: still running (see Activity). Is the worker up?`,
error: false,
busy: false,
});
return null;
}
const ok = final.status === "done";
const detail = final.error || (final.result ? JSON.stringify(final.result) : "");
setCmd({
text: `${label} ${ok ? "done" : "failed"}${detail ? " — " + detail : ""}`,
error: !ok,
busy: false,
});
return final;
} catch (e) {
if (e instanceof AuthError) return null;
setCmd({ text: `${label} failed: ${(e as Error).message}`, error: true, busy: false });
return null;
}
},
[],
);
const spawnAgent = useCallback(
async (f: SpawnBody) => {
const body: Record<string, unknown> = {
name: f.name.trim(),
role: f.role || null,
task: f.task.trim() || null,
};
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
const p = encodeURIComponent(selectedProjectRef.current);
const final = await enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
await loadAgents(projects);
return final?.status === "done";
},
[enqueueAndTrack, loadAgents, projects],
);
const killAgent = useCallback(
async (projectId: string, name: string) => {
const p = encodeURIComponent(projectId);
await enqueueAndTrack(`/projects/${p}/agents/${encodeURIComponent(name)}/kill`, undefined, `kill ${name}`);
await loadAgents(projects);
},
[enqueueAndTrack, loadAgents, projects],
);
const deleteAgent = useCallback(
async (projectId: string, name: string) => {
const p = encodeURIComponent(projectId);
try {
await clientRef.current.api(`/projects/${p}/agents/${encodeURIComponent(name)}`, {
method: "DELETE",
});
setCmd({ text: `agent '${name}' row deleted`, error: false, busy: false });
if (selectedRunRef.current?.name === name) setSelectedRun(null);
await loadAgents(projects);
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadAgents, projects],
);
const submitAnswer = useCallback(
async (answer: string, resume: boolean) => {
const run = selectedRunRef.current;
if (!run) return false;
const path = `/projects/${encodeURIComponent(run.projectId)}/agents/${encodeURIComponent(run.name)}`;
try {
await clientRef.current.api(`${path}/answer`, { method: "POST", body: { answer } });
if (resume) {
await enqueueAndTrack(`${path}/resume`, { answer }, "resume");
} else {
setCmd({ text: "Answer saved (agent still paused).", error: false, busy: false });
}
await loadAgents(projects);
await loadRun(run.projectId, run.name);
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[enqueueAndTrack, loadAgents, loadRun, projects],
);
const createProject = useCallback(
async (b: ProjectBody) => {
try {
await clientRef.current.api("/projects", {
method: "POST",
body: {
id: b.id.trim(),
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
},
});
setCmd({ text: `repository '${b.id}' registered`, error: false, busy: false });
await loadProjects();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadProjects],
);
const updateProject = useCallback(
async (id: string, b: Omit<ProjectBody, "id">) => {
try {
await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, {
method: "PATCH",
body: {
root_dir: b.root_dir.trim(),
git_remote: b.git_remote.trim() || null,
credential_ref: b.credential_ref.trim() || null,
},
});
setCmd({ text: `repository '${id}' updated`, error: false, busy: false });
await loadProjects();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadProjects],
);
const deleteProject = useCallback(
async (id: string) => {
try {
await clientRef.current.api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" });
setCmd({ text: `repository '${id}' removed`, error: false, busy: false });
setSelectedProjectId((cur) => (cur === id ? "" : cur));
await loadProjects();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadProjects],
);
const submitApproval = useCallback(
async (b: ApprovalBody) => {
const p = encodeURIComponent(selectedProjectRef.current);
await enqueueAndTrack(
`/projects/${p}/approvals`,
{
branch: b.branch.trim(),
status: b.status,
agent_name: b.agent_name.trim() || null,
sha: b.sha.trim() || null,
note: b.note.trim() || null,
},
`${b.status} ${b.branch}`,
);
await loadApprovals(selectedProjectRef.current);
},
[enqueueAndTrack, loadApprovals],
);
const createHost = useCallback(
async (b: HostBody) => {
try {
await clientRef.current.api("/hosts", {
method: "POST",
body: {
hostname: b.hostname.trim(),
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
},
});
setCmd({ text: `git server '${b.hostname}' added`, error: false, busy: false });
await loadHosts();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadHosts],
);
const updateHost = useCallback(
async (hostname: string, b: Omit<HostBody, "hostname">) => {
try {
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, {
method: "PATCH",
body: {
forge_type: b.forge_type,
token_env_var: b.token_env_var.trim() || null,
base_url: b.base_url.trim() || null,
},
});
setCmd({ text: `git server '${hostname}' updated`, error: false, busy: false });
await loadHosts();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadHosts],
);
const deleteHost = useCallback(
async (hostname: string) => {
try {
await clientRef.current.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "DELETE" });
setCmd({ text: `git server '${hostname}' removed`, error: false, busy: false });
await loadHosts();
} catch (e) {
if (e instanceof AuthError) return;
setCmd({ text: (e as Error).message, error: true, busy: false });
}
},
[loadHosts],
);
const pollCi = useCallback(async () => {
await enqueueAndTrack("/poll-ci", undefined, "poll-ci (all projects)");
await loadCommands();
}, [enqueueAndTrack, loadCommands]);
const setSharedKey = useCallback(
async (key: string, value: string) => {
try {
await clientRef.current.api(`/shared/context/${encodeURIComponent(key)}`, {
method: "PUT",
body: { value },
});
setCmd({ text: `shared context '${key}' set`, error: false, busy: false });
await loadShared();
return true;
} catch (e) {
if (e instanceof AuthError) return false;
setCmd({ text: (e as Error).message, error: true, busy: false });
return false;
}
},
[loadShared],
);
const value: StoreValue = {
section,
setSection,
projects,
agents,
selectedProjectId,
selectProject,
selectedRun,
selectRun,
checkmark,
checkmarkMissing,
log,
logOffset,
pageLog,
approvals,
hosts,
commands,
shared,
cmd,
lastError,
loading,
refresh,
spawnAgent,
killAgent,
deleteAgent,
submitAnswer,
createProject,
updateProject,
deleteProject,
submitApproval,
createHost,
updateHost,
deleteHost,
pollCi,
setSharedKey,
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
+282
View File
@@ -0,0 +1,282 @@
/* Design-system primitives ported from the Leeworks kit: flat, dark, border-led.
* Every value rendered here comes from the API and is placed via React children /
* textContent — never dangerouslySetInnerHTML — so agent-authored strings stay inert. */
"use client";
import type { ReactNode, ChangeEvent } from "react";
import type { Tone } from "@/lib/format";
import { statusLabel, statusTone } from "@/lib/format";
export function Badge({
tone = "neutral",
pill = false,
dot = false,
children,
}: {
tone?: Tone;
pill?: boolean;
dot?: boolean;
children: ReactNode;
}) {
return (
<span className={`badge badge-${tone}${pill ? " pill" : ""}`}>
{dot && <span className="dot" />}
{children}
</span>
);
}
/** Status badge that maps a raw handler status string to a tone + tidy label. */
export function StatusBadge({ status }: { status: string | null | undefined }) {
return <Badge tone={statusTone(status)}>{statusLabel(status)}</Badge>;
}
export function Card({
children,
interactive = false,
onClick,
className = "",
}: {
children: ReactNode;
interactive?: boolean;
onClick?: () => void;
className?: string;
}) {
return (
<div
className={`card${interactive ? " interactive" : ""} ${className}`.trim()}
onClick={onClick}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
>
{children}
</div>
);
}
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "secondary",
size = "md",
onClick,
disabled,
type = "button",
children,
}: {
variant?: ButtonVariant;
size?: "md" | "sm";
onClick?: () => void;
disabled?: boolean;
type?: "button" | "submit";
children: ReactNode;
}) {
return (
<button
type={type}
className={`btn btn-${variant}${size === "sm" ? " btn-sm" : ""}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
export function Field({ label, children }: { label?: string; children: ReactNode }) {
return (
<label className="field">
{label && <span className="field-label">{label}</span>}
{children}
</label>
);
}
export function Input({
label,
value,
onChange,
placeholder,
type = "text",
disabled,
}: {
label?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
type?: string;
disabled?: boolean;
}) {
return (
<Field label={label}>
<input
className="input"
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(e.target.value)}
/>
</Field>
);
}
export function Textarea({
label,
value,
onChange,
placeholder,
rows = 3,
}: {
label?: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
rows?: number;
}) {
return (
<Field label={label}>
<textarea
className="textarea"
value={value}
rows={rows}
placeholder={placeholder}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
/>
</Field>
);
}
export function Select({
label,
value,
onChange,
options,
}: {
label?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Field label={label}>
<select
className="select"
value={value}
onChange={(e: ChangeEvent<HTMLSelectElement>) => onChange(e.target.value)}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
);
}
export function Tabs({
tabs,
value,
onChange,
}: {
tabs: { value: string; label: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="tabs" role="tablist">
{tabs.map((t) => (
<button
key={t.value}
role="tab"
aria-selected={value === t.value}
className={`tab${value === t.value ? " active" : ""}`}
onClick={() => onChange(t.value)}
>
{t.label}
</button>
))}
</div>
);
}
export function Stat({
value,
label,
sub,
accent = false,
}: {
value: ReactNode;
label: string;
sub?: string;
accent?: boolean;
}) {
return (
<div>
<div className={`stat-value${accent ? " accent" : ""}`}>{value}</div>
<div className="stat-label">{label}</div>
{sub && <div className="stat-sub">{sub}</div>}
</div>
);
}
export function Callout({
tone = "info",
children,
}: {
tone?: "info" | "danger" | "success";
children: ReactNode;
}) {
return <div className={`callout callout-${tone}`}>{children}</div>;
}
/** Renders a unified-diff / patch, tinting +/- lines. Content is textContent. */
export function CodeBlock({
code,
language,
title,
}: {
code: string;
language?: string;
title?: string;
}) {
const lines = code.split("\n");
return (
<div className="codeblock">
{(title || language) && (
<div className="codeblock-head">
<span>{title ?? ""}</span>
<span>{language ?? ""}</span>
</div>
)}
<pre>
{lines.map((line, i) => {
const cls = line.startsWith("+")
? "diff-add"
: line.startsWith("-")
? "diff-del"
: undefined;
return (
<span key={i} className={cls}>
{line}
{i < lines.length - 1 ? "\n" : ""}
</span>
);
})}
</pre>
</div>
);
}
export function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
return (
<button
type="button"
className={`toggle${on ? " on" : ""}`}
aria-pressed={on}
onClick={onClick}
>
<span className="knob" />
</button>
);
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Static HTML/JS/CSS export. The Handler API (FastAPI) serves the built `out/`
// same-origin, exactly as it served the old Alpine shell — the browser calls the
// authed API with relative paths, so there is no separate frontend server to run.
output: "export",
reactStrictMode: true,
// The export is served from disk with no image optimizer behind it.
images: { unoptimized: true },
};
export default nextConfig;
+499
View File
@@ -0,0 +1,499 @@
{
"name": "handler-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "handler-frontend",
"version": "0.1.0",
"dependencies": {
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.14.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"typescript": "5.5.4"
}
},
"node_modules/@next/env": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz",
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz",
"integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz",
"integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
"integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz",
"integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz",
"integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz",
"integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz",
"integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz",
"integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz",
"integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"license": "Apache-2.0"
},
"node_modules/@swc/helpers": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.5.tgz",
"integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"tslib": "^2.4.0"
}
},
"node_modules/@types/node": {
"version": "20.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.0.tgz",
"integrity": "sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001803",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz",
"integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz",
"integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.",
"license": "MIT",
"dependencies": {
"@next/env": "14.2.15",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
"graceful-fs": "^4.2.11",
"postcss": "8.4.31",
"styled-jsx": "5.1.1"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.15",
"@next/swc-darwin-x64": "14.2.15",
"@next/swc-linux-arm64-gnu": "14.2.15",
"@next/swc-linux-arm64-musl": "14.2.15",
"@next/swc-linux-x64-gnu": "14.2.15",
"@next/swc-linux-x64-musl": "14.2.15",
"@next/swc-win32-arm64-msvc": "14.2.15",
"@next/swc-win32-ia32-msvc": "14.2.15",
"@next/swc-win32-x64-msvc": "14.2.15"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.41.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.1.tgz",
"integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
}
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "handler-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "next build && rm -rf ../src/handler/api/static && cp -r out ../src/handler/api/static",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.15",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.14.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"typescript": "5.5.4"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "out"]
}
+8 -10
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from ..config import get_settings
@@ -53,16 +52,15 @@ def create_app() -> FastAPI:
allow_headers=["Authorization", "Content-Type"],
)
# Serve the bundled UI same-origin. A dedicated "/static" prefix + an explicit "/"
# route (never a "/"-mounted catch-all) so the API routes above can't be shadowed.
# The shell holds no data and is served unauthenticated; all data comes from the
# authed API calls the browser makes after the operator supplies the bearer token.
# Serve the bundled Next.js static export same-origin. It is mounted at "/" *after*
# every API router, so it is a fallback, not a shadow: Starlette matches the explicit
# API routes (registered above) first and only unmatched paths — "/", the exported
# HTML, and the "/_next/*" assets — fall through to StaticFiles. A missing file still
# 404s (no SPA catch-all rewrite), so unknown API-looking paths behave as before.
# The shell holds no data; all data comes from the authed API calls the browser makes
# after the operator supplies the token.
if settings.ui_enabled and _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(_STATIC_DIR / "index.html")
app.mount("/", StaticFiles(directory=_STATIC_DIR, html=True), name="ui")
return app
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
self.__BUILD_MANIFEST={__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},"/_error":["static/chunks/pages/_error-7ba65e1336b92748.js"],sortedPages:["/_app","/_error"]},self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();
@@ -0,0 +1 @@
self.__SSG_MANIFEST=new Set([]);self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[409],{7589:function(e,t,n){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return n(3634)}])},3634:function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return s}}),n(7043);let i=n(7437);n(2265);let o={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"},l={display:"inline-block"},r={display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},d={fontSize:14,fontWeight:400,lineHeight:"49px",margin:0};function s(){return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)("title",{children:"404: This page could not be found."}),(0,i.jsx)("div",{style:o,children:(0,i.jsxs)("div",{children:[(0,i.jsx)("style",{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)}}"}}),(0,i.jsx)("h1",{className:"next-error-h1",style:r,children:"404"}),(0,i.jsx)("div",{style:l,children:(0,i.jsx)("h2",{style:d,children:"This page could not be found."})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},function(e){e.O(0,[971,117,744],function(){return e(e.s=7589)}),_N_E=e.O()}]);
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[185],{3156:function(n,e,u){Promise.resolve().then(u.t.bind(u,7960,23))},7960:function(){}},function(n){n.O(0,[587,971,117,744],function(){return n(n.s=3156)}),_N_E=n.O()}]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[744],{6994:function(e,n,t){Promise.resolve().then(t.t.bind(t,2846,23)),Promise.resolve().then(t.t.bind(t,9107,23)),Promise.resolve().then(t.t.bind(t,1060,23)),Promise.resolve().then(t.t.bind(t,4707,23)),Promise.resolve().then(t.t.bind(t,80,23)),Promise.resolve().then(t.t.bind(t,6423,23))}},function(e){var n=function(n){return e(e.s=n)};e.O(0,[971,117],function(){return n(4278),n(6994)}),_N_E=e.O()}]);
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[888],{1597:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_app",function(){return u(8141)}])}},function(n){var _=function(_){return n(n.s=_)};n.O(0,[774,179],function(){return _(1597),_(7253)}),_N_E=n.O()}]);
@@ -0,0 +1 @@
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[820],{1981:function(n,_,u){(window.__NEXT_P=window.__NEXT_P||[]).push(["/_error",function(){return u(8529)}])}},function(n){n.O(0,[888,774,179],function(){return n(n.s=1981)}),_N_E=n.O()}]);
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
!function(){"use strict";var e,t,r,n,o,u,i,c,f,a={},l={};function s(e){var t=l[e];if(void 0!==t)return t.exports;var r=l[e]={exports:{}},n=!0;try{a[e](r,r.exports,s),n=!1}finally{n&&delete l[e]}return r.exports}s.m=a,e=[],s.O=function(t,r,n,o){if(r){o=o||0;for(var u=e.length;u>0&&e[u-1][2]>o;u--)e[u]=e[u-1];e[u]=[r,n,o];return}for(var i=1/0,u=0;u<e.length;u++){for(var r=e[u][0],n=e[u][1],o=e[u][2],c=!0,f=0;f<r.length;f++)i>=o&&Object.keys(s.O).every(function(e){return s.O[e](r[f])})?r.splice(f--,1):(c=!1,o<i&&(i=o));if(c){e.splice(u--,1);var a=n();void 0!==a&&(t=a)}}return t},r=Object.getPrototypeOf?function(e){return Object.getPrototypeOf(e)}:function(e){return e.__proto__},s.t=function(e,n){if(1&n&&(e=this(e)),8&n||"object"==typeof e&&e&&(4&n&&e.__esModule||16&n&&"function"==typeof e.then))return e;var o=Object.create(null);s.r(o);var u={};t=t||[null,r({}),r([]),r(r)];for(var i=2&n&&e;"object"==typeof i&&!~t.indexOf(i);i=r(i))Object.getOwnPropertyNames(i).forEach(function(t){u[t]=function(){return e[t]}});return u.default=function(){return e},s.d(o,u),o},s.d=function(e,t){for(var r in t)s.o(t,r)&&!s.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},s.f={},s.e=function(e){return Promise.all(Object.keys(s.f).reduce(function(t,r){return s.f[r](e,t),t},[]))},s.u=function(e){},s.miniCssF=function(e){},s.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||Function("return this")()}catch(e){if("object"==typeof window)return window}}(),s.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n={},o="_N_E:",s.l=function(e,t,r,u){if(n[e]){n[e].push(t);return}if(void 0!==r)for(var i,c,f=document.getElementsByTagName("script"),a=0;a<f.length;a++){var l=f[a];if(l.getAttribute("src")==e||l.getAttribute("data-webpack")==o+r){i=l;break}}i||(c=!0,(i=document.createElement("script")).charset="utf-8",i.timeout=120,s.nc&&i.setAttribute("nonce",s.nc),i.setAttribute("data-webpack",o+r),i.src=s.tu(e)),n[e]=[t];var d=function(t,r){i.onerror=i.onload=null,clearTimeout(p);var o=n[e];if(delete n[e],i.parentNode&&i.parentNode.removeChild(i),o&&o.forEach(function(e){return e(r)}),t)return t(r)},p=setTimeout(d.bind(null,void 0,{type:"timeout",target:i}),12e4);i.onerror=d.bind(null,i.onerror),i.onload=d.bind(null,i.onload),c&&document.head.appendChild(i)},s.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},s.tt=function(){return void 0===u&&(u={createScriptURL:function(e){return e}},"undefined"!=typeof trustedTypes&&trustedTypes.createPolicy&&(u=trustedTypes.createPolicy("nextjs#bundler",u))),u},s.tu=function(e){return s.tt().createScriptURL(e)},s.p="/_next/",i={272:0,587:0},s.f.j=function(e,t){var r=s.o(i,e)?i[e]:void 0;if(0!==r){if(r)t.push(r[2]);else if(/^(272|587)$/.test(e))i[e]=0;else{var n=new Promise(function(t,n){r=i[e]=[t,n]});t.push(r[2]=n);var o=s.p+s.u(e),u=Error();s.l(o,function(t){if(s.o(i,e)&&(0!==(r=i[e])&&(i[e]=void 0),r)){var n=t&&("load"===t.type?"missing":t.type),o=t&&t.target&&t.target.src;u.message="Loading chunk "+e+" failed.\n("+n+": "+o+")",u.name="ChunkLoadError",u.type=n,u.request=o,r[1](u)}},"chunk-"+e,e)}}},s.O.j=function(e){return 0===i[e]},c=function(e,t){var r,n,o=t[0],u=t[1],c=t[2],f=0;if(o.some(function(e){return 0!==i[e]})){for(r in u)s.o(u,r)&&(s.m[r]=u[r]);if(c)var a=c(s)}for(e&&e(t);f<o.length;f++)n=o[f],s.o(i,n)&&i[n]&&i[n][0](),i[n]=0;return s.O(a)},(f=self.webpackChunk_N_E=self.webpackChunk_N_E||[]).forEach(c.bind(null,0)),f.push=c.bind(null,f.push.bind(f))}();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-572
View File
@@ -1,572 +0,0 @@
/* Handler web UI vanilla + Alpine.js, no build step.
*
* Classic (non-module) script: `function app()` below becomes a global that the
* shell references via x-data="app()". Loaded with `defer` BEFORE alpine.min.js so
* the global exists before Alpine evaluates the DOM.
*
* Security: every value from the API is rendered with Alpine `x-text` (textContent)
* in index.html never x-html so agent-authored strings can't inject markup.
*
* Control actions (spawn/kill/resume/approve/) are async: the API enqueues a command
* and the control worker executes it. enqueueAndTrack() posts the command, then polls
* GET /commands/{id} until it reaches done/failed, surfacing the result in a banner.
*/
const TOKEN_KEY = "handler_token";
const PROJECT_KEY = "handler_project";
const POLL_MS = 5000;
const LOG_LIMIT = 100;
/* Raised when the API rejects our token; callers re-prompt for it. */
class AuthError extends Error {}
function app() {
return {
// --- auth / shell ---
token: null,
tokenInput: "",
showTokenModal: true,
tokenError: "",
// --- data ---
projects: [],
selectedProjectId: "",
agents: [],
selectedAgentName: null,
checkmark: null,
checkmarkMissing: false,
log: [],
logLimit: LOG_LIMIT,
logOffset: 0,
shared: { log: [], context: [] },
approvals: [],
hosts: [],
commands: [],
// --- forms ---
answerText: "",
answerBusy: false,
answerMsg: "",
answerError: false,
spawnForm: { name: "", role: "", placement: "worktree", worktree: "", subdir: "", task: "" },
approvalForm: { branch: "", status: "approved", agent_name: "", sha: "", note: "" },
projectForm: { id: "", root_dir: "", git_remote: "", credential_ref: "", _editing: false },
hostForm: { hostname: "", forge_type: "github", token_env_var: "", base_url: "", _editing: false },
sharedForm: { key: "", value: "" },
// --- ui ---
tab: "agents",
lastError: "",
cmd: { text: "", error: false, busy: false },
_poll: null,
get selectedAgent() {
return this.agents.find((a) => a.name === this.selectedAgentName) || null;
},
init() {
this.token = localStorage.getItem(TOKEN_KEY);
if (this.token) {
this.showTokenModal = false;
this.start();
} else {
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
}
// Pause polling when the tab is hidden; resume (with an immediate tick) on return.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this._stopPolling();
} else if (this.token) {
this.tick();
this._startPolling();
}
});
},
// --- token lifecycle ---
saveToken() {
const t = this.tokenInput.trim();
if (!t) return;
this.token = t;
localStorage.setItem(TOKEN_KEY, t);
this.tokenInput = "";
this.tokenError = "";
this.showTokenModal = false;
this.start();
},
signOut() {
this._stopPolling();
this.token = null;
localStorage.removeItem(TOKEN_KEY);
this.projects = [];
this.agents = [];
this.selectedAgentName = null;
this.checkmark = null;
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
},
_handle401() {
this._stopPolling();
this.token = null;
localStorage.removeItem(TOKEN_KEY);
this.tokenError = "Invalid token — please try again.";
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
},
// --- fetch wrapper ---
async api(path, opts = {}) {
const headers = { Authorization: `Bearer ${this.token}` };
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(path, {
method: opts.method || "GET",
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
this._handle401();
throw new AuthError("unauthorized");
}
if (!res.ok) {
let detail = `${res.status}`;
try {
const body = await res.json();
detail = body.detail || detail;
// Pydantic 422 returns a list of validation errors.
if (Array.isArray(detail)) detail = detail.map((d) => d.msg || JSON.stringify(d)).join("; ");
} catch (_) {}
const err = new Error(detail);
err.status = res.status;
throw err;
}
if (res.status === 204) return null;
return res.json();
},
_sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
},
/* Post a control action, then poll its command to a terminal state. */
async enqueueAndTrack(path, body, label) {
this.cmd = { text: `${label}: queued…`, error: false, busy: true };
try {
const command = await this.api(path, { method: "POST", body });
return await this._trackCommand(command.id, label);
} catch (e) {
if (e instanceof AuthError) return null;
this.cmd = { text: `${label} failed: ${e.message}`, error: true, busy: false };
return null;
}
},
async _trackCommand(id, label) {
for (let i = 0; i < 40; i++) {
let c;
try {
c = await this.api(`/commands/${id}`);
} catch (e) {
if (e instanceof AuthError) return null;
this.cmd = { text: `${label}: ${e.message}`, error: true, busy: false };
return null;
}
if (c.status === "done" || c.status === "failed") {
const ok = c.status === "done";
const detail = c.error || (c.result ? JSON.stringify(c.result) : "");
this.cmd = {
text: `${label} ${ok ? "done" : "failed"}${detail ? " — " + detail : ""}`,
error: !ok,
busy: false,
};
return c;
}
this.cmd = { text: `${label}: ${c.status}`, error: false, busy: true };
await this._sleep(600);
}
this.cmd = { text: `${label}: still running (see Activity). Is the worker up?`, error: false, busy: false };
return null;
},
// --- lifecycle ---
async start() {
await this.loadProjects();
const saved = localStorage.getItem(PROJECT_KEY);
if (saved && this.projects.some((p) => p.id === saved)) {
await this.selectProject(saved);
}
this._startPolling();
},
_startPolling() {
this._stopPolling();
this._poll = setInterval(() => this.tick(), POLL_MS);
},
_stopPolling() {
if (this._poll) {
clearInterval(this._poll);
this._poll = null;
}
},
/* One poll cycle for whatever view is active. Swallows AuthError (already handled). */
async tick() {
try {
if (this.tab === "shared") return await this.loadShared();
if (this.tab === "activity") return await this.loadCommands();
if (this.tab === "hosts") return await this.loadHosts();
if (this.tab === "projects") return await this.loadProjects();
if (this.tab === "approvals") {
if (this.selectedProjectId) await this.loadApprovals();
return;
}
// agents tab
if (this.selectedProjectId) await this.loadAgents();
if (this.selectedAgentName) {
await this.loadCheckmark();
await this.loadLog();
}
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
refresh() {
this.tick();
},
switchTab(tab) {
this.tab = tab;
this.cmd = { text: "", error: false, busy: false };
this.tick();
},
// --- projects ---
async loadProjects() {
try {
this.projects = await this.api("/projects");
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
async selectProject(id) {
this.selectedProjectId = id;
localStorage.setItem(PROJECT_KEY, id);
this.selectedAgentName = null;
this.checkmark = null;
this.log = [];
this.logOffset = 0;
await this.loadAgents();
if (this.tab === "approvals") await this.loadApprovals();
},
resetProjectForm() {
this.projectForm = { id: "", root_dir: "", git_remote: "", credential_ref: "", _editing: false };
},
editProject(p) {
this.projectForm = {
id: p.id,
root_dir: p.root_dir,
git_remote: p.git_remote || "",
credential_ref: p.credential_ref || "",
_editing: true,
};
},
async saveProject() {
const f = this.projectForm;
const body = {
root_dir: f.root_dir.trim(),
git_remote: f.git_remote.trim() || null,
credential_ref: f.credential_ref.trim() || null,
};
try {
if (f._editing) {
await this.api(`/projects/${encodeURIComponent(f.id)}`, { method: "PATCH", body });
this.cmd = { text: `project '${f.id}' updated`, error: false, busy: false };
} else {
await this.api("/projects", { method: "POST", body: { id: f.id.trim(), ...body } });
this.cmd = { text: `project '${f.id}' created`, error: false, busy: false };
}
this.resetProjectForm();
await this.loadProjects();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
async deleteProject(id) {
if (!confirm(`Delete project '${id}'? Its agents/log rows go with it.`)) return;
try {
await this.api(`/projects/${encodeURIComponent(id)}`, { method: "DELETE" });
this.cmd = { text: `project '${id}' deleted`, error: false, busy: false };
if (this.selectedProjectId === id) this.selectedProjectId = "";
await this.loadProjects();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
// --- agents ---
async loadAgents() {
const p = this.selectedProjectId;
if (!p) return;
this.agents = await this.api(`/projects/${encodeURIComponent(p)}/agents`);
this.lastError = "";
},
async selectAgent(name) {
this.selectedAgentName = name;
this.answerText = "";
this.answerMsg = "";
this.answerError = false;
this.logOffset = 0;
await this.loadCheckmark();
await this.loadLog();
},
_agentPath() {
return `/projects/${encodeURIComponent(this.selectedProjectId)}/agents/${encodeURIComponent(this.selectedAgentName)}`;
},
async spawnAgent() {
const f = this.spawnForm;
const body = { name: f.name.trim(), role: f.role || null, task: f.task.trim() || null };
if (f.placement === "worktree" && f.worktree.trim()) body.worktree = f.worktree.trim();
if (f.placement === "subdir" && f.subdir.trim()) body.subdir = f.subdir.trim();
const p = encodeURIComponent(this.selectedProjectId);
const final = await this.enqueueAndTrack(`/projects/${p}/agents/spawn`, body, `spawn ${body.name}`);
if (final && final.status === "done") {
this.spawnForm = { name: "", role: "", placement: "worktree", worktree: "", subdir: "", task: "" };
}
await this.loadAgents();
},
async killAgent(name) {
const p = encodeURIComponent(this.selectedProjectId);
await this.enqueueAndTrack(`/projects/${p}/agents/${encodeURIComponent(name)}/kill`, undefined, `kill ${name}`);
await this.loadAgents();
},
async deleteAgent(name) {
if (!confirm(`Delete the agent row '${name}'? (Kill the session first if live.)`)) return;
const p = encodeURIComponent(this.selectedProjectId);
try {
await this.api(`/projects/${p}/agents/${encodeURIComponent(name)}`, { method: "DELETE" });
this.cmd = { text: `agent row '${name}' deleted`, error: false, busy: false };
if (this.selectedAgentName === name) this.selectedAgentName = null;
await this.loadAgents();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
async loadCheckmark() {
try {
this.checkmark = await this.api(`${this._agentPath()}/checkmark`);
this.checkmarkMissing = false;
} catch (e) {
if (e instanceof AuthError) return;
if (e.status === 404) {
this.checkmark = null;
this.checkmarkMissing = true;
} else {
this.lastError = e.message;
}
}
},
async loadLog() {
try {
this.log = await this.api(`${this._agentPath()}/log?limit=${this.logLimit}&offset=${this.logOffset}`);
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
pagePrev() {
if (this.logOffset === 0) return;
this.logOffset = Math.max(0, this.logOffset - this.logLimit);
this.loadLog();
},
pageNext() {
if (this.log.length < this.logLimit) return;
this.logOffset += this.logLimit;
this.loadLog();
},
// --- answer / resume ---
async submitAnswer(resume) {
const text = this.answerText.trim();
if (!text) return;
this.answerBusy = true;
this.answerMsg = "";
this.answerError = false;
try {
await this.api(`${this._agentPath()}/answer`, { method: "POST", body: { answer: text } });
if (resume) {
this.answerMsg = "Answer saved; resume enqueued.";
this.answerText = "";
await this.enqueueAndTrack(`${this._agentPath()}/resume`, { answer: text }, "resume");
await this.loadAgents();
await this.loadCheckmark();
await this.loadLog();
} else {
this.answerMsg = "Answer saved (agent still paused).";
this.answerText = "";
await this.loadCheckmark();
await this.loadLog();
}
} catch (e) {
if (e instanceof AuthError) return;
this.answerError = true;
this.answerMsg = e.message;
} finally {
this.answerBusy = false;
}
},
// --- approvals ---
async loadApprovals() {
if (!this.selectedProjectId) {
this.approvals = [];
return;
}
try {
this.approvals = await this.api(`/projects/${encodeURIComponent(this.selectedProjectId)}/approvals`);
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
async submitApproval() {
const f = this.approvalForm;
const body = {
branch: f.branch.trim(),
status: f.status,
agent_name: f.agent_name.trim() || null,
sha: f.sha.trim() || null,
note: f.note.trim() || null,
};
const p = encodeURIComponent(this.selectedProjectId);
await this.enqueueAndTrack(`/projects/${p}/approvals`, body, `${f.status} ${f.branch}`);
this.approvalForm = { branch: "", status: "approved", agent_name: "", sha: "", note: "" };
await this.loadApprovals();
},
// --- hosts ---
async loadHosts() {
try {
this.hosts = await this.api("/hosts");
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
resetHostForm() {
this.hostForm = { hostname: "", forge_type: "github", token_env_var: "", base_url: "", _editing: false };
},
editHost(h) {
this.hostForm = {
hostname: h.hostname,
forge_type: h.forge_type,
token_env_var: h.token_env_var || "",
base_url: h.base_url || "",
_editing: true,
};
},
async saveHost() {
const f = this.hostForm;
const body = {
forge_type: f.forge_type,
token_env_var: f.token_env_var.trim() || null,
base_url: f.base_url.trim() || null,
};
try {
if (f._editing) {
await this.api(`/hosts/${encodeURIComponent(f.hostname)}`, { method: "PATCH", body });
this.cmd = { text: `host '${f.hostname}' updated`, error: false, busy: false };
} else {
await this.api("/hosts", { method: "POST", body: { hostname: f.hostname.trim(), ...body } });
this.cmd = { text: `host '${f.hostname}' created`, error: false, busy: false };
}
this.resetHostForm();
await this.loadHosts();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
async deleteHost(hostname) {
if (!confirm(`Delete host '${hostname}'?`)) return;
try {
await this.api(`/hosts/${encodeURIComponent(hostname)}`, { method: "DELETE" });
this.cmd = { text: `host '${hostname}' deleted`, error: false, busy: false };
await this.loadHosts();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
// --- activity / commands ---
async loadCommands() {
try {
this.commands = await this.api("/commands?limit=50");
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
async pollCiGlobal() {
await this.enqueueAndTrack("/poll-ci", undefined, "poll-ci (all projects)");
await this.loadCommands();
},
// --- shared tab ---
async loadShared() {
try {
const [log, context] = await Promise.all([
this.api("/shared/log"),
this.api("/shared/context"),
]);
this.shared = { log, context };
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
async setSharedContext() {
const key = this.sharedForm.key.trim();
const value = this.sharedForm.value.trim();
if (!key || !value) return;
try {
await this.api(`/shared/context/${encodeURIComponent(key)}`, { method: "PUT", body: { value } });
this.cmd = { text: `shared context '${key}' set`, error: false, busy: false };
this.sharedForm = { key: "", value: "" };
await this.loadShared();
} catch (e) {
if (e instanceof AuthError) return;
this.cmd = { text: e.message, error: true, busy: false };
}
},
// --- rendering helpers ---
badgeClass(kind, value) {
const v = value == null ? "unknown" : String(value);
return `badge-${kind}-${v.replace(/[^a-z0-9_]/gi, "")}`;
},
fmt(ts) {
if (!ts) return "";
const d = new Date(ts);
if (isNaN(d)) return String(ts);
return d.toLocaleString();
},
};
}
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#90cdf4"/>
<stop offset="1" stop-color="#667eea"/>
</linearGradient>
</defs>
<rect width="32" height="32" rx="8" fill="#0f1117"/>
<rect x="6" y="6" width="20" height="20" rx="6" fill="url(#g)"/>
</svg>

After

Width:  |  Height:  |  Size: 402 B

+1 -411
View File
@@ -1,411 +1 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Handler</title>
<link rel="stylesheet" href="/static/styles.css" />
<!-- app.js defines window.app() and MUST run before Alpine starts; both are classic
`defer` scripts, which execute in document order, so app.js is guaranteed first. -->
<script defer src="/static/app.js"></script>
<script defer src="/static/alpine.min.js"></script>
</head>
<body x-data="app()" x-cloak>
<!-- Token modal: shown until a bearer token is supplied. Holds no data. -->
<div class="modal-backdrop" x-show="showTokenModal">
<form class="modal" @submit.prevent="saveToken()">
<h2>Handler</h2>
<p class="muted">Paste your API bearer token to continue. Management actions
(spawn, approve, edit projects/hosts) require the <strong>admin</strong> token.</p>
<input type="password" x-model="tokenInput" placeholder="AUTH_TOKEN / ADMIN_TOKEN"
autocomplete="current-password" x-ref="tokenField" />
<p class="error" x-show="tokenError" x-text="tokenError"></p>
<button type="submit">Save</button>
</form>
</div>
<template x-if="!showTokenModal">
<div>
<header class="topbar">
<div class="brand">Handler</div>
<div class="topbar-controls">
<label class="muted" for="project-select">Project</label>
<select id="project-select" x-model="selectedProjectId" @change="selectProject($event.target.value)">
<option value="" disabled>— select —</option>
<template x-for="p in projects" :key="p.id">
<option :value="p.id" x-text="p.id"></option>
</template>
</select>
<nav class="tabs">
<button :class="{ active: tab === 'agents' }" @click="switchTab('agents')">Agents</button>
<button :class="{ active: tab === 'approvals' }" @click="switchTab('approvals')">Approvals</button>
<button :class="{ active: tab === 'projects' }" @click="switchTab('projects')">Projects</button>
<button :class="{ active: tab === 'hosts' }" @click="switchTab('hosts')">Hosts</button>
<button :class="{ active: tab === 'activity' }" @click="switchTab('activity')">Activity</button>
<button :class="{ active: tab === 'shared' }" @click="switchTab('shared')">Shared</button>
</nav>
<button class="ghost" @click="refresh()" title="Refresh now"></button>
<button class="ghost" @click="signOut()" title="Sign out / change token">Sign out</button>
</div>
</header>
<p class="banner error" x-show="lastError" x-text="lastError"></p>
<!-- Command feedback: the async result of any enqueued control action. -->
<p class="banner" :class="cmd.error ? 'error' : 'ok'" x-show="cmd.text" x-text="cmd.text"></p>
<!-- AGENTS TAB -->
<main class="layout" x-show="tab === 'agents'">
<!-- Agent list + spawn -->
<section class="panel agents">
<h3>Agents <span class="muted" x-show="selectedProjectId" x-text="'· ' + selectedProjectId"></span></h3>
<p class="muted" x-show="!selectedProjectId">Select a project.</p>
<div class="card" x-show="selectedProjectId">
<h4>Spawn an agent</h4>
<div class="form-grid">
<input x-model="spawnForm.name" placeholder="name (e.g. junior)" />
<select x-model="spawnForm.role">
<option value="">role — none</option>
<option value="junior">junior</option>
<option value="senior">senior</option>
<option value="deploy">deploy</option>
</select>
<select x-model="spawnForm.placement">
<option value="worktree">git worktree on branch</option>
<option value="subdir">subdir under root</option>
</select>
<input x-show="spawnForm.placement === 'worktree'" x-model="spawnForm.worktree" placeholder="branch (e.g. feat/auth)" />
<input x-show="spawnForm.placement === 'subdir'" x-model="spawnForm.subdir" placeholder="subdir (e.g. api)" />
</div>
<textarea x-model="spawnForm.task" rows="2" placeholder="initial task / prompt (optional)"></textarea>
<div class="toolbar">
<button class="primary" @click="spawnAgent()" :disabled="cmd.busy || !spawnForm.name.trim()">Spawn</button>
</div>
</div>
<p class="muted" x-show="selectedProjectId && agents.length === 0">No agents in this project.</p>
<ul class="agent-list">
<template x-for="a in agents" :key="a.id">
<li :class="{ selected: a.name === selectedAgentName }" @click="selectAgent(a.name)">
<span class="agent-name" x-text="a.name"></span>
<span class="badge" :class="badgeClass('role', a.role || 'none')" x-show="a.role" x-text="a.role"></span>
<span class="badge" :class="badgeClass('status', a.status)" x-text="a.status"></span>
<span class="needs-answer" x-show="a.status === 'paused_for_input'">needs answer</span>
</li>
</template>
</ul>
</section>
<!-- Agent detail -->
<section class="panel detail" x-show="selectedAgentName">
<h3>
<span x-text="selectedAgentName"></span>
<span class="badge" x-show="selectedAgent" :class="badgeClass('status', selectedAgent?.status)"
x-text="selectedAgent?.status"></span>
<span class="spacer"></span>
<button class="ghost small" @click="killAgent(selectedAgentName)" :disabled="cmd.busy">Kill</button>
<button class="ghost small danger" @click="deleteAgent(selectedAgentName)" :disabled="cmd.busy">Delete row</button>
</h3>
<!-- Checkmark -->
<div class="card">
<h4>Checkmark</h4>
<p class="muted" x-show="checkmarkMissing">No checkpoint recorded yet.</p>
<dl class="kv" x-show="checkmark && !checkmarkMissing">
<dt>Status</dt>
<dd><span class="badge" :class="badgeClass('status', checkmark?.status)" x-text="checkmark?.status"></span></dd>
<dt>Where it stopped</dt>
<dd x-text="checkmark?.where_it_stopped || '—'"></dd>
<dt>Open question</dt>
<dd x-text="checkmark?.open_question || '—'"></dd>
<dt>Next steps</dt>
<dd>
<ul class="next-steps" x-show="checkmark?.next_steps?.length">
<template x-for="(step, i) in (checkmark?.next_steps || [])" :key="i">
<li x-text="step"></li>
</template>
</ul>
<span x-show="!checkmark?.next_steps?.length"></span>
</dd>
<dt>Tests</dt>
<dd>
<span class="badge" :class="badgeClass('gate', checkmark?.tests_status)" x-text="checkmark?.tests_status"></span>
<span class="muted" x-text="fmt(checkmark?.tested_at)"></span>
</dd>
<dt>Build</dt>
<dd>
<span class="badge" :class="badgeClass('gate', checkmark?.build_status)" x-text="checkmark?.build_status"></span>
<span class="muted" x-text="fmt(checkmark?.built_at)"></span>
</dd>
<dt>Checkpoint at</dt>
<dd class="muted" x-text="fmt(checkmark?.checkpoint_at)"></dd>
</dl>
</div>
<!-- Answer form -->
<div class="card answer" x-show="selectedAgent?.status === 'paused_for_input'">
<h4>Answer this question</h4>
<p class="question" x-text="checkmark?.open_question || '(no question text on the checkmark)'"></p>
<textarea x-model="answerText" rows="3" placeholder="Your answer…"></textarea>
<div class="answer-actions">
<button @click="submitAnswer(false)" :disabled="answerBusy || !answerText.trim()">Answer</button>
<button class="primary" @click="submitAnswer(true)" :disabled="answerBusy || !answerText.trim()">Answer &amp; Resume</button>
</div>
<p class="answer-msg" :class="{ error: answerError }" x-show="answerMsg" x-text="answerMsg"></p>
</div>
<!-- Log -->
<div class="card">
<h4>Log <span class="muted">(newest first)</span></h4>
<p class="muted" x-show="log.length === 0">No log entries.</p>
<div class="table-wrap" x-show="log.length">
<table class="log">
<thead>
<tr><th>When</th><th>Status</th><th>Summary</th><th>Q / A</th><th>Vis</th><th>Push</th><th>CI</th></tr>
</thead>
<tbody>
<template x-for="e in log" :key="e.id">
<tr>
<td class="muted nowrap" x-text="fmt(e.created_at)"></td>
<td><span class="badge" :class="badgeClass('status', e.status)" x-text="e.status"></span></td>
<td x-text="e.summary || '—'"></td>
<td>
<div x-show="e.question"><strong>Q:</strong> <span x-text="e.question"></span></div>
<div x-show="e.answer"><strong>A:</strong> <span x-text="e.answer"></span></div>
<span x-show="!e.question && !e.answer"></span>
</td>
<td><span class="badge" :class="badgeClass('visibility', e.visibility)" x-text="e.visibility"></span></td>
<td class="mono" x-text="e.push_sha ? e.push_sha.slice(0,7) : '—'"></td>
<td><span class="badge" :class="badgeClass('ci', e.ci_status)" x-text="e.ci_status"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<button @click="pagePrev()" :disabled="logOffset === 0"> Newer</button>
<span class="muted" x-text="'offset ' + logOffset"></span>
<button @click="pageNext()" :disabled="log.length < logLimit">Older </button>
</div>
</div>
</section>
</main>
<!-- APPROVALS TAB -->
<main class="layout" x-show="tab === 'approvals'">
<section class="panel">
<h3>Approvals <span class="muted" x-show="selectedProjectId" x-text="'· ' + selectedProjectId"></span></h3>
<p class="muted" x-show="!selectedProjectId">Select a project.</p>
<div class="card" x-show="selectedProjectId">
<h4>Record a verdict</h4>
<div class="form-grid">
<input x-model="approvalForm.branch" placeholder="branch (e.g. feat/auth)" />
<select x-model="approvalForm.status">
<option value="approved">approve</option>
<option value="rejected">reject</option>
</select>
<input x-model="approvalForm.agent_name" placeholder="agent (optional — reads its HEAD)" />
<input x-model="approvalForm.sha" placeholder="sha (optional — pins the approval)" />
</div>
<input x-model="approvalForm.note" placeholder="note (optional)" />
<div class="toolbar">
<button class="primary" @click="submitApproval()" :disabled="cmd.busy || !approvalForm.branch.trim()">Enqueue verdict</button>
</div>
</div>
<p class="muted" x-show="selectedProjectId && approvals.length === 0">No approvals recorded.</p>
<div class="table-wrap" x-show="approvals.length">
<table class="log">
<thead><tr><th>When</th><th>Branch</th><th>Verdict</th><th>By</th><th>SHA</th><th>Note</th></tr></thead>
<tbody>
<template x-for="ap in approvals" :key="ap.id">
<tr>
<td class="muted nowrap" x-text="fmt(ap.created_at)"></td>
<td class="mono" x-text="ap.branch"></td>
<td><span class="badge" :class="badgeClass('approval', ap.status)" x-text="ap.status"></span></td>
<td x-text="ap.approved_by_agent_id ? ('agent ' + ap.approved_by_agent_id) : (ap.actor || '—')"></td>
<td class="mono" x-text="ap.approved_sha ? ap.approved_sha.slice(0,7) : '—'"></td>
<td x-text="ap.note || '—'"></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
<!-- PROJECTS TAB -->
<main class="layout" x-show="tab === 'projects'">
<section class="panel">
<h3>Projects</h3>
<div class="card">
<h4 x-text="projectForm._editing ? ('Edit project · ' + projectForm.id) : 'Register a project'"></h4>
<div class="form-grid">
<input x-model="projectForm.id" :disabled="projectForm._editing" placeholder="id / slug (e.g. leeworks-api)" />
<input x-model="projectForm.root_dir" placeholder="root_dir (e.g. /var/lib/handler/projects/leeworks)" />
<input x-model="projectForm.git_remote" placeholder="git_remote (optional)" />
<input x-model="projectForm.credential_ref" placeholder="credential_ref (env:VAR / file:/path / db:id)" />
</div>
<p class="muted small">credential_ref is a <em>pointer</em>, never the token. <code>cmd:</code> is CLI-only.</p>
<div class="toolbar">
<button class="primary" @click="saveProject()" :disabled="cmd.busy || !projectForm.id.trim() || !projectForm.root_dir.trim()"
x-text="projectForm._editing ? 'Save changes' : 'Create'"></button>
<button class="ghost" x-show="projectForm._editing" @click="resetProjectForm()">Cancel</button>
</div>
</div>
<div class="table-wrap" x-show="projects.length">
<table class="log">
<thead><tr><th>ID</th><th>root_dir</th><th>git_remote</th><th>credential_ref</th><th></th></tr></thead>
<tbody>
<template x-for="p in projects" :key="p.id">
<tr>
<td class="mono" x-text="p.id"></td>
<td class="mono" x-text="p.root_dir"></td>
<td class="mono" x-text="p.git_remote || '—'"></td>
<td class="mono" x-text="p.credential_ref || '—'"></td>
<td class="nowrap">
<button class="ghost small" @click="editProject(p)">Edit</button>
<button class="ghost small danger" @click="deleteProject(p.id)">Delete</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
<!-- HOSTS TAB -->
<main class="layout" x-show="tab === 'hosts'">
<section class="panel">
<h3>Forge hosts</h3>
<p class="muted small">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.</p>
<div class="card">
<h4 x-text="hostForm._editing ? ('Edit host · ' + hostForm.hostname) : 'Register a host'"></h4>
<div class="form-grid">
<input x-model="hostForm.hostname" :disabled="hostForm._editing" placeholder="hostname (e.g. git.corp.internal)" />
<select x-model="hostForm.forge_type">
<option value="github">github</option>
<option value="gitlab">gitlab</option>
<option value="gitea">gitea</option>
<option value="forgejo">forgejo</option>
<option value="bitbucket">bitbucket</option>
</select>
<input x-model="hostForm.token_env_var" placeholder="token env var (e.g. GITEA_TOKEN)" />
<input x-model="hostForm.base_url" placeholder="base_url (optional, e.g. https://git.corp.internal)" />
</div>
<div class="toolbar">
<button class="primary" @click="saveHost()" :disabled="cmd.busy || !hostForm.hostname.trim()"
x-text="hostForm._editing ? 'Save changes' : 'Create'"></button>
<button class="ghost" x-show="hostForm._editing" @click="resetHostForm()">Cancel</button>
</div>
</div>
<p class="muted" x-show="hosts.length === 0">No hosts registered (built-in host map still applies).</p>
<div class="table-wrap" x-show="hosts.length">
<table class="log">
<thead><tr><th>Hostname</th><th>Type</th><th>Token env var</th><th>Base URL</th><th></th></tr></thead>
<tbody>
<template x-for="h in hosts" :key="h.hostname">
<tr>
<td class="mono" x-text="h.hostname"></td>
<td x-text="h.forge_type"></td>
<td class="mono" x-text="h.token_env_var || '—'"></td>
<td class="mono" x-text="h.base_url || '—'"></td>
<td class="nowrap">
<button class="ghost small" @click="editHost(h)">Edit</button>
<button class="ghost small danger" @click="deleteHost(h.hostname)">Delete</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
<!-- ACTIVITY TAB -->
<main class="layout" x-show="tab === 'activity'">
<section class="panel">
<h3>Activity <span class="muted">· control commands</span>
<span class="spacer"></span>
<button class="ghost small" @click="pollCiGlobal()" :disabled="cmd.busy">Sweep CI now</button>
</h3>
<p class="muted" x-show="commands.length === 0">No commands yet.</p>
<div class="table-wrap" x-show="commands.length">
<table class="log">
<thead><tr><th>When</th><th>Type</th><th>Project</th><th>Agent</th><th>Status</th><th>Result / Error</th></tr></thead>
<tbody>
<template x-for="c in commands" :key="c.id">
<tr>
<td class="muted nowrap" x-text="fmt(c.created_at)"></td>
<td class="mono" x-text="c.type"></td>
<td class="mono" x-text="c.project_id || '—'"></td>
<td class="mono" x-text="c.agent_name || '—'"></td>
<td><span class="badge" :class="badgeClass('cmd', c.status)" x-text="c.status"></span></td>
<td class="mono small" x-text="c.error || (c.result ? JSON.stringify(c.result) : '—')"></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
<!-- SHARED TAB -->
<main class="layout" x-show="tab === 'shared'">
<section class="panel">
<h3>Global feed</h3>
<p class="muted" x-show="shared.log.length === 0">No global log entries.</p>
<div class="table-wrap" x-show="shared.log.length">
<table class="log">
<thead><tr><th>When</th><th>Agent</th><th>Status</th><th>Summary</th><th>CI</th></tr></thead>
<tbody>
<template x-for="e in shared.log" :key="e.id">
<tr>
<td class="muted nowrap" x-text="fmt(e.created_at)"></td>
<td class="mono" x-text="e.agent_id"></td>
<td><span class="badge" :class="badgeClass('status', e.status)" x-text="e.status"></span></td>
<td x-text="e.summary || '—'"></td>
<td><span class="badge" :class="badgeClass('ci', e.ci_status)" x-text="e.ci_status"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h3>Shared context</h3>
<div class="card">
<h4>Set a key</h4>
<div class="form-grid">
<input x-model="sharedForm.key" placeholder="key" />
<input x-model="sharedForm.value" placeholder="value" />
</div>
<p class="muted small">Requires the shared-context write token (or admin/global if unset).</p>
<div class="toolbar">
<button class="primary" @click="setSharedContext()" :disabled="!sharedForm.key.trim() || !sharedForm.value.trim()">Set</button>
</div>
</div>
<p class="muted" x-show="shared.context.length === 0">No shared context keys.</p>
<div class="table-wrap" x-show="shared.context.length">
<table class="log">
<thead><tr><th>Key</th><th>Value</th><th>Updated</th></tr></thead>
<tbody>
<template x-for="c in shared.context" :key="c.key">
<tr>
<td class="mono" x-text="c.key"></td>
<td x-text="c.value"></td>
<td class="muted nowrap" x-text="fmt(c.updated_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
</div>
</template>
</body>
</html>
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/bbd6ee1e7265be74.css" data-precedence="next"/><link rel="preload" as="script" fetchPriority="low" href="/_next/static/chunks/webpack-29957205745576aa.js"/><script src="/_next/static/chunks/fd9d1056-8edc3f3573e7d5e5.js" async=""></script><script src="/_next/static/chunks/117-e7bb738621b70d3f.js" async=""></script><script src="/_next/static/chunks/main-app-8321800782c5a11e.js" async=""></script><script src="/_next/static/chunks/app/page-67c3ee7b71a251cf.js" async=""></script><title>Handler · Claude Activity</title><meta name="description" content="Monitor and manage Claude Code agents across projects."/><link rel="icon" href="/icon.svg?bab509b45a421e43" type="image/svg+xml" sizes="any"/><script src="/_next/static/chunks/polyfills-42372ed130431b0a.js" noModule=""></script></head><body><div class="gate"><form class="gate-card"><div class="gate-brand"><span class="logo" style="width:26px;height:26px;border-radius:7px"></span>Claude Monitor</div><p class="muted" style="font-size:var(--text-sm);margin:0">Paste your API token to continue. Management actions require the admin token; read-only views work with the plain auth token.</p><input class="input" type="password" autoComplete="current-password" placeholder="API token" autofocus="" value=""/><button class="btn btn-primary" type="submit">Continue</button></form></div><script src="/_next/static/chunks/webpack-29957205745576aa.js" async=""></script><script>(self.__next_f=self.__next_f||[]).push([0]);self.__next_f.push([2,null])</script><script>self.__next_f.push([1,"1:HL[\"/_next/static/css/bbd6ee1e7265be74.css\",\"style\"]\n"])</script><script>self.__next_f.push([1,"2:I[2846,[],\"\"]\n4:I[9107,[],\"ClientPageRoot\"]\n5:I[3815,[\"931\",\"static/chunks/app/page-67c3ee7b71a251cf.js\"],\"default\",1]\n6:I[4707,[],\"\"]\n7:I[6423,[],\"\"]\n9:I[1060,[],\"\"]\na:[]\n0:[\"$\",\"$L2\",null,{\"buildId\":\"GQbm47pHcnN5aChqrNLgi\",\"assetPrefix\":\"\",\"urlParts\":[\"\",\"\"],\"initialTree\":[\"\",{\"children\":[\"__PAGE__\",{}]},\"$undefined\",\"$undefined\",true],\"initialSeedData\":[\"\",{\"children\":[\"__PAGE__\",{},[[\"$L3\",[\"$\",\"$L4\",null,{\"props\":{\"params\":{},\"searchParams\":{}},\"Component\":\"$5\"}],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\":[\"$\",\"$L6\",null,{\"parallelRouterKey\":\"children\",\"segmentPath\":[\"children\"],\"error\":\"$undefined\",\"errorStyles\":\"$undefined\",\"errorScripts\":\"$undefined\",\"template\":[\"$\",\"$L7\",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.\"}]}]]}]}]],\"notFoundStyl"])</script><script>self.__next_f.push([1,"es\":[]}]}]}]],null],null],\"couldBeIntercepted\":false,\"initialHead\":[null,\"$L8\"],\"globalErrorComponent\":\"$9\",\"missingSlots\":\"$Wa\"}]\n"])</script><script>self.__next_f.push([1,"8:[[\"$\",\"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\"}]]\n3:null\n"])</script></body></html>
+7
View File
@@ -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
-204
View File
@@ -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; }
+27 -18
View File
@@ -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