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
+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"]
}