feat(dashboard): Phase 3 — embedded web dashboard #138

Merged
xiaomo merged 2 commits from feat/133-phase3-web-dashboard into main 2026-04-25 07:53:05 +00:00
18 changed files with 694 additions and 18 deletions
@@ -29,6 +29,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 30s", "on: cpu-threshold"],
lastSignalTimestamp: 1_700_000_000_000,
},
{
@@ -36,6 +37,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "system",
throttle: 30000,
timeout: null,
triggers: [],
lastSignalTimestamp: null,
},
{
@@ -43,6 +45,7 @@ const SAMPLE_SENSES: SenseInfo[] = [
group: "tasks",
throttle: 10000,
timeout: 30000,
triggers: ["every 1m"],
lastSignalTimestamp: null,
},
];
@@ -112,6 +115,13 @@ describe("formatSenseList", () => {
expect(output).toContain("—");
});
it("shows triggers from sense metadata", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("triggers:");
expect(output).toContain("every 30s");
expect(output).toContain("(none)");
});
it("shows '(never)' when lastSignalTimestamp is null", () => {
const output = formatSenseList(SAMPLE_SENSES);
expect(output).toContain("(never)");
@@ -263,6 +273,7 @@ describe("listSensesViaDaemon", () => {
group: "system",
throttle: 5000,
timeout: 3000,
triggers: [],
lastSignalTimestamp: 12345,
},
];
+10 -2
View File
@@ -2,7 +2,12 @@ import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { type SenseInfo, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core";
import {
type SenseInfo,
isPlainRecord,
parseNerveConfig,
senseTriggerLabels,
} from "@uncaged/nerve-core";
import { defineCommand } from "citty";
import { isRemoteDaemonCli } from "../cli-global.js";
@@ -44,6 +49,7 @@ export function formatSenseList(senses: SenseInfo[]): string {
lines.push(` group: ${s.group}\n`);
lines.push(` throttle: ${formatDuration(s.throttle)}\n`);
lines.push(` timeout: ${formatDuration(s.timeout)}\n`);
lines.push(` triggers: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`);
const lastSignal =
s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)";
lines.push(` last signal: ${lastSignal}\n`);
@@ -61,11 +67,13 @@ export function sensesFromConfig(configPath: string): SenseInfo[] {
}
const result = parseNerveConfig(raw);
if (!result.ok) return [];
return Object.entries(result.value.senses).map(([name, cfg]) => ({
const { senses, reflexes } = result.value;
return Object.entries(senses).map(([name, cfg]) => ({
name,
group: cfg.group,
throttle: cfg.throttle,
timeout: cfg.timeout,
triggers: senseTriggerLabels(name, reflexes),
lastSignalTimestamp: null,
}));
}
+1
View File
@@ -326,6 +326,7 @@ const workflowDaemonListCommand = defineCommand({
const rows = workflows.map((w) => ({
name: w.name,
active: w.activeThreads,
runIds: w.activeRunIds.length > 0 ? w.activeRunIds.join(", ") : "—",
queued: w.queuedThreads,
concurrency: w.config.concurrency,
overflow: w.config.overflow,
+1 -1
View File
@@ -168,7 +168,7 @@ export class HttpTransport implements DaemonTransport {
const res = await fetch(`${this.baseUrl}/api/kill-workflow`, {
method: "POST",
headers: { ...this.baseHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({ threadId: runId }),
body: JSON.stringify({ runId }),
});
const body = await readJsonBody(res);
if (res.status === 401) {
+2
View File
@@ -11,6 +11,8 @@ import type { SenseInfo } from "./sense.js";
export type WorkflowStatus = {
name: string;
activeThreads: number;
/** Run IDs currently executing (same identifiers accepted by kill-workflow). */
activeRunIds: string[];
queuedThreads: number;
config: { concurrency: number; overflow: string };
};
@@ -10,6 +10,8 @@ export function isSenseInfo(value: unknown): value is SenseInfo {
typeof value.group === "string" &&
(value.throttle === null || typeof value.throttle === "number") &&
(value.timeout === null || typeof value.timeout === "number") &&
Array.isArray(value.triggers) &&
value.triggers.every((t: unknown) => typeof t === "string") &&
(value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number")
);
}
@@ -22,6 +24,8 @@ export function isWorkflowStatus(value: unknown): value is WorkflowStatus {
return (
typeof value.name === "string" &&
typeof value.activeThreads === "number" &&
Array.isArray(value.activeRunIds) &&
value.activeRunIds.every((id: unknown) => typeof id === "string") &&
typeof value.queuedThreads === "number" &&
typeof cfg.concurrency === "number" &&
typeof cfg.overflow === "string"
+1
View File
@@ -9,6 +9,7 @@ export type {
NerveConfig,
} from "./config.js";
export type { Signal, SenseInfo, SenseResult } from "./sense.js";
export { labelSenseReflexTrigger, senseTriggerLabels } from "./sense-trigger-labels.js";
export type {
WorkflowMessage,
RoleResult,
+1 -3
View File
@@ -312,9 +312,7 @@ function parseApiConfig(obj: Record<string, unknown>): Result<NerveApiConfig> {
if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) {
return err(
new Error(
"api.host binds to non-loopback address, api.token is required for security",
),
new Error("api.host binds to non-loopback address, api.token is required for security"),
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReflexConfig } from "./config.js";
function formatIntervalMs(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
if (totalSeconds < 60) return `${totalSeconds}s`;
const minutes = Math.floor(totalSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
/** Human-readable label for one sense reflex (interval and/or signal subscriptions). */
export function labelSenseReflexTrigger(reflex: Extract<ReflexConfig, { kind: "sense" }>): string {
const parts: string[] = [];
if (reflex.interval !== null) {
parts.push(`every ${formatIntervalMs(reflex.interval)}`);
}
if (reflex.on.length > 0) {
parts.push(`on: ${reflex.on.join(", ")}`);
}
if (parts.length === 0) {
return "reflex (no interval or on)";
}
return parts.join(" · ");
}
/** All reflex trigger descriptions that target the given sense name. */
export function senseTriggerLabels(senseName: string, reflexes: readonly ReflexConfig[]): string[] {
const out: string[] = [];
for (const ref of reflexes) {
if (ref.kind !== "sense" || ref.sense !== senseName) continue;
out.push(labelSenseReflexTrigger(ref));
}
return out;
}
+2
View File
@@ -11,6 +11,8 @@ export type SenseInfo = {
group: string;
throttle: number | null;
timeout: number | null;
/** Declarative reflex lines that schedule this sense (derived from nerve.yaml). */
triggers: string[];
lastSignalTimestamp: number | null;
};
+1
View File
@@ -17,5 +17,6 @@ export default defineConfig({
output: {
target: "node",
cleanDistPath: true,
copy: [{ from: "./src/dashboard.html", to: "./dashboard.html" }],
},
});
@@ -240,6 +240,7 @@ describe("daemon-ipc — list-senses", () => {
group: "system",
throttle: 5000,
timeout: 3000,
triggers: ["every 30s"],
lastSignalTimestamp: 1000,
},
{
@@ -247,6 +248,7 @@ describe("daemon-ipc — list-senses", () => {
group: "system",
throttle: 30000,
timeout: null,
triggers: [],
lastSignalTimestamp: null,
},
];
@@ -38,6 +38,16 @@ describe("createHttpApiServer — bearer auth", () => {
expect(body.version).toBe("0-test");
});
it("serves GET / dashboard HTML without Authorization when token is configured", async () => {
srv = createHttpApiServer({ port: 0, host: "127.0.0.1", token: "secret" }, handlers);
const { port } = await srv.ready();
const res = await fetch(`http://127.0.0.1:${String(port)}/`);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toMatch(/text\/html/);
const body = await res.text();
expect(body).toContain("Nerve");
});
it("returns 401 when token is configured and Authorization is missing", async () => {
srv = createHttpApiServer({ port: 0, host: "127.0.0.1", token: "secret" }, handlers);
const { port } = await srv.ready();
+569
View File
@@ -0,0 +1,569 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nerve daemon</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--ok: #3fb950;
--err: #f85149;
--warn: #d29922;
--font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
font-size: 13px;
line-height: 1.45;
background: var(--bg);
color: var(--text);
}
a { color: var(--accent); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 12px 14px 48px; }
header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 16px;
padding: 10px 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 14px;
}
header h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
}
.meta { color: var(--muted); font-size: 12px; }
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
vertical-align: middle;
}
.status-dot.ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.status-dot.bad { background: var(--err); box-shadow: 0 0 6px var(--err); }
.banner-err {
background: rgba(248, 81, 73, 0.12);
border: 1px solid var(--err);
color: #ffb4af;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 14px;
}
.auth-bar {
width: 100%;
flex-basis: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
}
.auth-bar label { color: var(--muted); }
.auth-bar input {
flex: 1;
min-width: 160px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 4px;
font-family: inherit;
font-size: 12px;
}
.btn {
font-family: inherit;
font-size: 12px;
padding: 5px 10px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
cursor: pointer;
}
.btn:hover { border-color: var(--muted); }
.btn-primary { border-color: var(--accent); color: var(--accent); }
.btn-danger { border-color: var(--err); color: var(--err); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 14px;
}
.card h2 {
margin: 0 0 10px;
font-size: 0.85rem;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.kv { display: grid; grid-template-columns: 120px 1fr; gap: 6px 12px; }
.kv .k { color: var(--muted); }
.loading-mask {
text-align: center;
padding: 28px;
color: var(--muted);
}
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
th { color: var(--muted); font-weight: 600; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
.run-row { font-size: 11px; color: var(--muted); margin-top: 4px; }
.run-row code { color: var(--text); font-size: 11px; }
.toast-wrap {
position: fixed;
bottom: 12px;
right: 12px;
left: 12px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
pointer-events: none;
z-index: 50;
}
.toast {
pointer-events: auto;
max-width: 420px;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--panel);
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
font-size: 12px;
}
.toast.ok { border-color: var(--ok); }
.toast.bad { border-color: var(--err); color: #ffb4af; }
@media (max-width: 640px) {
.kv { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Nerve</h1>
<span id="connLabel"><span class="status-dot bad" id="connDot"></span><span id="connText">Disconnected</span></span>
<span class="meta" id="headerMeta"></span>
<div class="auth-bar" id="authBar">
<label for="tokenInput">Bearer token</label>
<input id="tokenInput" type="password" autocomplete="off" placeholder="Optional — saved in this browser only" />
<button type="button" class="btn" id="tokenSave">Save</button>
</div>
</header>
<div id="errBanner" class="banner-err" style="display:none;"></div>
<div id="loadingBlock" class="card loading-mask">Loading daemon state…</div>
<div id="mainBlock" style="display:none;">
<section class="card" id="healthCard">
<h2>Health</h2>
<div class="kv" id="healthKv"></div>
</section>
<section class="card">
<h2>Senses</h2>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Triggers</th>
<th>Last signal</th>
<th></th>
</tr>
</thead>
<tbody id="sensesBody"></tbody>
</table>
</div>
</section>
<section class="card">
<h2>Workflows</h2>
<div class="table-scroll">
<table>
<thead>
<tr>
<th>Name</th>
<th>Active</th>
<th>Queued</th>
<th>Concurrency</th>
<th></th>
</tr>
</thead>
<tbody id="workflowsBody"></tbody>
</table>
</div>
</section>
</div>
</div>
<div class="toast-wrap" id="toasts"></div>
<script>
(function () {
'use strict';
var TOKEN_KEY = 'nerve-dashboard-api-token';
var pollMs = 5000;
function $(id) { return document.getElementById(id); }
function getToken() {
try {
return localStorage.getItem(TOKEN_KEY) || '';
} catch (e) {
return '';
}
}
function setToken(v) {
try {
if (v) localStorage.setItem(TOKEN_KEY, v);
else localStorage.removeItem(TOKEN_KEY);
} catch (e) { /* ignore */ }
}
function authHeaders() {
var t = getToken().trim();
if (!t) return {};
return { Authorization: 'Bearer ' + t };
}
function toast(msg, ok) {
var w = $('toasts');
if (!w) return;
var el = document.createElement('div');
el.className = 'toast ' + (ok ? 'ok' : 'bad');
el.textContent = msg;
w.appendChild(el);
setTimeout(function () {
el.remove();
}, 4500);
}
function fmtUptime(sec) {
if (typeof sec !== 'number' || sec < 0) return '—';
var d = Math.floor(sec / 86400);
var h = Math.floor((sec % 86400) / 3600);
var m = Math.floor((sec % 3600) / 60);
var s = sec % 60;
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
function fmtTime(ts) {
if (ts === null || ts === undefined) return '—';
try {
return new Date(ts).toLocaleString();
} catch (e) {
return String(ts);
}
}
function shortId(id) {
if (!id || id.length <= 10) return id;
return id.slice(0, 8) + '…';
}
function apiFetch(path, opts) {
opts = opts || {};
var h = Object.assign({ Accept: 'application/json' }, authHeaders(), opts.headers || {});
return fetch(path, Object.assign({}, opts, { headers: h }));
}
var lastOk = false;
var firstLoad = true;
function setConn(ok) {
lastOk = ok;
var dot = $('connDot');
var tx = $('connText');
if (dot) {
dot.className = 'status-dot ' + (ok ? 'ok' : 'bad');
}
if (tx) tx.textContent = ok ? 'Connected' : 'Disconnected';
}
function setErr(msg) {
var b = $('errBanner');
if (!b) return;
if (msg) {
b.style.display = 'block';
b.textContent = msg;
} else {
b.style.display = 'none';
b.textContent = '';
}
}
function renderHealth(h) {
var kv = $('healthKv');
if (!kv) return;
kv.innerHTML = '';
function row(k, v) {
var dk = document.createElement('div');
dk.className = 'k';
dk.textContent = k;
var dv = document.createElement('div');
dv.textContent = v;
kv.appendChild(dk);
kv.appendChild(dv);
}
row('Version', h.version != null ? String(h.version) : '—');
row('Uptime', fmtUptime(h.uptime));
row('Started', fmtTime(h.startedAt));
row('Hostname', h.hostname != null ? String(h.hostname) : '—');
}
function renderSenses(list) {
var tb = $('sensesBody');
if (!tb) return;
tb.innerHTML = '';
if (!list.length) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 5;
td.style.color = 'var(--muted)';
td.textContent = 'No senses registered.';
tr.appendChild(td);
tb.appendChild(tr);
return;
}
list.forEach(function (s) {
var tr = document.createElement('tr');
var triggers = (s.triggers && s.triggers.length) ? s.triggers.join('; ') : '—';
var last = s.lastSignalTimestamp != null ? fmtTime(s.lastSignalTimestamp) : '—';
tr.innerHTML =
'<td>' + escapeHtml(s.name) + '</td>' +
'<td>' + escapeHtml(String(s.group || '—')) + '</td>' +
'<td>' + escapeHtml(triggers) + '</td>' +
'<td>' + escapeHtml(last) + '</td>' +
'<td></td>';
var tdBtn = tr.querySelector('td:last-child');
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-primary';
b.textContent = 'Trigger';
b.addEventListener('click', function () { onTriggerSense(s.name); });
tdBtn.appendChild(b);
tb.appendChild(tr);
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderWorkflows(list) {
var tb = $('workflowsBody');
if (!tb) return;
tb.innerHTML = '';
if (!list.length) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 5;
td.style.color = 'var(--muted)';
td.textContent = 'No workflows registered.';
tr.appendChild(td);
tb.appendChild(tr);
return;
}
list.forEach(function (w) {
var tr = document.createElement('tr');
var conc = w.config ? String(w.config.concurrency) + ' / ' + String(w.config.overflow) : '—';
var activeCell = document.createElement('td');
var n = typeof w.activeThreads === 'number' ? w.activeThreads : 0;
activeCell.appendChild(document.createTextNode(String(n)));
var ids = Array.isArray(w.activeRunIds) ? w.activeRunIds : [];
ids.forEach(function (rid) {
var div = document.createElement('div');
div.className = 'run-row';
var code = document.createElement('code');
code.textContent = rid;
div.appendChild(code);
div.appendChild(document.createTextNode(' '));
var kb = document.createElement('button');
kb.type = 'button';
kb.className = 'btn btn-danger';
kb.style.marginLeft = '4px';
kb.textContent = 'Kill';
kb.addEventListener('click', function () { onKillWorkflow(rid, w.name); });
div.appendChild(kb);
activeCell.appendChild(div);
});
var trBtn = document.createElement('td');
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-primary';
b.textContent = 'Trigger';
b.addEventListener('click', function () { onTriggerWorkflow(w.name); });
trBtn.appendChild(b);
var nameTd = document.createElement('td');
nameTd.textContent = w.name || '—';
var qTd = document.createElement('td');
qTd.textContent = String(typeof w.queuedThreads === 'number' ? w.queuedThreads : 0);
var cTd = document.createElement('td');
cTd.textContent = conc;
tr.appendChild(nameTd);
tr.appendChild(activeCell);
tr.appendChild(qTd);
tr.appendChild(cTd);
tr.appendChild(trBtn);
tb.appendChild(tr);
});
}
function onTriggerSense(name) {
if (!window.confirm('Trigger sense "' + name + '"?')) return;
apiFetch('/api/trigger-sense', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name })
})
.then(function (r) { return r.json().then(function (j) { return { r: r, j: j }; }); })
.then(function (_ref) {
var r = _ref.r;
var j = _ref.j;
if (r.ok && j && j.ok) toast('Sense "' + name + '" triggered.', true);
else toast((j && j.error) ? String(j.error) : 'Trigger failed (' + r.status + ')', false);
})
.catch(function (e) {
toast(e && e.message ? e.message : 'Network error', false);
});
}
function onTriggerWorkflow(name) {
if (!window.confirm('Start workflow "' + name + '"?')) return;
apiFetch('/api/trigger-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, prompt: '', dryRun: false })
})
.then(function (r) { return r.json().then(function (j) { return { r: r, j: j }; }); })
.then(function (_ref2) {
var r = _ref2.r;
var j = _ref2.j;
if (r.ok && j && j.ok) toast('Workflow "' + name + '" started.', true);
else toast((j && j.error) ? String(j.error) : 'Start failed (' + r.status + ')', false);
})
.catch(function (e) {
toast(e && e.message ? e.message : 'Network error', false);
});
}
function onKillWorkflow(threadId, wfName) {
if (!window.confirm('Kill thread ' + shortId(threadId) + '?')) return;
apiFetch('/api/kill-workflow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ runId: threadId, name: wfName || null })
})
.then(function (r) { return r.json().then(function (j) { return { r: r, j: j }; }); })
.then(function (_ref3) {
var r = _ref3.r;
var j = _ref3.j;
if (r.ok && j && j.ok) toast('Thread killed.', true);
else toast((j && j.error) ? String(j.error) : 'Kill failed (' + r.status + ')', false);
})
.catch(function (e) {
toast(e && e.message ? e.message : 'Network error', false);
});
}
function refresh() {
return Promise.all([
apiFetch('/api/health'),
apiFetch('/api/senses'),
apiFetch('/api/workflows')
])
.then(function (responses) {
var bad = responses.some(function (r) { return !r.ok; });
if (bad) {
var st = responses[0] && responses[0].status;
setConn(false);
setErr('API unreachable or unauthorized (HTTP ' + (st || '?') + '). Retrying every ' + (pollMs / 1000) + 's…');
if (firstLoad) {
$('loadingBlock').style.display = 'block';
$('mainBlock').style.display = 'none';
}
return null;
}
return Promise.all(responses.map(function (r) { return r.json(); }));
})
.then(function (data) {
if (!data) return;
var health = data[0];
var senses = data[1];
var workflows = data[2];
setConn(true);
setErr('');
firstLoad = false;
$('loadingBlock').style.display = 'none';
$('mainBlock').style.display = 'block';
var hm = $('headerMeta');
if (hm && health) {
hm.textContent =
(health.hostname || '—') +
' · v' + (health.version || '—') +
' · uptime ' + fmtUptime(health.uptime);
}
renderHealth(health || {});
renderSenses((senses && senses.senses) || []);
renderWorkflows((workflows && workflows.workflows) || []);
})
.catch(function () {
setConn(false);
setErr('Network error. Retrying…');
if (firstLoad) {
$('loadingBlock').style.display = 'block';
$('mainBlock').style.display = 'none';
}
});
}
function init() {
var inp = $('tokenInput');
if (inp) inp.value = getToken();
$('tokenSave').addEventListener('click', function () {
setToken(inp.value.trim());
toast('Token saved locally.', true);
refresh();
});
refresh();
setInterval(refresh, pollMs);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
</script>
</body>
</html>
+16 -11
View File
@@ -10,6 +10,7 @@ import { type IncomingMessage, type Server, type ServerResponse, createServer }
import { isPlainRecord } from "@uncaged/nerve-core";
import type { DaemonHandlerBundle } from "./daemon-handlers.js";
import { getDashboardHtml } from "./load-dashboard-html.js";
const MAX_REQUEST_BODY_BYTES = 1024 * 1024;
@@ -135,6 +136,17 @@ export function createHttpApiServer(
return;
}
const url = req.url ?? "";
const path = url.split("?")[0] ?? "";
if (req.method === "GET" && path === "/") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.end(getDashboardHtml());
return;
}
if (expectedToken !== null) {
const presented = extractBearerToken(req.headers.authorization);
if (!bearerTokenMatches(expectedToken, presented)) {
@@ -143,9 +155,6 @@ export function createHttpApiServer(
}
}
const url = req.url ?? "";
const path = url.split("?")[0] ?? "";
try {
if (req.method === "GET" && path === "/api/health") {
sendJson(res, 200, handlers.health());
@@ -217,14 +226,10 @@ export function createHttpApiServer(
if (req.method === "POST" && path === "/api/kill-workflow") {
const raw = await readRequestBody(req, res);
const body = parseJsonBody(raw);
if (
!isPlainRecord(body) ||
typeof body.threadId !== "string" ||
body.threadId.length === 0
) {
if (!isPlainRecord(body) || typeof body.runId !== "string" || body.runId.length === 0) {
sendJson(res, 400, {
ok: false,
error: 'Expected JSON body: { "threadId": string, "name"?: string }',
error: 'Expected JSON body: { "runId": string, "name"?: string }',
});
return;
}
@@ -235,10 +240,10 @@ export function createHttpApiServer(
const nameForLog = typeof body.name === "string" && body.name.length > 0 ? body.name : null;
if (nameForLog !== null) {
process.stderr.write(
`[http-api] kill-workflow threadId=${body.threadId} workflowName=${nameForLog}\n`,
`[http-api] kill-workflow runId=${body.runId} workflowName=${nameForLog}\n`,
);
}
const result = handlers.killWorkflowByRunId(body.threadId);
const result = handlers.killWorkflowByRunId(body.runId);
sendJson(res, result.ok ? 200 : 400, result);
return;
}
+8 -1
View File
@@ -8,7 +8,13 @@ import { hostname } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { HealthInfo, NerveConfig, SenseInfo, Signal } from "@uncaged/nerve-core";
import {
type HealthInfo,
type NerveConfig,
type SenseInfo,
type Signal,
senseTriggerLabels,
} from "@uncaged/nerve-core";
import { routeSenseComputeOutput } from "@uncaged/nerve-core";
import { createLogStore } from "@uncaged/nerve-store";
@@ -371,6 +377,7 @@ export function createKernel(
group: senseConfig.group,
throttle: senseConfig.throttle,
timeout: senseConfig.timeout,
triggers: senseTriggerLabels(name, config.reflexes),
lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null,
};
});
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
let cached: string | null = null;
/** Single-file dashboard HTML; file lives beside this module (src/ dev, dist/ release). */
export function getDashboardHtml(): string {
if (cached !== null) {
return cached;
}
const dir = dirname(fileURLToPath(import.meta.url));
cached = readFileSync(join(dir, "dashboard.html"), "utf8");
return cached;
}
+4
View File
@@ -689,9 +689,13 @@ export function createWorkflowManager(
for (const name of names) {
const wf = config.workflows[name];
if (wf === undefined) continue;
const state = states.get(name);
const activeRunIds =
state !== undefined ? [...state.active].sort((a, b) => a.localeCompare(b)) : [];
out.push({
name,
activeThreads: activeCount(name),
activeRunIds,
queuedThreads: queueLength(name),
config: { concurrency: wf.concurrency, overflow: wf.overflow },
});