@homeostat/dashboard 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
1
+ import { CORE_AREA, detectConflicts, nextRun, STATUSES, } from "@homeostat/core";
2
+ const DAY = 86_400_000;
3
+ const day = (d) => d.toISOString().slice(0, 10);
4
+ const daysSince = (iso, now) => Math.floor((now.getTime() - Date.parse(iso)) / DAY);
5
+ export function agentLiveness({ config, events, now }) {
6
+ const week = new Date(now.getTime() - 7 * DAY).toISOString();
7
+ return ["curator", "deepDive"].map((agent) => {
8
+ const runs = events.filter((e) => e.type === "agent-run" && e.agent === agent);
9
+ const last = runs.at(-1);
10
+ const success = runs.filter((r) => r.ok === true).at(-1);
11
+ const next = config.agents[agent].enabled
12
+ ? nextRun(config.agents[agent].schedule, now)
13
+ : undefined;
14
+ return {
15
+ agent,
16
+ enabled: config.agents[agent].enabled,
17
+ schedule: config.agents[agent].schedule,
18
+ ...(next ? { nextRun: next.toISOString() } : {}),
19
+ ...(last
20
+ ? { lastRun: { ts: last.ts, ok: last.ok === true, summary: String(last.summary ?? "") } }
21
+ : {}),
22
+ ...(success ? { lastSuccess: success.ts } : {}),
23
+ failures7d: runs.filter((r) => r.ok === false && r.ts >= week).length,
24
+ };
25
+ });
26
+ }
27
+ export function health({ config, entries, now }) {
28
+ const byStatus = Object.fromEntries(STATUSES.map((s) => [s, 0]));
29
+ for (const e of entries)
30
+ byStatus[e.frontmatter.status]++;
31
+ const accepted = entries.filter((e) => e.frontmatter.status === "accepted");
32
+ const staleAfter = config.agents.deepDive.staleAfterDays;
33
+ const stale = accepted.filter((e) => daysSince(e.frontmatter.lastConfirmed, now) > staleAfter);
34
+ const flagged = entries.filter((e) => e.frontmatter.flags?.length);
35
+ const healthy = accepted.filter((e) => !stale.includes(e) && !e.frontmatter.flags?.length);
36
+ return {
37
+ total: entries.length,
38
+ byStatus,
39
+ averageAgeDays: accepted.length
40
+ ? Math.round(accepted.reduce((n, e) => n + daysSince(e.frontmatter.lastConfirmed, now), 0) /
41
+ accepted.length)
42
+ : 0,
43
+ stale: stale.map((e) => e.frontmatter.id),
44
+ disputed: entries
45
+ .filter((e) => e.frontmatter.status === "disputed")
46
+ .map((e) => e.frontmatter.id),
47
+ flagged: flagged.map((e) => e.frontmatter.id),
48
+ possibleDuplicates: detectConflicts(entries.map((e) => e.frontmatter))
49
+ .filter((c) => c.kind === "possible-duplicate")
50
+ .map((c) => [c.ids[0] ?? "", c.ids[1] ?? ""]),
51
+ stability: entries.length ? healthy.length / entries.length : 0,
52
+ };
53
+ }
54
+ export function contributions({ config, entries, events, now }) {
55
+ const month = new Date(now.getTime() - 30 * DAY).toISOString();
56
+ return [...config.team.members]
57
+ .sort((a, b) => a.id.localeCompare(b.id))
58
+ .map((m) => {
59
+ const authored = entries.filter((e) => e.frontmatter.source.author === m.id);
60
+ const accepted = authored.filter((e) => e.frontmatter.status === "accepted").length;
61
+ return {
62
+ member: m.id,
63
+ role: m.role,
64
+ authored: authored.length,
65
+ accepted,
66
+ ...(authored.length ? { acceptedRatio: accepted / authored.length } : {}),
67
+ flagsRaised: entries.reduce((n, e) => n + (e.frontmatter.flags ?? []).filter((f) => f.by === m.id).length, 0),
68
+ captured30d: events.filter((e) => e.type === "entry-created" && e.author === m.id && e.ts >= month).length,
69
+ };
70
+ });
71
+ }
72
+ export function coverage({ config, entries }) {
73
+ const areas = [CORE_AREA, ...Object.keys(config.areas)].map((area) => {
74
+ const inArea = entries.filter((e) => e.frontmatter.area === area);
75
+ return {
76
+ area,
77
+ globs: area === CORE_AREA ? ["**"] : (config.areas[area] ?? []),
78
+ accepted: inArea.filter((e) => e.frontmatter.status === "accepted").length,
79
+ candidates: inArea.filter((e) => e.frontmatter.status === "candidate").length,
80
+ };
81
+ });
82
+ const sources = (config.sources?.repos ?? []).map((repo) => ({
83
+ repo,
84
+ entries: entries.filter((e) => e.frontmatter.source.url?.toLowerCase().includes(`/${repo.toLowerCase()}/`)).length,
85
+ }));
86
+ return { areas, sources };
87
+ }
88
+ function byDay(events, type, now, days) {
89
+ const out = [];
90
+ for (let i = days - 1; i >= 0; i--) {
91
+ const d = day(new Date(now.getTime() - i * DAY));
92
+ out.push({
93
+ day: d,
94
+ count: events.filter((e) => e.type === type && e.ts.slice(0, 10) === d).length,
95
+ });
96
+ }
97
+ return out;
98
+ }
99
+ export function impact({ events, now }, days = 14) {
100
+ const injections = events.filter((e) => e.type === "injection");
101
+ const ids = injections.flatMap((e) => (e.entries ?? []).map((x) => x.id));
102
+ const counts = new Map();
103
+ for (const id of ids)
104
+ counts.set(id, (counts.get(id) ?? 0) + 1);
105
+ const tools = new Map();
106
+ for (const e of events.filter((x) => x.type === "mcp-call"))
107
+ tools.set(String(e.tool), (tools.get(String(e.tool)) ?? 0) + 1);
108
+ return {
109
+ injectionsByDay: byDay(events, "injection", now, days),
110
+ mcpCallsByDay: byDay(events, "mcp-call", now, days),
111
+ sessions: new Set(injections.map((e) => e.session).filter(Boolean)).size,
112
+ rulesPerInjection: injections.length
113
+ ? Math.round((ids.length / injections.length) * 10) / 10
114
+ : 0,
115
+ tokensPerInjection: injections.length
116
+ ? Math.round(injections.reduce((n, e) => n + (Number(e.tokens) || 0), 0) / injections.length)
117
+ : 0,
118
+ topRules: [...counts]
119
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
120
+ .slice(0, 8)
121
+ .map(([id, n]) => ({ id, injections: n })),
122
+ toolUse: [...tools].sort((a, b) => b[1] - a[1]).map(([tool, calls]) => ({ tool, calls })),
123
+ };
124
+ }
125
+ /** Tool names agents guessed: the ones to consider implementing (execution plan 3.2). */
126
+ export function desirePaths({ events }) {
127
+ const paths = new Map();
128
+ for (const e of events) {
129
+ if (e.type !== "mcp-alias" && e.type !== "mcp-unknown-tool")
130
+ continue;
131
+ const name = String(e.name);
132
+ const p = paths.get(name) ?? {
133
+ name,
134
+ count: 0,
135
+ last: e.ts,
136
+ ...(e.resolved ? { resolved: String(e.resolved) } : {}),
137
+ };
138
+ p.count++;
139
+ p.last = e.ts > p.last ? e.ts : p.last;
140
+ paths.set(name, p);
141
+ }
142
+ return [...paths.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
143
+ }
@@ -0,0 +1,17 @@
1
+ import { type Server } from "node:http";
2
+ export interface DashboardOptions {
3
+ kbRoot: string;
4
+ port?: number;
5
+ /** Interface to bind; loopback by default so the dashboard is never on the network. */
6
+ host?: string;
7
+ now?: () => Date;
8
+ }
9
+ /**
10
+ * The local dashboard: setup forms and metrics, rendered on the server from the KB checkout and
11
+ * its event log on every request, so it is always live. Binds to loopback and rejects requests
12
+ * whose Host or Origin is not this server, so other web pages cannot drive its forms.
13
+ */
14
+ export declare function startDashboard(opts: DashboardOptions): Promise<{
15
+ server: Server;
16
+ url: string;
17
+ }>;
@@ -0,0 +1,180 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { join } from "node:path";
5
+ import { CONFIG_FILE, readEvents, readKbFiles, sectionUsage, validate, } from "@homeostat/core";
6
+ import { automationPage, automationView, readAutomation } from "./automation.js";
7
+ import { agentLiveness, contributions, coverage, desirePaths, health, impact, } from "./metrics.js";
8
+ import { applySection, SECTIONS } from "./settings.js";
9
+ import { coveragePage, desirePathsPage, documentsPage, entriesPage, entryPage, eventsPage, impactPage, layout, membersPage, messagePage, overviewPage, settingsIndex, settingsPage, } from "./views.js";
10
+ const MAX_BODY = 256 * 1024;
11
+ function readBody(req) {
12
+ return new Promise((resolve, reject) => {
13
+ let size = 0;
14
+ const chunks = [];
15
+ req.on("data", (c) => {
16
+ size += c.length;
17
+ if (size > MAX_BODY) {
18
+ reject(new Error("body too large"));
19
+ req.destroy();
20
+ }
21
+ else
22
+ chunks.push(c);
23
+ });
24
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
25
+ req.on("error", reject);
26
+ });
27
+ }
28
+ const sameToken = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
29
+ /**
30
+ * The local dashboard: setup forms and metrics, rendered on the server from the KB checkout and
31
+ * its event log on every request, so it is always live. Binds to loopback and rejects requests
32
+ * whose Host or Origin is not this server, so other web pages cannot drive its forms.
33
+ */
34
+ export function startDashboard(opts) {
35
+ const host = opts.host ?? "127.0.0.1";
36
+ const token = randomBytes(24).toString("hex");
37
+ const now = opts.now ?? (() => new Date());
38
+ // DNS-rebinding protection keys on the hostname: a browser only sends one of these when it
39
+ // really is talking to this machine. Any port, so a container's port mapping still works.
40
+ const allowedNames = new Set([
41
+ "localhost",
42
+ "127.0.0.1",
43
+ "[::1]",
44
+ ...(host === "0.0.0.0" ? [] : [host]),
45
+ ]);
46
+ const sameSite = (origin) => {
47
+ try {
48
+ return allowed(new URL(origin).host);
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ };
54
+ const allowed = (hostPort) => {
55
+ try {
56
+ return Boolean(hostPort) && allowedNames.has(new URL(`http://${hostPort}`).hostname);
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ };
62
+ const snapshot = () => {
63
+ const kb = validate(readKbFiles(opts.kbRoot));
64
+ if (!kb.config)
65
+ return undefined;
66
+ const since = new Date(now().getTime() - 90 * 86_400_000).toISOString();
67
+ return {
68
+ config: kb.config,
69
+ entries: kb.entries.map((e) => e.entry),
70
+ docs: kb.docs,
71
+ events: readEvents(opts.kbRoot, { since }),
72
+ now: now(),
73
+ };
74
+ };
75
+ const send = (res, status, html) => {
76
+ res.writeHead(status, {
77
+ "content-type": "text/html; charset=utf-8",
78
+ "cache-control": "no-store",
79
+ "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'",
80
+ "x-content-type-options": "nosniff",
81
+ // same-origin, not no-referrer: under no-referrer Chrome sends "Origin: null" on this
82
+ // page's own form posts, which the write check below must refuse.
83
+ "referrer-policy": "same-origin",
84
+ });
85
+ res.end(html);
86
+ };
87
+ const handler = async (req, res) => {
88
+ // DNS-rebinding and cross-site protection: only our own Host, and our own Origin on writes.
89
+ if (!allowed(req.headers.host))
90
+ return send(res, 421, "Misdirected request");
91
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
92
+ const t = url.searchParams.get("theme");
93
+ const theme = t === "light" || t === "dark" ? { theme: t } : {};
94
+ const page = (title, body, status = 200) => send(res, status, layout({ title, path: url.pathname, kbRoot: opts.kbRoot, body, ...theme }));
95
+ const snap = snapshot();
96
+ if (!snap) {
97
+ return page("No knowledge base", messagePage("No knowledge base", `${CONFIG_FILE} is missing or invalid in ${opts.kbRoot}. Run homeostat validate there.`), 500);
98
+ }
99
+ if (req.method === "POST") {
100
+ // Our own pages send their origin; "null" or anything unreadable is refused, never parsed blind.
101
+ const origin = req.headers.origin;
102
+ if (origin && !sameSite(origin))
103
+ return send(res, 403, "Cross-origin request refused");
104
+ const m = /^\/settings\/([a-z]+)$/.exec(url.pathname);
105
+ const section = m && SECTIONS.find((s) => s.id === m[1]);
106
+ if (!section)
107
+ return page("Not found", messagePage("Not found", "No such settings section."), 404);
108
+ let form;
109
+ try {
110
+ form = new URLSearchParams(await readBody(req));
111
+ }
112
+ catch {
113
+ return send(res, 413, "Too large");
114
+ }
115
+ if (!sameToken(form.get("_token") ?? "", token))
116
+ return send(res, 403, "Stale form: reload the page and try again");
117
+ const path = join(opts.kbRoot, CONFIG_FILE);
118
+ const result = applySection(readFileSync(path, "utf8"), section.id, form);
119
+ if (!result.text)
120
+ return page(section.title, settingsPage(section, snap.config, token, { issues: result.issues }), 422);
121
+ writeFileSync(path, result.text);
122
+ const fresh = snapshot();
123
+ return page(section.title, settingsPage(section, fresh?.config ?? snap.config, token, { saved: true }));
124
+ }
125
+ if (req.method !== "GET")
126
+ return send(res, 405, "Method not allowed");
127
+ const week = new Date(snap.now.getTime() - 7 * 86_400_000).toISOString();
128
+ switch (url.pathname) {
129
+ case "/": {
130
+ const recent = snap.events.slice(-12).reverse();
131
+ return page("Overview", overviewPage(health(snap), agentLiveness(snap), impact(snap), recent));
132
+ }
133
+ case "/automation":
134
+ return page("Automation", automationPage(automationView(readAutomation(opts.kbRoot), snap.config, snap.now)));
135
+ case "/entries":
136
+ return page("Entries", entriesPage(snap.entries, url.searchParams.get("status")));
137
+ case "/members":
138
+ return page("Members", membersPage(contributions(snap)));
139
+ case "/coverage":
140
+ return page("Coverage", coveragePage(coverage(snap)));
141
+ case "/impact":
142
+ return page("Impact", impactPage(impact(snap)));
143
+ case "/documents":
144
+ return page("Documents", documentsPage(sectionUsage(snap.docs ?? [], snap.events, snap.now)));
145
+ case "/desire-paths":
146
+ return page("Desire paths", desirePathsPage(desirePaths(snap)));
147
+ case "/events":
148
+ return page("Events", eventsPage(snap.events.filter((e) => e.ts >= week).reverse()));
149
+ case "/settings":
150
+ return page("Settings", settingsIndex());
151
+ }
152
+ const entry = /^\/entries\/([a-z0-9-]+)$/.exec(url.pathname);
153
+ if (entry) {
154
+ const found = snap.entries.find((e) => e.frontmatter.id === entry[1]);
155
+ return found
156
+ ? page(found.frontmatter.title, entryPage(found, snap.config))
157
+ : page("Not found", messagePage("Not found", `No entry with id ${entry[1]}.`), 404);
158
+ }
159
+ const settings = /^\/settings\/([a-z]+)$/.exec(url.pathname);
160
+ const section = settings && SECTIONS.find((s) => s.id === settings[1]);
161
+ if (section)
162
+ return page(section.title, settingsPage(section, snap.config, token));
163
+ return page("Not found", messagePage("Not found", "Nothing here."), 404);
164
+ };
165
+ const server = createServer((req, res) => {
166
+ handler(req, res).catch((e) => {
167
+ send(res, 500, `Dashboard error: ${String(e.message).replace(/</g, "&lt;")}`);
168
+ });
169
+ });
170
+ return new Promise((resolve) => {
171
+ server.listen(opts.port ?? 4747, host, () => {
172
+ const address = server.address();
173
+ const port = typeof address === "object" && address ? address.port : (opts.port ?? 4747);
174
+ resolve({
175
+ server,
176
+ url: `http://${host === "127.0.0.1" || host === "0.0.0.0" ? "localhost" : host}:${port}`,
177
+ });
178
+ });
179
+ });
180
+ }
@@ -0,0 +1,32 @@
1
+ import { type Config, type Issue } from "@homeostat/core";
2
+ /** One editable config key. `path` is its location in homeostat.yaml. */
3
+ export interface Field {
4
+ path: string[];
5
+ label: string;
6
+ kind: "text" | "number" | "select" | "checkbox" | "checks" | "lines";
7
+ options?: readonly string[];
8
+ help?: string;
9
+ /** Empty means "remove the key" rather than an empty value. */
10
+ optional?: boolean;
11
+ }
12
+ export interface Section {
13
+ id: string;
14
+ title: string;
15
+ intro: string;
16
+ fields?: Field[];
17
+ /** Row editors for the two keyed lists. */
18
+ rows?: "team" | "areas" | "teams";
19
+ }
20
+ /** Every section of homeostat.yaml, so every key is editable without touching YAML (4.2). */
21
+ export declare const SECTIONS: Section[];
22
+ /** The current value of a field, from the parsed (defaulted) config. */
23
+ export declare function currentValue(config: Config, field: Field): unknown;
24
+ export interface ApplyResult {
25
+ text?: string;
26
+ issues: Issue[];
27
+ }
28
+ /**
29
+ * Applies one submitted section to the YAML text, keeping comments and untouched keys, and
30
+ * validates the result. Returns the new text only when it is valid.
31
+ */
32
+ export declare function applySection(yamlText: string, sectionId: string, form: URLSearchParams): ApplyResult;