@homeostat/dashboard 0.1.0 → 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.
- package/dist/src/automation.d.ts +44 -0
- package/dist/src/automation.js +142 -0
- package/dist/src/server.js +16 -2
- package/dist/src/settings.js +61 -22
- package/dist/src/views.js +6 -5
- package/package.json +2 -2
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Config, type DecisionRecord, type DecisionsFile, type Heartbeat, type RunReport } from "@homeostat/core";
|
|
2
|
+
/**
|
|
3
|
+
* The Automation page reads the `homeostat/state` branch the daily run writes. It reads the
|
|
4
|
+
* clone's copy (`origin/homeostat/state`), which every sync fetches, so the page makes no network
|
|
5
|
+
* call: it is as current as this clone's last sync.
|
|
6
|
+
*/
|
|
7
|
+
export interface AutomationState {
|
|
8
|
+
/** False when no daily run has written the state branch yet (or this clone never fetched it). */
|
|
9
|
+
present: boolean;
|
|
10
|
+
runs: RunReport[];
|
|
11
|
+
heartbeats: Heartbeat[];
|
|
12
|
+
decisions: DecisionsFile[];
|
|
13
|
+
}
|
|
14
|
+
export declare function readAutomation(kbRoot: string, maxRuns?: number): AutomationState;
|
|
15
|
+
export interface SyncHealth {
|
|
16
|
+
member: string;
|
|
17
|
+
role: string;
|
|
18
|
+
heartbeat?: Heartbeat;
|
|
19
|
+
/** "ok", "stale" (no report for STALE_HEARTBEAT_HOURS), "never" (no sync has reported) */
|
|
20
|
+
state: "ok" | "stale" | "never";
|
|
21
|
+
ageHours?: number;
|
|
22
|
+
today?: RunReport["members"][number];
|
|
23
|
+
}
|
|
24
|
+
export interface QueueItem extends DecisionRecord {
|
|
25
|
+
member: string;
|
|
26
|
+
ageDays: number;
|
|
27
|
+
overdue: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface AutomationView {
|
|
30
|
+
present: boolean;
|
|
31
|
+
lastRun?: RunReport;
|
|
32
|
+
runs: RunReport[];
|
|
33
|
+
health: SyncHealth[];
|
|
34
|
+
queue: QueueItem[];
|
|
35
|
+
/** Lessons decided per day, by outcome, over the runs on record. */
|
|
36
|
+
byDay: {
|
|
37
|
+
day: string;
|
|
38
|
+
merged: number;
|
|
39
|
+
review: number;
|
|
40
|
+
declined: number;
|
|
41
|
+
}[];
|
|
42
|
+
}
|
|
43
|
+
export declare function automationView(state: AutomationState, config: Config, now: Date): AutomationView;
|
|
44
|
+
export declare function automationPage(v: AutomationView): string;
|
|
@@ -0,0 +1,142 @@
|
|
|
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";
|
|
4
|
+
const REF = `refs/remotes/origin/${STATE_BRANCH}`;
|
|
5
|
+
function git(dir, ...args) {
|
|
6
|
+
try {
|
|
7
|
+
return execFileSync("git", ["-C", dir, ...args], {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
10
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function json(dir, path) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(git(dir, "show", `${REF}:${path}`) ?? "");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function readAutomation(kbRoot, maxRuns = 30) {
|
|
26
|
+
const listing = git(kbRoot, "ls-tree", "-r", "--name-only", REF);
|
|
27
|
+
if (listing === undefined)
|
|
28
|
+
return { present: false, runs: [], heartbeats: [], decisions: [] };
|
|
29
|
+
const paths = listing.split("\n").filter(Boolean);
|
|
30
|
+
const under = (dir) => paths.filter((p) => p.startsWith(`${dir}/`) && p.endsWith(".json"));
|
|
31
|
+
const runs = under("runs")
|
|
32
|
+
.sort()
|
|
33
|
+
.slice(-maxRuns)
|
|
34
|
+
.flatMap((p) => json(kbRoot, p) ?? []);
|
|
35
|
+
const heartbeats = under("heartbeats").flatMap((p) => json(kbRoot, p) ?? []);
|
|
36
|
+
const decisions = under("decisions").flatMap((p) => json(kbRoot, p) ?? []);
|
|
37
|
+
return { present: true, runs, heartbeats, decisions };
|
|
38
|
+
}
|
|
39
|
+
export function automationView(state, config, now) {
|
|
40
|
+
const lastRun = state.runs.at(-1);
|
|
41
|
+
const health = config.team.members.map((m) => {
|
|
42
|
+
const heartbeat = state.heartbeats.find((h) => h.member === m.id);
|
|
43
|
+
const ageHours = heartbeat ? (now.getTime() - Date.parse(heartbeat.at)) / 3_600_000 : undefined;
|
|
44
|
+
const today = lastRun?.members.find((r) => r.member === m.id);
|
|
45
|
+
return {
|
|
46
|
+
member: m.id,
|
|
47
|
+
role: m.role,
|
|
48
|
+
...(heartbeat ? { heartbeat } : {}),
|
|
49
|
+
...(ageHours !== undefined ? { ageHours } : {}),
|
|
50
|
+
...(today ? { today } : {}),
|
|
51
|
+
state: !heartbeat ? "never" : (ageHours ?? 0) > STALE_HEARTBEAT_HOURS ? "stale" : "ok",
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const queue = state.decisions
|
|
55
|
+
.flatMap((d) => d.decided
|
|
56
|
+
.filter((r) => r.decision === "review")
|
|
57
|
+
.map((r) => {
|
|
58
|
+
const ageDays = (now.getTime() - Date.parse(r.at)) / 86_400_000;
|
|
59
|
+
return { ...r, member: d.member, ageDays, overdue: ageDays > REVIEW_REMINDER_DAYS };
|
|
60
|
+
}))
|
|
61
|
+
.sort((a, b) => b.ageDays - a.ageDays);
|
|
62
|
+
const byDay = state.runs.map((r) => ({
|
|
63
|
+
day: r.at.slice(0, 10),
|
|
64
|
+
merged: r.members.reduce((n, m) => n + m.merged, 0),
|
|
65
|
+
review: r.members.reduce((n, m) => n + m.review, 0),
|
|
66
|
+
declined: r.members.reduce((n, m) => n + m.declined, 0),
|
|
67
|
+
}));
|
|
68
|
+
return {
|
|
69
|
+
present: state.present,
|
|
70
|
+
...(lastRun ? { lastRun } : {}),
|
|
71
|
+
runs: state.runs,
|
|
72
|
+
health,
|
|
73
|
+
queue,
|
|
74
|
+
byDay,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const age = (hours) => hours === undefined
|
|
78
|
+
? "—"
|
|
79
|
+
: hours < 1
|
|
80
|
+
? "under 1h"
|
|
81
|
+
: hours < 48
|
|
82
|
+
? `${Math.round(hours)}h`
|
|
83
|
+
: `${Math.round(hours / 24)}d`;
|
|
84
|
+
const prLink = (url, label = "PR") => url && /^https:\/\//.test(url) ? `<a href="${esc(url)}">${esc(label)}</a>` : "";
|
|
85
|
+
function healthRow(h) {
|
|
86
|
+
const lamp = h.state === "ok" ? "good" : h.state === "stale" ? "warning" : "";
|
|
87
|
+
const state = h.state === "ok"
|
|
88
|
+
? "Syncing"
|
|
89
|
+
: h.state === "stale"
|
|
90
|
+
? `Not syncing for ${age(h.ageHours)}`
|
|
91
|
+
: "No sync yet";
|
|
92
|
+
const hb = h.heartbeat;
|
|
93
|
+
const kb = !hb ? "—" : hb.behind > 0 ? `${hb.behind} behind` : "current";
|
|
94
|
+
const t = h.today;
|
|
95
|
+
const outcome = t
|
|
96
|
+
? `${t.merged} merged · ${t.review} to review · ${t.declined} declined${t.mergeError ? " · merge refused" : ""}`
|
|
97
|
+
: "—";
|
|
98
|
+
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
|
+
}
|
|
100
|
+
export function automationPage(v) {
|
|
101
|
+
if (!v.present) {
|
|
102
|
+
return `<section class="panel"><h2>Automation</h2><p class="lede">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.</p></section>`;
|
|
103
|
+
}
|
|
104
|
+
const r = v.lastRun;
|
|
105
|
+
const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
|
|
106
|
+
const total = (k) => r?.members.reduce((n, m) => n + m[k], 0) ?? 0;
|
|
107
|
+
const notSyncing = v.health.filter((h) => h.state !== "ok");
|
|
108
|
+
const overdue = v.queue.filter((q) => q.overdue).length;
|
|
109
|
+
const daily = v.byDay.map((d) => ({
|
|
110
|
+
label: d.day.slice(5),
|
|
111
|
+
value: d.merged + d.review + d.declined,
|
|
112
|
+
title: `${d.day}: ${d.merged} merged, ${d.review} to review, ${d.declined} declined`,
|
|
113
|
+
}));
|
|
114
|
+
return `<div class="grid">
|
|
115
|
+
<section class="panel span-8" aria-labelledby="last"><h2 id="last">Last daily run</h2>
|
|
116
|
+
${r ? `<p class="lede mono">${esc(r.at.replace("T", " ").slice(0, 16))} UTC</p>` : ""}
|
|
117
|
+
<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 run</span>${columns(daily, { title: "Lessons decided per run", labelled: "ends", tickEvery: 7 })}${dataTable(daily, ["Run", "Lessons"])}</div>` : ""}</section>
|
|
119
|
+
<section class="panel span-4" aria-labelledby="attention"><h2 id="attention">Needs attention</h2>
|
|
120
|
+
<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>
|
|
121
|
+
${notSyncing.length ? `<p class="lede">Not syncing: ${notSyncing.map((h) => `<span class="mono">${esc(h.member)}</span>`).join(", ")}. Their lessons reach the daily run only after a session syncs; check that the Homeostat plugin is installed for them.</p>` : ""}</section>
|
|
122
|
+
<section class="panel span-12" aria-labelledby="queue"><h2 id="queue">Review queue</h2><p class="lede">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.</p>
|
|
123
|
+
${v.queue.length
|
|
124
|
+
? `<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
|
|
125
|
+
.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>`)
|
|
126
|
+
.join("")}</tbody></table></div>`
|
|
127
|
+
: '<p class="empty">Nothing waiting for a reviewer.</p>'}</section>
|
|
128
|
+
<section class="panel span-12" aria-labelledby="health"><h2 id="health">Developer sync</h2><p class="lede">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.</p>
|
|
129
|
+
<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 daily run</th></tr></thead><tbody>${v.health.map(healthRow).join("")}</tbody></table></div></section>
|
|
130
|
+
<section class="panel span-12" aria-labelledby="runs"><h2 id="runs">Runs</h2>
|
|
131
|
+
<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>${[
|
|
132
|
+
...v.runs,
|
|
133
|
+
]
|
|
134
|
+
.reverse()
|
|
135
|
+
.map((run) => {
|
|
136
|
+
const sum = (k) => run.members.reduce((n, m) => n + m[k], 0);
|
|
137
|
+
const refused = run.members.filter((m) => m.mergeError).map((m) => m.member);
|
|
138
|
+
return `<tr><td class="mono">${esc(run.at.replace("T", " ").slice(0, 16))}</td><td class="num">${sum("merged")}</td><td class="num">${sum("review")}</td><td class="num">${sum("declined")}</td><td class="num">${run.settled}</td><td class="mono">${esc(refused.join(", ") || "—")}</td></tr>`;
|
|
139
|
+
})
|
|
140
|
+
.join("")}</tbody></table></div></section>
|
|
141
|
+
</div>`;
|
|
142
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -3,6 +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
7
|
import { agentLiveness, contributions, coverage, desirePaths, health, impact, } from "./metrics.js";
|
|
7
8
|
import { applySection, SECTIONS } from "./settings.js";
|
|
8
9
|
import { coveragePage, desirePathsPage, documentsPage, entriesPage, entryPage, eventsPage, impactPage, layout, membersPage, messagePage, overviewPage, settingsIndex, settingsPage, } from "./views.js";
|
|
@@ -42,6 +43,14 @@ export function startDashboard(opts) {
|
|
|
42
43
|
"[::1]",
|
|
43
44
|
...(host === "0.0.0.0" ? [] : [host]),
|
|
44
45
|
]);
|
|
46
|
+
const sameSite = (origin) => {
|
|
47
|
+
try {
|
|
48
|
+
return allowed(new URL(origin).host);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
45
54
|
const allowed = (hostPort) => {
|
|
46
55
|
try {
|
|
47
56
|
return Boolean(hostPort) && allowedNames.has(new URL(`http://${hostPort}`).hostname);
|
|
@@ -69,7 +78,9 @@ export function startDashboard(opts) {
|
|
|
69
78
|
"cache-control": "no-store",
|
|
70
79
|
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'",
|
|
71
80
|
"x-content-type-options": "nosniff",
|
|
72
|
-
|
|
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",
|
|
73
84
|
});
|
|
74
85
|
res.end(html);
|
|
75
86
|
};
|
|
@@ -86,8 +97,9 @@ export function startDashboard(opts) {
|
|
|
86
97
|
return page("No knowledge base", messagePage("No knowledge base", `${CONFIG_FILE} is missing or invalid in ${opts.kbRoot}. Run homeostat validate there.`), 500);
|
|
87
98
|
}
|
|
88
99
|
if (req.method === "POST") {
|
|
100
|
+
// Our own pages send their origin; "null" or anything unreadable is refused, never parsed blind.
|
|
89
101
|
const origin = req.headers.origin;
|
|
90
|
-
if (origin && !
|
|
102
|
+
if (origin && !sameSite(origin))
|
|
91
103
|
return send(res, 403, "Cross-origin request refused");
|
|
92
104
|
const m = /^\/settings\/([a-z]+)$/.exec(url.pathname);
|
|
93
105
|
const section = m && SECTIONS.find((s) => s.id === m[1]);
|
|
@@ -118,6 +130,8 @@ export function startDashboard(opts) {
|
|
|
118
130
|
const recent = snap.events.slice(-12).reverse();
|
|
119
131
|
return page("Overview", overviewPage(health(snap), agentLiveness(snap), impact(snap), recent));
|
|
120
132
|
}
|
|
133
|
+
case "/automation":
|
|
134
|
+
return page("Automation", automationPage(automationView(readAutomation(opts.kbRoot), snap.config, snap.now)));
|
|
121
135
|
case "/entries":
|
|
122
136
|
return page("Entries", entriesPage(snap.entries, url.searchParams.get("status")));
|
|
123
137
|
case "/members":
|
package/dist/src/settings.js
CHANGED
|
@@ -93,6 +93,27 @@ export const SECTIONS = [
|
|
|
93
93
|
fields: [
|
|
94
94
|
...Object.keys(DEFAULT_WEIGHTS).map((k) => ({ path: ["scoring", "weights", k], label: `Weight: ${k}`, kind: "number" })),
|
|
95
95
|
{ path: ["scoring", "autoMergeThreshold"], label: "Auto-merge threshold", kind: "number" },
|
|
96
|
+
{ path: ["scoring", "confidence", "accept"], label: "Merge at confidence", kind: "number" },
|
|
97
|
+
{
|
|
98
|
+
path: ["scoring", "confidence", "coreAccept"],
|
|
99
|
+
label: "Merge at confidence (core or global rules)",
|
|
100
|
+
kind: "number",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
path: ["scoring", "confidence", "decline"],
|
|
104
|
+
label: "Decline below confidence",
|
|
105
|
+
kind: "number",
|
|
106
|
+
},
|
|
107
|
+
...[
|
|
108
|
+
["base", ["prFeedbackChangedCode", "prFeedback", "manual", "agentHandoff", "deepDive"]],
|
|
109
|
+
["roles", ["junior", "member", "senior", "lead"]],
|
|
110
|
+
["bonus", ["areaOwner", "specific", "goodTitle", "repeat", "modelRating"]],
|
|
111
|
+
["penalty", ["vagueTitle", "conflict", "flag"]],
|
|
112
|
+
].flatMap(([group, keys]) => keys.map((k) => ({
|
|
113
|
+
path: ["scoring", "confidence", group, k],
|
|
114
|
+
label: `Confidence ${group}: ${k}`,
|
|
115
|
+
kind: "number",
|
|
116
|
+
}))),
|
|
96
117
|
{ path: ["scoring", "autoMerge", "enabled"], label: "Auto-merge", kind: "checkbox" },
|
|
97
118
|
{
|
|
98
119
|
path: ["scoring", "autoMerge", "areas"],
|
|
@@ -148,7 +169,7 @@ export const SECTIONS = [
|
|
|
148
169
|
optional: true,
|
|
149
170
|
help: "global, us, eu or a region",
|
|
150
171
|
},
|
|
151
|
-
...agentFields("curator", "
|
|
172
|
+
...agentFields("curator", "Daily run (curator)"),
|
|
152
173
|
...agentFields("deepDive", "Deep dive"),
|
|
153
174
|
{
|
|
154
175
|
path: ["agents", "deepDive", "staleAfterDays"],
|
|
@@ -257,6 +278,18 @@ export function applySection(yamlText, sectionId, form) {
|
|
|
257
278
|
],
|
|
258
279
|
};
|
|
259
280
|
const doc = parseDocument(yamlText);
|
|
281
|
+
// A field whose value did not change is left untouched: defaults stay implicit, formatting stays.
|
|
282
|
+
const before = parseConfig(yamlText).config;
|
|
283
|
+
const canon = (v) => Array.isArray(v)
|
|
284
|
+
? v.map(canon)
|
|
285
|
+
: v && typeof v === "object"
|
|
286
|
+
? Object.fromEntries(Object.entries(v)
|
|
287
|
+
.filter(([, x]) => x !== undefined)
|
|
288
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
289
|
+
.map(([k, x]) => [k, canon(x)]))
|
|
290
|
+
: v;
|
|
291
|
+
const same = (a, b) => JSON.stringify(canon(a)) === JSON.stringify(canon(b));
|
|
292
|
+
const unchanged = (path, value) => before !== undefined && same(value, currentValue(before, { path }));
|
|
260
293
|
if (section.rows === "team") {
|
|
261
294
|
const members = rowIndices(form, "member").flatMap((i) => {
|
|
262
295
|
const id = form.get(`member.${i}.id`)?.trim();
|
|
@@ -274,7 +307,8 @@ export function applySection(yamlText, sectionId, form) {
|
|
|
274
307
|
},
|
|
275
308
|
];
|
|
276
309
|
});
|
|
277
|
-
|
|
310
|
+
if (!unchanged(["team", "members"], members))
|
|
311
|
+
doc.setIn(["team", "members"], doc.createNode(members));
|
|
278
312
|
}
|
|
279
313
|
else if (section.rows === "teams") {
|
|
280
314
|
const teams = {};
|
|
@@ -303,7 +337,8 @@ export function applySection(yamlText, sectionId, form) {
|
|
|
303
337
|
continue;
|
|
304
338
|
areas[name] = lines(form.get(`area.${i}.globs`) ?? "");
|
|
305
339
|
}
|
|
306
|
-
|
|
340
|
+
if (!unchanged(["areas"], areas))
|
|
341
|
+
doc.setIn(["areas"], doc.createNode(areas));
|
|
307
342
|
}
|
|
308
343
|
else {
|
|
309
344
|
for (const field of section.fields ?? []) {
|
|
@@ -313,27 +348,31 @@ export function applySection(yamlText, sectionId, form) {
|
|
|
313
348
|
doc.deleteIn(["sources"]);
|
|
314
349
|
break;
|
|
315
350
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
if (doc.hasIn(field.path))
|
|
326
|
-
doc.deleteIn(field.path);
|
|
327
|
-
}
|
|
328
|
-
else
|
|
329
|
-
doc.setIn(field.path, Number(raw));
|
|
330
|
-
}
|
|
331
|
-
else if (field.optional && !raw?.trim()) {
|
|
351
|
+
// An empty number or optional text means "use the default", so the key is removed.
|
|
352
|
+
const clear = (field.kind === "number" && (raw === null || raw === "")) ||
|
|
353
|
+
(field.kind !== "checkbox" &&
|
|
354
|
+
field.kind !== "checks" &&
|
|
355
|
+
field.kind !== "lines" &&
|
|
356
|
+
field.kind !== "number" &&
|
|
357
|
+
field.optional &&
|
|
358
|
+
!raw?.trim());
|
|
359
|
+
if (clear) {
|
|
332
360
|
if (doc.hasIn(field.path))
|
|
333
361
|
doc.deleteIn(field.path);
|
|
362
|
+
continue;
|
|
334
363
|
}
|
|
335
|
-
|
|
336
|
-
|
|
364
|
+
const value = field.kind === "checkbox"
|
|
365
|
+
? form.get(name) === "on"
|
|
366
|
+
: field.kind === "checks"
|
|
367
|
+
? form.getAll(name)
|
|
368
|
+
: field.kind === "lines"
|
|
369
|
+
? lines(raw ?? "")
|
|
370
|
+
: field.kind === "number"
|
|
371
|
+
? Number(raw)
|
|
372
|
+
: raw?.trim();
|
|
373
|
+
if (value === undefined || unchanged(field.path, value))
|
|
374
|
+
continue;
|
|
375
|
+
doc.setIn(field.path, Array.isArray(value) ? doc.createNode(value) : value);
|
|
337
376
|
}
|
|
338
377
|
}
|
|
339
378
|
// Drop sections that became empty maps, so the defaults apply.
|
|
@@ -342,7 +381,7 @@ export function applySection(yamlText, sectionId, form) {
|
|
|
342
381
|
if (isMap(node) && node.items.length === 0)
|
|
343
382
|
doc.delete(key);
|
|
344
383
|
}
|
|
345
|
-
const text = doc.toString();
|
|
384
|
+
const text = doc.toString({ flowCollectionPadding: false });
|
|
346
385
|
const { config, issues } = parseConfig(text);
|
|
347
386
|
return config ? { text, issues: [] } : { issues };
|
|
348
387
|
}
|
package/dist/src/views.js
CHANGED
|
@@ -75,9 +75,9 @@ header.top { display: flex; align-items: baseline; justify-content: space-betwee
|
|
|
75
75
|
nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
|
76
76
|
nav a { color: var(--ink-2); text-decoration: none; font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.06em; padding: 2px 0; border-bottom: 1px solid transparent; }
|
|
77
77
|
nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--ink); }
|
|
78
|
-
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, 1fr); }
|
|
78
|
+
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
79
79
|
.panel { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; padding: 16px 18px; grid-column: span 12; min-width: 0; }
|
|
80
|
-
.span-4 { grid-column: span 4; } .span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; }
|
|
80
|
+
.span-4 { grid-column: span 4; } .span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-12 { grid-column: span 12; }
|
|
81
81
|
@media (max-width: 860px) { .span-4, .span-6, .span-8 { grid-column: span 12; } }
|
|
82
82
|
.label { color: var(--ink-3); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
83
83
|
.lede { color: var(--ink-2); margin: 4px 0 14px; max-width: 70ch; }
|
|
@@ -152,6 +152,7 @@ footer { margin-top: 36px; color: var(--ink-3); font-size: 12.5px; border-top: 1
|
|
|
152
152
|
`;
|
|
153
153
|
const NAV = [
|
|
154
154
|
["/", "Overview"],
|
|
155
|
+
["/automation", "Automation"],
|
|
155
156
|
["/entries", "Entries"],
|
|
156
157
|
["/members", "Members"],
|
|
157
158
|
["/coverage", "Coverage"],
|
|
@@ -196,7 +197,7 @@ function lampFor(a) {
|
|
|
196
197
|
}
|
|
197
198
|
function unit(a) {
|
|
198
199
|
const lamp = lampFor(a);
|
|
199
|
-
const name = a.agent === "curator" ? "
|
|
200
|
+
const name = a.agent === "curator" ? "Daily run" : "Deep dive";
|
|
200
201
|
return `<div class="unit">
|
|
201
202
|
<div class="head"><span class="lamp ${lamp.cls}" aria-hidden="true"></span><h3>${name}</h3><span class="state">${lamp.text}</span></div>
|
|
202
203
|
<div class="mono muted">${esc(a.schedule)} UTC</div>
|
|
@@ -228,7 +229,7 @@ ${dial(h.stability, { title: "Share of entries accepted, fresh and unflagged", c
|
|
|
228
229
|
<section class="panel span-8" aria-labelledby="kb"><h2 id="kb">Knowledge base</h2>
|
|
229
230
|
<div class="stats" style="--cols: 3">${stat(h.total, "entries")}${stat(h.byStatus.accepted, "accepted")}${stat(h.byStatus.candidate, "awaiting review")}${stat(h.flagged.length, "flagged")}${stat(h.stale.length, "stale")}${stat(h.byStatus.disputed, "disputed")}</div>
|
|
230
231
|
<div style="margin-top:14px"><span class="label">Injections into sessions, last 14 days</span>${columns(daily, { title: "Injections per day", labelled: "ends", tickEvery: 7 })}${dataTable(daily, ["Day", "Injections"])}</div></section>
|
|
231
|
-
<section class="panel span-6" aria-labelledby="agents"><h2 id="agents">Agents</h2><p class="lede">Runs recorded in this checkout's log (shared box or local runs). CI runs show up as their PRs on the KB repo.</p><div class="units">${agents.map(unit).join("")}</div></section>
|
|
232
|
+
<section class="panel span-6" aria-labelledby="agents"><h2 id="agents">Agents</h2><p class="lede">Runs recorded in this checkout's log (shared box or local runs). CI runs show up as their PRs on the KB repo.</p><div class="units">${agents.map(unit).join("")}</div><p><a href="/automation">Daily run decisions, review queue and developer sync</a></p></section>
|
|
232
233
|
<section class="panel span-6" aria-labelledby="recent"><h2 id="recent">Recent events</h2>${recent.length ? recentTable(recent) : '<p class="empty">No events yet. Sessions, MCP calls and captures will appear here.</p>'}<p><a href="/events">All events</a></p></section>
|
|
233
234
|
</div>`;
|
|
234
235
|
}
|
|
@@ -356,7 +357,7 @@ function rowEditor(section, config) {
|
|
|
356
357
|
if (section.rows === "team") {
|
|
357
358
|
const members = [...config.team.members, ...Array(3).fill(undefined)];
|
|
358
359
|
return `<div class="table-wrap"><table class="rows"><thead><tr><th>Id</th><th>Handle</th><th>Role</th><th>Areas</th><th title="The curator never reads this member's pull requests or review comments">Opted out</th><th>Remove</th></tr></thead><tbody>${members
|
|
359
|
-
.map((m, i) => `<tr><td><input type="text" name="member.${i}.id" value="${esc(m?.id ?? "")}" aria-label="Member id"></td><td><input type="text" name="member.${i}.handle" value="${esc(m?.handle ?? "")}" aria-label="Handle"></td><td><select name="member.${i}.role" aria-label="Role">${["member", "senior"].map((r) => `<option${(m?.role ?? "member") === r ? " selected" : ""}>${r}</option>`).join("")}</select></td><td><input type="text" name="member.${i}.areas" value="${esc(m?.areas.join(", ") ?? "")}" aria-label="Areas"></td><td><input type="checkbox" name="member.${i}.optOut"${m?.optOut ? " checked" : ""} aria-label="Opted out"></td><td>${m ? `<input type="checkbox" name="member.${i}.remove" aria-label="Remove ${esc(m.id)}">` : ""}</td></tr>`)
|
|
360
|
+
.map((m, i) => `<tr><td><input type="text" name="member.${i}.id" value="${esc(m?.id ?? "")}" aria-label="Member id"></td><td><input type="text" name="member.${i}.handle" value="${esc(m?.handle ?? "")}" aria-label="Handle"></td><td><select name="member.${i}.role" aria-label="Role">${["junior", "member", "senior", "lead"].map((r) => `<option${(m?.role ?? "member") === r ? " selected" : ""}>${r}</option>`).join("")}</select></td><td><input type="text" name="member.${i}.areas" value="${esc(m?.areas.join(", ") ?? "")}" aria-label="Areas"></td><td><input type="checkbox" name="member.${i}.optOut"${m?.optOut ? " checked" : ""} aria-label="Opted out"></td><td>${m ? `<input type="checkbox" name="member.${i}.remove" aria-label="Remove ${esc(m.id)}">` : ""}</td></tr>`)
|
|
360
361
|
.join("")}</tbody></table></div><p class="muted">Blank rows add members. Areas are comma-separated.</p>`;
|
|
361
362
|
}
|
|
362
363
|
if (section.rows === "teams") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeostat/dashboard",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The local dashboard for homeostat.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"homeostat",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"yaml": "^2.9.1",
|
|
37
|
-
"@homeostat/core": "0.1.
|
|
37
|
+
"@homeostat/core": "0.1.1"
|
|
38
38
|
},
|
|
39
39
|
"publishConfig": {
|
|
40
40
|
"access": "public",
|