mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-02 02:56:24 +00:00
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:
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Generated
+499
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user