feat(dashboard): Phase 3 — embedded web dashboard

- Single-file dark-theme HTML dashboard (569 lines, zero deps)
- GET / serves dashboard HTML (no auth required, token handled in JS)
- Auto-poll every 5s: health, senses, workflows
- Trigger/Kill buttons with confirmation + toast notifications
- Bearer token input persisted in localStorage
- Connection status indicator (green/red dot)
- Responsive layout for mobile
- SenseInfo gains triggers[] field, WorkflowStatus gains activeRunIds[]
- rslib copies dashboard.html to dist/

Refs #133
This commit is contained in:
2026-04-25 15:01:11 +08:00
parent a4073415b1
commit 69e50d8339
16 changed files with 688 additions and 6 deletions
+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,
+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;
};