@homeostat/dashboard 0.1.6 → 0.1.7
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.
- package/dist/src/automation.d.ts +28 -0
- package/dist/src/automation.js +138 -18
- package/dist/src/charts.d.ts +19 -0
- package/dist/src/charts.js +71 -1
- package/dist/src/server.js +36 -8
- package/dist/src/views.d.ts +30 -3
- package/dist/src/views.js +490 -182
- package/package.json +2 -2
package/dist/src/automation.d.ts
CHANGED
|
@@ -42,3 +42,31 @@ export interface AutomationView {
|
|
|
42
42
|
}
|
|
43
43
|
export declare function automationView(state: AutomationState, config: Config, now: Date): AutomationView;
|
|
44
44
|
export declare function automationPage(v: AutomationView): string;
|
|
45
|
+
/** One lamp on the console: what state the team's knowledge loop is in, at a glance. */
|
|
46
|
+
export interface Lamp {
|
|
47
|
+
key: "daily" | "queue" | "sync" | "kb";
|
|
48
|
+
label: string;
|
|
49
|
+
state: "good" | "warning" | "critical" | "off";
|
|
50
|
+
value: string;
|
|
51
|
+
/** A few words under the reading. */
|
|
52
|
+
detail: string;
|
|
53
|
+
/** What this unit measures and what to do about it: the (i) text. */
|
|
54
|
+
about: string;
|
|
55
|
+
href: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The four lamps every page shows: the daily run, the review queue, developer sync and the
|
|
59
|
+
* knowledge base. `nextRun` comes from the schedule; everything else from the state branch.
|
|
60
|
+
*/
|
|
61
|
+
export declare function lamps(v: AutomationView, kb: {
|
|
62
|
+
stability: number;
|
|
63
|
+
accepted: number;
|
|
64
|
+
flagged: number;
|
|
65
|
+
disputed: number;
|
|
66
|
+
}, now: Date, nextRun?: string): Lamp[];
|
|
67
|
+
/** The first name in this checkout's git identity, for the greeting; undefined when unset. */
|
|
68
|
+
export declare function firstName(kbRoot: string): string | undefined;
|
|
69
|
+
/** Every lesson decided across the recorded runs, newest first. */
|
|
70
|
+
export declare function decidedLessons(state: AutomationState): (RunReport["lessons"][number] & {
|
|
71
|
+
at: string;
|
|
72
|
+
})[];
|
package/dist/src/automation.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { REVIEW_REMINDER_DAYS, STALE_HEARTBEAT_HOURS, STATE_BRANCH, } from "@homeostat/core";
|
|
3
|
-
import { columns, dataTable, esc } from "./charts.js";
|
|
2
|
+
import { HEARTBEAT_PATH, REVIEW_REMINDER_DAYS, STALE_HEARTBEAT_HOURS, STATE_BRANCH, } from "@homeostat/core";
|
|
3
|
+
import { columns, dataTable, esc, info } from "./charts.js";
|
|
4
4
|
const REF = `refs/remotes/origin/${STATE_BRANCH}`;
|
|
5
|
+
const CANDIDATES = "refs/remotes/origin/candidates/";
|
|
5
6
|
function git(dir, ...args) {
|
|
6
7
|
try {
|
|
7
8
|
return execFileSync("git", ["-C", dir, ...args], {
|
|
@@ -32,7 +33,28 @@ export function readAutomation(kbRoot, maxRuns = 30) {
|
|
|
32
33
|
.sort()
|
|
33
34
|
.slice(-maxRuns)
|
|
34
35
|
.flatMap((p) => json(kbRoot, p) ?? []);
|
|
35
|
-
|
|
36
|
+
// The daily run's copy of each heartbeat is as old as the run; each developer's candidates
|
|
37
|
+
// branch has the latest one their sync pushed (sync fetches them all). Keep the newer.
|
|
38
|
+
const latest = new Map();
|
|
39
|
+
const fresher = (h) => {
|
|
40
|
+
if (!h?.member)
|
|
41
|
+
return;
|
|
42
|
+
const known = latest.get(h.member);
|
|
43
|
+
if (!known || h.at > known.at)
|
|
44
|
+
latest.set(h.member, h);
|
|
45
|
+
};
|
|
46
|
+
for (const p of under("heartbeats"))
|
|
47
|
+
fresher(json(kbRoot, p));
|
|
48
|
+
const branches = git(kbRoot, "for-each-ref", "--format=%(refname)", CANDIDATES) ?? "";
|
|
49
|
+
for (const ref of branches.split("\n").filter(Boolean)) {
|
|
50
|
+
try {
|
|
51
|
+
fresher(JSON.parse(git(kbRoot, "show", `${ref}:${HEARTBEAT_PATH}`) ?? ""));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// A candidates branch without a heartbeat: skip it.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const heartbeats = [...latest.values()];
|
|
36
58
|
const decisions = under("decisions").flatMap((p) => json(kbRoot, p) ?? []);
|
|
37
59
|
return { present: true, runs, heartbeats, decisions };
|
|
38
60
|
}
|
|
@@ -41,7 +63,11 @@ export function automationView(state, config, now) {
|
|
|
41
63
|
const health = config.team.members.map((m) => {
|
|
42
64
|
const heartbeat = state.heartbeats.find((h) => h.member === m.id);
|
|
43
65
|
const ageHours = heartbeat ? (now.getTime() - Date.parse(heartbeat.at)) / 3_600_000 : undefined;
|
|
44
|
-
|
|
66
|
+
// The latest run that had lessons from this developer, not just the latest run.
|
|
67
|
+
const today = [...state.runs]
|
|
68
|
+
.reverse()
|
|
69
|
+
.map((r) => r.members.find((x) => x.member === m.id))
|
|
70
|
+
.find((x) => x !== undefined);
|
|
45
71
|
return {
|
|
46
72
|
member: m.id,
|
|
47
73
|
role: m.role,
|
|
@@ -59,12 +85,19 @@ export function automationView(state, config, now) {
|
|
|
59
85
|
return { ...r, member: d.member, ageDays, overdue: ageDays > REVIEW_REMINDER_DAYS };
|
|
60
86
|
}))
|
|
61
87
|
.sort((a, b) => b.ageDays - a.ageDays);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
// A day can have several runs (the schedule, a manual run): one bar per day.
|
|
89
|
+
const days = new Map();
|
|
90
|
+
for (const r of state.runs) {
|
|
91
|
+
const day = r.at.slice(0, 10);
|
|
92
|
+
const d = days.get(day) ?? { day, merged: 0, review: 0, declined: 0 };
|
|
93
|
+
for (const m of r.members) {
|
|
94
|
+
d.merged += m.merged;
|
|
95
|
+
d.review += m.review;
|
|
96
|
+
d.declined += m.declined;
|
|
97
|
+
}
|
|
98
|
+
days.set(day, d);
|
|
99
|
+
}
|
|
100
|
+
const byDay = [...days.values()];
|
|
68
101
|
return {
|
|
69
102
|
present: state.present,
|
|
70
103
|
...(lastRun ? { lastRun } : {}),
|
|
@@ -93,13 +126,13 @@ function healthRow(h) {
|
|
|
93
126
|
const kb = !hb ? "—" : hb.behind > 0 ? `${hb.behind} behind` : "current";
|
|
94
127
|
const t = h.today;
|
|
95
128
|
const outcome = t
|
|
96
|
-
? `${t.merged} merged
|
|
129
|
+
? `${t.merged} merged, ${t.review} to review, ${t.declined} declined${t.mergeError ? ", merge refused" : ""}`
|
|
97
130
|
: "—";
|
|
98
131
|
return `<tr><td class="mono">${esc(h.member)}</td><td>${esc(h.role)}</td><td><span class="lamp ${lamp}" aria-hidden="true" style="display:inline-block;vertical-align:middle;margin-right:6px"></span>${esc(state)}</td><td class="num">${age(h.ageHours)}</td><td class="mono">${esc(kb)}</td><td class="num">${hb?.pending ?? "—"}</td><td class="mono">${esc(hb?.version ?? "—")}</td><td>${esc(outcome)} ${prLink(t?.dailyPr, "daily")} ${prLink(t?.reviewPr, "review")}</td></tr>`;
|
|
99
132
|
}
|
|
100
133
|
export function automationPage(v) {
|
|
101
134
|
if (!v.present) {
|
|
102
|
-
return `<section class="panel"><h2>Automation
|
|
135
|
+
return `<section class="panel"><h2>Automation${info(`No daily run on record in this clone yet. The daily run (<code>homeostat daily</code>, scheduled in the KB repo's CI) writes its decisions to the <code>${STATE_BRANCH}</code> branch, and each developer's sync fetches it. Once the first run has happened and this clone has synced, its runs, every developer's sync health and the review queue show up here.`)}</h2><p class="empty">No daily run yet.</p></section>`;
|
|
103
136
|
}
|
|
104
137
|
const r = v.lastRun;
|
|
105
138
|
const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
|
|
@@ -113,21 +146,21 @@ export function automationPage(v) {
|
|
|
113
146
|
}));
|
|
114
147
|
return `<div class="grid">
|
|
115
148
|
<section class="panel span-8" aria-labelledby="last"><h2 id="last">Last daily run</h2>
|
|
116
|
-
${r ? `<p class="lede
|
|
149
|
+
${r ? `<p class="lede">${esc(r.at.replace("T", " ").slice(0, 16))} UTC</p>` : ""}
|
|
117
150
|
<div class="stats" style="--cols: 4">${stat(total("merged"), "merged")}${stat(total("review"), "to review")}${stat(total("declined"), "declined")}${stat(r?.settled ?? 0, "settled by reviewers")}</div>
|
|
118
|
-
${daily.length > 1 ? `<div style="margin-top:14px"><span class="label">Lessons decided per
|
|
151
|
+
${daily.length > 1 ? `<div style="margin-top:14px"><span class="label">Lessons decided per day</span>${columns(daily, { title: "Lessons decided per day", labelled: "ends", tickEvery: 7 })}${dataTable(daily, ["Day", "Lessons"])}</div>` : ""}
|
|
119
152
|
${r?.skipped?.length ? `<details style="margin-top:12px"><summary>${r.skipped.length} review thread${r.skipped.length === 1 ? "" : "s"} read with no lesson</summary><ul>${r.skipped.map((s) => `<li>${prLink(s.url, s.url.replace(/^https:\/\/[^/]+\//, ""))}: ${esc(s.reason)}</li>`).join("")}</ul></details>` : ""}</section>
|
|
120
153
|
<section class="panel span-4" aria-labelledby="attention"><h2 id="attention">Needs attention</h2>
|
|
121
154
|
<div class="stats" style="--cols: 2">${stat(v.queue.length, "in review")}${stat(overdue, `waiting > ${REVIEW_REMINDER_DAYS} days`)}${stat(notSyncing.length, "not syncing")}${stat(r?.members.filter((m) => m.mergeError).length ?? 0, "merges refused")}</div>
|
|
122
|
-
${notSyncing.length ? `<p
|
|
123
|
-
<section class="panel span-12" aria-labelledby="queue"><h2 id="queue">Review queue
|
|
155
|
+
${notSyncing.length ? `<p>Not syncing: ${notSyncing.map((h) => `<span class="mono">${esc(h.member)}</span>`).join(", ")}${info("Their lessons reach the daily run only after one of their sessions syncs. Check that the Homeostat plugin is installed for them.")}</p>` : ""}</section>
|
|
156
|
+
<section class="panel span-12" aria-labelledby="queue"><h2 id="queue">Review queue${info(`Lessons the daily run could not decide on its own. Merging the review PR accepts them; closing it declines them. The next daily run records the outcome, and the developer's sync picks it up.`)}</h2>
|
|
124
157
|
${v.queue.length
|
|
125
158
|
? `<div class="table-wrap"><table><thead><tr><th>Lesson</th><th>Developer</th><th class="num">Confidence</th><th>Why it waits</th><th class="num">Waiting</th><th>PR branch</th></tr></thead><tbody>${v.queue
|
|
126
159
|
.map((q) => `<tr><td class="mono">${esc(q.id)}</td><td class="mono">${esc(q.member)}</td><td class="num">${q.confidence ?? "—"}</td><td>${esc(q.reason)}</td><td class="num">${q.overdue ? '<span class="chip disputed">' : ""}${Math.floor(q.ageDays)}d${q.overdue ? "</span>" : ""}</td><td class="mono">${esc(q.pr ?? "—")}</td></tr>`)
|
|
127
160
|
.join("")}</tbody></table></div>`
|
|
128
161
|
: '<p class="empty">Nothing waiting for a reviewer.</p>'}</section>
|
|
129
|
-
<section class="panel span-12" aria-labelledby="health"><h2 id="health">Developer sync
|
|
130
|
-
<div class="table-wrap"><table><thead><tr><th>Member</th><th>Role</th><th>Sync</th><th class="num">Last report</th><th>KB clone</th><th class="num">Pending</th><th>Version</th><th>Last
|
|
162
|
+
<section class="panel span-12" aria-labelledby="health"><h2 id="health">Developer sync${info(`Each developer's sessions sync their clone in the background and report a heartbeat; the daily run collects them. "Not syncing" means no report for ${STALE_HEARTBEAT_HOURS} hours: normal over a weekend, worth a look after that.`)}</h2>
|
|
163
|
+
<div class="table-wrap"><table><thead><tr><th>Member</th><th>Role</th><th>Sync</th><th class="num">Last report</th><th>KB clone</th><th class="num">Pending</th><th>Version</th><th>Last run with lessons</th></tr></thead><tbody>${v.health.map(healthRow).join("")}</tbody></table></div></section>
|
|
131
164
|
<section class="panel span-12" aria-labelledby="runs"><h2 id="runs">Runs</h2>
|
|
132
165
|
<div class="table-wrap"><table><thead><tr><th>Run</th><th class="num">Merged</th><th class="num">To review</th><th class="num">Declined</th><th class="num">Settled</th><th>Merge refused</th></tr></thead><tbody>${[
|
|
133
166
|
...v.runs,
|
|
@@ -141,3 +174,90 @@ ${v.queue.length
|
|
|
141
174
|
.join("")}</tbody></table></div></section>
|
|
142
175
|
</div>`;
|
|
143
176
|
}
|
|
177
|
+
const hhmm = (iso) => (iso ? iso.slice(11, 16) : "—");
|
|
178
|
+
/**
|
|
179
|
+
* The four lamps every page shows: the daily run, the review queue, developer sync and the
|
|
180
|
+
* knowledge base. `nextRun` comes from the schedule; everything else from the state branch.
|
|
181
|
+
*/
|
|
182
|
+
export function lamps(v, kb, now, nextRun) {
|
|
183
|
+
const last = v.lastRun;
|
|
184
|
+
const ageH = last ? (now.getTime() - Date.parse(last.at)) / 3_600_000 : undefined;
|
|
185
|
+
const refused = last?.members.some((m) => m.mergeError || m.reviewError);
|
|
186
|
+
const when = (iso) => {
|
|
187
|
+
const days = Math.floor((now.getTime() - Date.parse(iso)) / 86_400_000);
|
|
188
|
+
return `${days === 0 ? "today" : days === 1 ? "yesterday" : `${days} days ago`} at ${hhmm(iso)}`;
|
|
189
|
+
};
|
|
190
|
+
const next = nextRun ? `, next ${hhmm(nextRun)}` : "";
|
|
191
|
+
const daily = {
|
|
192
|
+
key: "daily",
|
|
193
|
+
label: "Daily run",
|
|
194
|
+
href: "/automation",
|
|
195
|
+
about: "The scheduled job in the KB repo's CI. Each morning it scores every developer's new lessons and merges, declines or sends them to a reviewer.",
|
|
196
|
+
...(!last
|
|
197
|
+
? {
|
|
198
|
+
state: "off",
|
|
199
|
+
value: "Not run yet",
|
|
200
|
+
detail: nextRun ? `First at ${hhmm(nextRun)} UTC` : "Not scheduled",
|
|
201
|
+
}
|
|
202
|
+
: (ageH ?? 0) > 30
|
|
203
|
+
? { state: "critical", value: "Late", detail: `Last ${when(last.at)}` }
|
|
204
|
+
: refused
|
|
205
|
+
? {
|
|
206
|
+
state: "warning",
|
|
207
|
+
value: "Needs a look",
|
|
208
|
+
detail: "A merge or review request was refused",
|
|
209
|
+
}
|
|
210
|
+
: { state: "good", value: "Steady", detail: `Ran ${hhmm(last.at)}${next}` }),
|
|
211
|
+
};
|
|
212
|
+
const overdue = v.queue.filter((q) => q.overdue).length;
|
|
213
|
+
const oldest = v.queue[0];
|
|
214
|
+
const queue = {
|
|
215
|
+
key: "queue",
|
|
216
|
+
label: "Review queue",
|
|
217
|
+
href: "/automation#queue",
|
|
218
|
+
about: "Lessons the daily run could not decide on its own. Merging their review PR accepts them; closing it declines them.",
|
|
219
|
+
state: !v.present ? "off" : overdue ? "critical" : v.queue.length ? "warning" : "good",
|
|
220
|
+
value: !v.present ? "Unknown" : v.queue.length ? `${v.queue.length} waiting` : "Clear",
|
|
221
|
+
detail: !v.present || !v.queue.length
|
|
222
|
+
? ""
|
|
223
|
+
: `Oldest ${Math.floor(oldest?.ageDays ?? 0)} days${overdue ? `, ${overdue} overdue` : ""}`,
|
|
224
|
+
};
|
|
225
|
+
const syncing = v.health.filter((h) => h.state === "ok").length;
|
|
226
|
+
const silent = v.health.filter((h) => h.state !== "ok").map((h) => h.member);
|
|
227
|
+
const sync = {
|
|
228
|
+
key: "sync",
|
|
229
|
+
label: "Developer sync",
|
|
230
|
+
href: "/automation#health",
|
|
231
|
+
about: "Each developer's sessions sync their KB clone in the background and report in. A developer silent for two days isn't sending lessons or getting the team's.",
|
|
232
|
+
state: !v.present
|
|
233
|
+
? "off"
|
|
234
|
+
: silent.length === 0
|
|
235
|
+
? "good"
|
|
236
|
+
: syncing === 0
|
|
237
|
+
? "critical"
|
|
238
|
+
: "warning",
|
|
239
|
+
value: `${syncing} of ${v.health.length}`,
|
|
240
|
+
detail: silent.length ? `${silent.join(", ")} silent` : "",
|
|
241
|
+
};
|
|
242
|
+
const pct = Math.round(kb.stability * 100);
|
|
243
|
+
const issues = kb.flagged + kb.disputed;
|
|
244
|
+
const kbLamp = {
|
|
245
|
+
key: "kb",
|
|
246
|
+
label: "Knowledge",
|
|
247
|
+
href: "/entries",
|
|
248
|
+
about: "The share of rules an agent can rely on today: accepted, confirmed recently and not flagged.",
|
|
249
|
+
state: kb.accepted === 0 ? "off" : pct >= 80 ? "good" : pct >= 50 ? "warning" : "critical",
|
|
250
|
+
value: kb.accepted === 0 ? "Empty" : `${pct}% sound`,
|
|
251
|
+
detail: `${kb.accepted} ${kb.accepted === 1 ? "rule" : "rules"}${issues ? `, ${issues} flagged` : ""}`,
|
|
252
|
+
};
|
|
253
|
+
return [daily, queue, sync, kbLamp];
|
|
254
|
+
}
|
|
255
|
+
/** The first name in this checkout's git identity, for the greeting; undefined when unset. */
|
|
256
|
+
export function firstName(kbRoot) {
|
|
257
|
+
const name = git(kbRoot, "config", "user.name")?.trim();
|
|
258
|
+
return name ? name.split(/\s+/)[0] : undefined;
|
|
259
|
+
}
|
|
260
|
+
/** Every lesson decided across the recorded runs, newest first. */
|
|
261
|
+
export function decidedLessons(state) {
|
|
262
|
+
return [...state.runs].reverse().flatMap((r) => r.lessons.map((l) => ({ ...l, at: r.at })));
|
|
263
|
+
}
|
package/dist/src/charts.d.ts
CHANGED
|
@@ -31,3 +31,22 @@ export declare function dial(value: number, opts: {
|
|
|
31
31
|
}): string;
|
|
32
32
|
/** The table view every chart offers, for screen readers and exact numbers. */
|
|
33
33
|
export declare function dataTable(data: readonly Datum[], headers: [string, string]): string;
|
|
34
|
+
/**
|
|
35
|
+
* An (i) that shows `html` on hover or keyboard focus. Explanations live here instead of in
|
|
36
|
+
* paragraphs. The text stays in the page for screen readers; only its box is hidden.
|
|
37
|
+
*/
|
|
38
|
+
export declare function info(html: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* A smooth area chart: one series as a curve over a soft gradient, with day labels below and the
|
|
41
|
+
* last point marked and labelled. Each point has a hover title.
|
|
42
|
+
*/
|
|
43
|
+
export declare function areaChart(data: readonly Datum[], opts: {
|
|
44
|
+
title: string;
|
|
45
|
+
id: string;
|
|
46
|
+
}): string;
|
|
47
|
+
/** Concentric rings, one per segment, each filled to its share of the largest. */
|
|
48
|
+
export declare function rings(segments: readonly {
|
|
49
|
+
label: string;
|
|
50
|
+
value: number;
|
|
51
|
+
color: string;
|
|
52
|
+
}[], title: string): string;
|
package/dist/src/charts.js
CHANGED
|
@@ -61,11 +61,15 @@ export function rows(data, opts) {
|
|
|
61
61
|
const height = Math.max(rowH, data.length * rowH) + 4;
|
|
62
62
|
const max = Math.max(1, ...data.map((d) => d.value));
|
|
63
63
|
const track = width - labelW - 40;
|
|
64
|
+
// Labels are right-aligned against the bars, so a long one would run off the left edge.
|
|
65
|
+
// Monospace at the chart's 12px label size is about 7.2 units a character.
|
|
66
|
+
const fit = Math.floor((labelW - 12) / 7.2);
|
|
67
|
+
const label = (s) => (s.length > fit ? `${s.slice(0, fit - 1)}…` : s);
|
|
64
68
|
const marks = data
|
|
65
69
|
.map((d, i) => {
|
|
66
70
|
const y = i * rowH + 4;
|
|
67
71
|
const w = (d.value / max) * track;
|
|
68
|
-
return `<g class="m"><rect class="hit" x="0" y="${y - 2}" width="${width}" height="${rowH}"/><text class="t" x="${labelW - 8}" y="${y + 12}" text-anchor="end">${esc(d.label)}</text><path d="${rowPath(labelW, y + 2, w, rowH - 8)}"/><text class="v" x="${labelW + w + 6}" y="${y + 12}">${d.value}</text><title>${esc(d.title ?? `${d.label}: ${d.value}`)}</title></g>`;
|
|
72
|
+
return `<g class="m"><rect class="hit" x="0" y="${y - 2}" width="${width}" height="${rowH}"/><text class="t" x="${labelW - 8}" y="${y + 12}" text-anchor="end">${esc(label(d.label))}</text><path d="${rowPath(labelW, y + 2, w, rowH - 8)}"/><text class="v" x="${labelW + w + 6}" y="${y + 12}">${d.value}</text><title>${esc(d.title ?? `${d.label}: ${d.value}`)}</title></g>`;
|
|
69
73
|
})
|
|
70
74
|
.join("");
|
|
71
75
|
return `<svg class="chart" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(opts.title)}"><line class="base" x1="${labelW}" x2="${labelW}" y1="0" y2="${height}"/>${marks}</svg>`;
|
|
@@ -110,3 +114,69 @@ export function dataTable(data, headers) {
|
|
|
110
114
|
.map((d) => `<tr><td>${esc(d.label)}</td><td class="num">${d.value}</td></tr>`)
|
|
111
115
|
.join("")}</tbody></table></details>`;
|
|
112
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* An (i) that shows `html` on hover or keyboard focus. Explanations live here instead of in
|
|
119
|
+
* paragraphs. The text stays in the page for screen readers; only its box is hidden.
|
|
120
|
+
*/
|
|
121
|
+
export function info(html) {
|
|
122
|
+
return `<span class="info" tabindex="0"><span class="i" aria-hidden="true">i</span><span class="tip" role="tooltip">${html}</span></span>`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A smooth area chart: one series as a curve over a soft gradient, with day labels below and the
|
|
126
|
+
* last point marked and labelled. Each point has a hover title.
|
|
127
|
+
*/
|
|
128
|
+
export function areaChart(data, opts) {
|
|
129
|
+
const w = 560;
|
|
130
|
+
const h = 170;
|
|
131
|
+
const pad = { top: 26, bottom: 24, left: 4, right: 14 };
|
|
132
|
+
const max = Math.max(1, ...data.map((d) => d.value));
|
|
133
|
+
const x = (i) => pad.left + (i * (w - pad.left - pad.right)) / Math.max(1, data.length - 1);
|
|
134
|
+
const y = (v) => pad.top + (1 - v / max) * (h - pad.top - pad.bottom);
|
|
135
|
+
const pts = data.map((d, i) => [x(i), y(d.value)]);
|
|
136
|
+
// Catmull-Rom to cubic Bezier: a smooth curve through every point.
|
|
137
|
+
let line = pts.length ? `M${pts[0]?.[0]},${pts[0]?.[1]}` : "";
|
|
138
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
139
|
+
const p0 = pts[i - 1] ?? pts[i];
|
|
140
|
+
const p1 = pts[i];
|
|
141
|
+
const p2 = pts[i + 1];
|
|
142
|
+
const p3 = pts[i + 2] ?? p2;
|
|
143
|
+
if (!p0 || !p1 || !p2 || !p3)
|
|
144
|
+
continue;
|
|
145
|
+
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
|
|
146
|
+
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
|
|
147
|
+
line += ` C${c1[0]?.toFixed(1)},${c1[1]?.toFixed(1)} ${c2[0]?.toFixed(1)},${c2[1]?.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`;
|
|
148
|
+
}
|
|
149
|
+
const base = h - pad.bottom;
|
|
150
|
+
const area = pts.length ? `${line} L${pts.at(-1)?.[0]},${base} L${pts[0]?.[0]},${base} Z` : "";
|
|
151
|
+
const last = data.at(-1);
|
|
152
|
+
const lp = pts.at(-1);
|
|
153
|
+
const grid = [0.25, 0.5, 0.75]
|
|
154
|
+
.map((f) => `<line class="gridline" x1="0" x2="${w}" y1="${(pad.top + f * (base - pad.top)).toFixed(1)}" y2="${(pad.top + f * (base - pad.top)).toFixed(1)}"/>`)
|
|
155
|
+
.join("");
|
|
156
|
+
const labels = data
|
|
157
|
+
.map((d, i) => (i % 2 === 0 && i !== data.length - 2) || i === data.length - 1
|
|
158
|
+
? `<text class="xl" x="${x(i).toFixed(1)}" y="${h - 4}" text-anchor="middle">${esc(d.label)}</text>`
|
|
159
|
+
: "")
|
|
160
|
+
.join("");
|
|
161
|
+
const hits = data
|
|
162
|
+
.map((d, i) => `<circle class="pt" cx="${x(i).toFixed(1)}" cy="${y(d.value).toFixed(1)}" r="9"><title>${esc(d.title ?? `${d.label}: ${d.value}`)}</title></circle>`)
|
|
163
|
+
.join("");
|
|
164
|
+
return `<svg class="area" viewBox="0 0 ${w} ${h}" role="img" aria-label="${esc(opts.title)}"><defs><linearGradient id="g-${opts.id}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="var(--accent)" stop-opacity="0.45"/><stop offset="1" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>${grid}<path d="${area}" fill="url(#g-${opts.id})"/><path class="curve" d="${line}"/>${labels}${hits}${last && lp
|
|
165
|
+
? `<circle class="last" cx="${lp[0].toFixed(1)}" cy="${lp[1].toFixed(1)}" r="5"/><g class="callout" transform="translate(${(lp[0] - 84).toFixed(1)},${Math.max(lp[1] - 17, 4).toFixed(1)})"><rect width="70" height="34" rx="6"/><text x="8" y="14" class="cl">${esc(last.label)}</text><text x="8" y="28" class="cv">${last.value}</text></g>`
|
|
166
|
+
: ""}</svg>`;
|
|
167
|
+
}
|
|
168
|
+
/** Concentric rings, one per segment, each filled to its share of the largest. */
|
|
169
|
+
export function rings(segments, title) {
|
|
170
|
+
const size = 200;
|
|
171
|
+
const c = size / 2;
|
|
172
|
+
const max = Math.max(1, ...segments.map((s) => s.value));
|
|
173
|
+
const arcs = segments
|
|
174
|
+
.map((s, i) => {
|
|
175
|
+
const r = 86 - i * 17;
|
|
176
|
+
const len = 2 * Math.PI * r;
|
|
177
|
+
const share = s.value / max;
|
|
178
|
+
return `<circle class="ring-track" cx="${c}" cy="${c}" r="${r}"/><circle class="ring" cx="${c}" cy="${c}" r="${r}" stroke="${s.color}" stroke-dasharray="${(len * share * 0.75).toFixed(1)} ${len.toFixed(1)}" transform="rotate(-90 ${c} ${c})"><title>${esc(`${s.label}: ${s.value}`)}</title></circle>`;
|
|
179
|
+
})
|
|
180
|
+
.join("");
|
|
181
|
+
return `<svg class="rings" viewBox="0 0 ${size} ${size}" role="img" aria-label="${esc(title)}">${arcs}</svg>`;
|
|
182
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { CONFIG_FILE, readEvents, readKbFiles, sectionUsage, validate, } from "@homeostat/core";
|
|
6
|
-
import { automationPage, automationView, readAutomation } from "./automation.js";
|
|
6
|
+
import { automationPage, automationView, decidedLessons, firstName, lamps, readAutomation, } from "./automation.js";
|
|
7
7
|
import { agentLiveness, contributions, coverage, desirePaths, health, impact, } from "./metrics.js";
|
|
8
8
|
import { applySection, SECTIONS } from "./settings.js";
|
|
9
9
|
import { coveragePage, desirePathsPage, documentsPage, entriesPage, entryPage, eventsPage, impactPage, layout, membersPage, messagePage, overviewPage, settingsIndex, settingsPage, } from "./views.js";
|
|
@@ -89,13 +89,37 @@ export function startDashboard(opts) {
|
|
|
89
89
|
if (!allowed(req.headers.host))
|
|
90
90
|
return send(res, 421, "Misdirected request");
|
|
91
91
|
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
92
|
-
|
|
92
|
+
// ?theme= picks a theme and a cookie keeps it; dark is the default.
|
|
93
|
+
const asked = url.searchParams.get("theme");
|
|
94
|
+
const kept = /(?:^|;\s*)theme=(light|dark)/.exec(req.headers.cookie ?? "")?.[1];
|
|
95
|
+
const t = asked === "light" || asked === "dark" ? asked : kept;
|
|
93
96
|
const theme = t === "light" || t === "dark" ? { theme: t } : {};
|
|
94
|
-
|
|
97
|
+
if (asked === "light" || asked === "dark") {
|
|
98
|
+
res.setHeader("set-cookie", `theme=${asked}; Path=/; SameSite=Strict; Max-Age=31536000`);
|
|
99
|
+
}
|
|
100
|
+
let lampsNow = [];
|
|
101
|
+
const page = (title, body, status = 200) => send(res, status, layout({
|
|
102
|
+
title,
|
|
103
|
+
path: url.pathname,
|
|
104
|
+
kbRoot: opts.kbRoot,
|
|
105
|
+
body,
|
|
106
|
+
status: lampsNow,
|
|
107
|
+
...theme,
|
|
108
|
+
}));
|
|
95
109
|
const snap = snapshot();
|
|
96
110
|
if (!snap) {
|
|
97
111
|
return page("No knowledge base", messagePage("No knowledge base", `${CONFIG_FILE} is missing or invalid in ${opts.kbRoot}. Run homeostat validate there.`), 500);
|
|
98
112
|
}
|
|
113
|
+
// The console lamps on every page: the daily run, the review queue, sync and the KB.
|
|
114
|
+
const autoState = readAutomation(opts.kbRoot);
|
|
115
|
+
const auto = automationView(autoState, snap.config, snap.now);
|
|
116
|
+
const kbHealth = health(snap);
|
|
117
|
+
lampsNow = lamps(auto, {
|
|
118
|
+
stability: kbHealth.stability,
|
|
119
|
+
accepted: kbHealth.byStatus.accepted,
|
|
120
|
+
flagged: kbHealth.flagged.length,
|
|
121
|
+
disputed: kbHealth.byStatus.disputed,
|
|
122
|
+
}, snap.now, agentLiveness(snap).find((a) => a.agent === "curator")?.nextRun);
|
|
99
123
|
if (req.method === "POST") {
|
|
100
124
|
// Our own pages send their origin; "null" or anything unreadable is refused, never parsed blind.
|
|
101
125
|
const origin = req.headers.origin;
|
|
@@ -127,13 +151,17 @@ export function startDashboard(opts) {
|
|
|
127
151
|
const week = new Date(snap.now.getTime() - 7 * 86_400_000).toISOString();
|
|
128
152
|
switch (url.pathname) {
|
|
129
153
|
case "/": {
|
|
130
|
-
const recent = snap.events.slice(-
|
|
131
|
-
return page("Overview", overviewPage(
|
|
154
|
+
const recent = snap.events.slice(-60).reverse();
|
|
155
|
+
return page("Overview", overviewPage(kbHealth, agentLiveness(snap), impact(snap), recent, auto, lampsNow, {
|
|
156
|
+
...(firstName(opts.kbRoot) ? { name: firstName(opts.kbRoot) } : {}),
|
|
157
|
+
newThisWeek: snap.entries.filter((e) => e.frontmatter.status === "accepted" && e.frontmatter.created >= week.slice(0, 10)).length,
|
|
158
|
+
decided: decidedLessons(autoState),
|
|
159
|
+
}));
|
|
132
160
|
}
|
|
133
161
|
case "/automation":
|
|
134
|
-
return page("Automation", automationPage(
|
|
162
|
+
return page("Automation", automationPage(auto));
|
|
135
163
|
case "/entries":
|
|
136
|
-
return page("Entries", entriesPage(snap.entries, url.searchParams.get("status")));
|
|
164
|
+
return page("Entries", entriesPage(snap.entries, url.searchParams.get("status"), url.searchParams.get("q")));
|
|
137
165
|
case "/members":
|
|
138
166
|
return page("Members", membersPage(contributions(snap)));
|
|
139
167
|
case "/coverage":
|
|
@@ -145,7 +173,7 @@ export function startDashboard(opts) {
|
|
|
145
173
|
case "/desire-paths":
|
|
146
174
|
return page("Desire paths", desirePathsPage(desirePaths(snap)));
|
|
147
175
|
case "/events":
|
|
148
|
-
return page("Events", eventsPage(snap.events.filter((e) => e.ts >= week).reverse()));
|
|
176
|
+
return page("Events", eventsPage(snap.events.filter((e) => e.ts >= week).reverse(), url.searchParams.get("type")));
|
|
149
177
|
case "/settings":
|
|
150
178
|
return page("Settings", settingsIndex());
|
|
151
179
|
}
|
package/dist/src/views.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Config, type Entry, type Event, type Issue, type SectionUsage } from "@homeostat/core";
|
|
2
|
+
import type { AutomationView, Lamp } from "./automation.ts";
|
|
2
3
|
import type { AgentLiveness, Contribution, DesirePath, Health, Impact } from "./metrics.ts";
|
|
3
4
|
import { type Section } from "./settings.ts";
|
|
4
5
|
export declare function layout(opts: {
|
|
@@ -7,9 +8,35 @@ export declare function layout(opts: {
|
|
|
7
8
|
kbRoot: string;
|
|
8
9
|
body: string;
|
|
9
10
|
theme?: "light" | "dark";
|
|
11
|
+
/** The console lamps, shown on every page. */
|
|
12
|
+
status?: Lamp[];
|
|
10
13
|
}): string;
|
|
11
|
-
|
|
12
|
-
export declare function
|
|
14
|
+
/** Cron as words for the common shapes (`0 6 * * *` → daily at 06:00 UTC); anything else as is. */
|
|
15
|
+
export declare function schedule(expr: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* A state as a shape as well as a colour (never colour alone): a tick, a warning triangle, a
|
|
18
|
+
* cross, a dash. Plain strokes in currentColor, so they follow the theme.
|
|
19
|
+
*/
|
|
20
|
+
export declare function stateIcon(state: Lamp["state"], size?: number): string;
|
|
21
|
+
/**
|
|
22
|
+
* One homeostat unit: Ashby's trough of water with a needle on a pivot. Upright is steady; the
|
|
23
|
+
* further the needle swings, the further the loop has drifted from equilibrium.
|
|
24
|
+
*/
|
|
25
|
+
export declare function gauge(state: Lamp["state"], width?: number, title?: string): string;
|
|
26
|
+
export interface OverviewExtras {
|
|
27
|
+
name?: string;
|
|
28
|
+
/** Rules accepted in the last 7 days. */
|
|
29
|
+
newThisWeek: number;
|
|
30
|
+
decided: {
|
|
31
|
+
member: string;
|
|
32
|
+
id: string;
|
|
33
|
+
decision: string;
|
|
34
|
+
confidence?: number;
|
|
35
|
+
at: string;
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
export declare function overviewPage(h: Health, agents: AgentLiveness[], imp: Impact, _recent: Event[], v: AutomationView, _ls: readonly Lamp[], x?: OverviewExtras): string;
|
|
39
|
+
export declare function entriesPage(entries: readonly Entry[], status: string | null, query?: string | null): string;
|
|
13
40
|
export declare function entryPage(entry: Entry, config: Config): string;
|
|
14
41
|
export declare function membersPage(rowsData: Contribution[]): string;
|
|
15
42
|
export declare function coveragePage(cov: {
|
|
@@ -27,7 +54,7 @@ export declare function coveragePage(cov: {
|
|
|
27
54
|
export declare function impactPage(imp: Impact): string;
|
|
28
55
|
export declare function documentsPage(usage: readonly SectionUsage[]): string;
|
|
29
56
|
export declare function desirePathsPage(paths: DesirePath[]): string;
|
|
30
|
-
export declare function eventsPage(events: Event[]): string;
|
|
57
|
+
export declare function eventsPage(events: Event[], type?: string | null): string;
|
|
31
58
|
export declare function settingsIndex(): string;
|
|
32
59
|
export declare function settingsPage(section: Section, config: Config, token: string, notice?: {
|
|
33
60
|
saved?: boolean;
|