feat(dashboard): Phase 3 — embedded web dashboard #138
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user