@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.
- package/LICENSE +202 -0
- package/README.md +4 -2
- package/dist/src/automation.d.ts +44 -0
- package/dist/src/automation.js +142 -0
- package/dist/src/charts.d.ts +33 -0
- package/dist/src/charts.js +112 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/metrics.d.ts +91 -0
- package/dist/src/metrics.js +143 -0
- package/dist/src/server.d.ts +17 -0
- package/dist/src/server.js +180 -0
- package/dist/src/settings.d.ts +32 -0
- package/dist/src/settings.js +397 -0
- package/dist/src/views.d.ts +36 -0
- package/dist/src/views.js +397 -0
- package/package.json +40 -3
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { describeEvent, explainEntry, formatEvent, STATUSES, } from "@homeostat/core";
|
|
2
|
+
import { columns, dataTable, dial, esc, rows } from "./charts.js";
|
|
3
|
+
import { currentValue, SECTIONS } from "./settings.js";
|
|
4
|
+
const CSS = `
|
|
5
|
+
:root {
|
|
6
|
+
color-scheme: light;
|
|
7
|
+
--paper: #f6f5f1;
|
|
8
|
+
--surface: #fcfcfb;
|
|
9
|
+
--ink: #0b0b0b;
|
|
10
|
+
--ink-2: #52514e;
|
|
11
|
+
--ink-3: #85847e;
|
|
12
|
+
--rule: #e2e0d9;
|
|
13
|
+
--grid: rgba(11, 11, 11, 0.035);
|
|
14
|
+
--series: #2a78d6;
|
|
15
|
+
--series-soft: #cde2fb;
|
|
16
|
+
--good: #0ca30c;
|
|
17
|
+
--warning: #b37a00;
|
|
18
|
+
--critical: #d03b3b;
|
|
19
|
+
--focus: #2a78d6;
|
|
20
|
+
}
|
|
21
|
+
@media (prefers-color-scheme: dark) {
|
|
22
|
+
:root:not([data-theme="light"]) {
|
|
23
|
+
color-scheme: dark;
|
|
24
|
+
--paper: #121211;
|
|
25
|
+
--surface: #1a1a19;
|
|
26
|
+
--ink: #ffffff;
|
|
27
|
+
--ink-2: #c3c2b7;
|
|
28
|
+
--ink-3: #8f8e86;
|
|
29
|
+
--rule: #2e2e2b;
|
|
30
|
+
--grid: rgba(255, 255, 255, 0.03);
|
|
31
|
+
--series: #3987e5;
|
|
32
|
+
--series-soft: #184f95;
|
|
33
|
+
--good: #0ca30c;
|
|
34
|
+
--warning: #fab219;
|
|
35
|
+
--critical: #e66767;
|
|
36
|
+
--focus: #3987e5;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
:root[data-theme="dark"] {
|
|
40
|
+
color-scheme: dark;
|
|
41
|
+
--paper: #121211;
|
|
42
|
+
--surface: #1a1a19;
|
|
43
|
+
--ink: #ffffff;
|
|
44
|
+
--ink-2: #c3c2b7;
|
|
45
|
+
--ink-3: #8f8e86;
|
|
46
|
+
--rule: #2e2e2b;
|
|
47
|
+
--grid: rgba(255, 255, 255, 0.03);
|
|
48
|
+
--series: #3987e5;
|
|
49
|
+
--series-soft: #184f95;
|
|
50
|
+
--warning: #fab219;
|
|
51
|
+
--critical: #e66767;
|
|
52
|
+
--focus: #3987e5;
|
|
53
|
+
}
|
|
54
|
+
* { box-sizing: border-box; }
|
|
55
|
+
body {
|
|
56
|
+
margin: 0;
|
|
57
|
+
background-color: var(--paper);
|
|
58
|
+
background-image:
|
|
59
|
+
linear-gradient(var(--grid) 1px, transparent 1px),
|
|
60
|
+
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
|
61
|
+
background-size: 24px 24px;
|
|
62
|
+
color: var(--ink);
|
|
63
|
+
font: 15px/1.5 "Avenir Next", "Segoe UI Variable Text", "Helvetica Neue", sans-serif;
|
|
64
|
+
}
|
|
65
|
+
.mono, code, pre, td.num, .num, .chip, .stat .n, nav a, .label { font-family: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
|
|
66
|
+
h1, h2, h3 { font-family: "Iowan Old Style", Charter, "Bitstream Charter", "Sitka Text", Cambria, serif; font-weight: 600; letter-spacing: -0.01em; margin: 0; }
|
|
67
|
+
h1 { font-size: 28px; }
|
|
68
|
+
h2 { font-size: 19px; margin-bottom: 10px; }
|
|
69
|
+
a { color: var(--series); text-decoration-thickness: 1px; text-underline-offset: 3px; }
|
|
70
|
+
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
71
|
+
.shell { max-width: 1120px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
72
|
+
header.top { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; flex-wrap: wrap; border-bottom: 1px solid var(--rule); padding-bottom: 14px; margin-bottom: 18px; }
|
|
73
|
+
.brand { display: flex; align-items: baseline; gap: 12px; }
|
|
74
|
+
.brand .sub { color: var(--ink-3); font-size: 13px; }
|
|
75
|
+
nav { display: flex; flex-wrap: wrap; gap: 4px 14px; }
|
|
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
|
+
nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--ink); }
|
|
78
|
+
.grid { display: grid; gap: 16px; grid-template-columns: repeat(12, minmax(0, 1fr)); }
|
|
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; } .span-12 { grid-column: span 12; }
|
|
81
|
+
@media (max-width: 860px) { .span-4, .span-6, .span-8 { grid-column: span 12; } }
|
|
82
|
+
.label { color: var(--ink-3); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
83
|
+
.lede { color: var(--ink-2); margin: 4px 0 14px; max-width: 70ch; }
|
|
84
|
+
.stats { display: grid; grid-template-columns: repeat(var(--cols, 3), 1fr); gap: 1px; background: var(--rule); border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; }
|
|
85
|
+
.stat { background: var(--surface); padding: 12px 14px; }
|
|
86
|
+
@media (max-width: 600px) { .stats { --cols: 2 !important; } }
|
|
87
|
+
.recent { table-layout: fixed; font-size: 13px; }
|
|
88
|
+
.recent td { padding: 5px 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
89
|
+
.recent .when { width: 116px; color: var(--ink-3); }
|
|
90
|
+
.recent .what { width: 118px; color: var(--ink-2); }
|
|
91
|
+
.stat .n { font-size: 26px; line-height: 1.1; display: block; }
|
|
92
|
+
.stat .k { color: var(--ink-2); font-size: 12.5px; }
|
|
93
|
+
.units { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
|
94
|
+
@media (max-width: 600px) { .units { grid-template-columns: 1fr; } }
|
|
95
|
+
.unit { border: 1px solid var(--rule); border-radius: 6px; padding: 12px 14px; display: grid; gap: 4px; }
|
|
96
|
+
.unit .head { display: flex; align-items: center; gap: 8px; }
|
|
97
|
+
.lamp { width: 10px; height: 10px; border-radius: 50%; background: var(--ink-3); box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); color: var(--ink-3); flex: none; }
|
|
98
|
+
.lamp.good { background: var(--good); color: var(--good); } .lamp.warning { background: var(--warning); color: var(--warning); } .lamp.critical { background: var(--critical); color: var(--critical); }
|
|
99
|
+
.state { font-size: 12.5px; color: var(--ink-2); }
|
|
100
|
+
.chart { width: 100%; height: auto; display: block; overflow: visible; }
|
|
101
|
+
.chart path { fill: var(--series); }
|
|
102
|
+
.chart .hit { fill: transparent; }
|
|
103
|
+
.chart .m:hover path { fill: color-mix(in srgb, var(--series) 80%, var(--ink)); }
|
|
104
|
+
.chart .base { stroke: var(--rule); stroke-width: 1; }
|
|
105
|
+
.chart text { font: 11px ui-monospace, Menlo, monospace; fill: var(--ink-3); }
|
|
106
|
+
.chart text.v { fill: var(--ink-2); }
|
|
107
|
+
.dial { width: 100%; max-width: 260px; display: block; margin: 0 auto; }
|
|
108
|
+
.dial .track { fill: none; stroke: var(--rule); stroke-width: 10; stroke-linecap: round; }
|
|
109
|
+
.dial .fill { fill: none; stroke: var(--series); stroke-width: 10; stroke-linecap: round; }
|
|
110
|
+
.dial .tick { stroke: var(--ink-3); stroke-width: 1; } .dial .tick.major { stroke: var(--ink-2); stroke-width: 1.5; }
|
|
111
|
+
.dial .needle { stroke: var(--ink); stroke-width: 2; stroke-linecap: round; }
|
|
112
|
+
.dial .hub { fill: var(--ink); }
|
|
113
|
+
.dial-value { font: 600 26px "Iowan Old Style", Charter, Cambria, serif; fill: var(--ink); }
|
|
114
|
+
.dial-caption { font: 11px ui-monospace, Menlo, monospace; fill: var(--ink-3); text-transform: uppercase; letter-spacing: 0.08em; }
|
|
115
|
+
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
116
|
+
th { text-align: left; font: 11.5px ui-monospace, Menlo, monospace; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); font-weight: 500; border-bottom: 1px solid var(--rule); padding: 6px 8px; }
|
|
117
|
+
td { border-bottom: 1px solid var(--rule); padding: 7px 8px; vertical-align: top; }
|
|
118
|
+
tr:last-child td { border-bottom: 0; }
|
|
119
|
+
.num { text-align: right; }
|
|
120
|
+
.table-wrap { overflow-x: auto; }
|
|
121
|
+
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; padding: 1px 7px; border: 1px solid var(--rule); border-radius: 999px; color: var(--ink-2); white-space: nowrap; }
|
|
122
|
+
.chip::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); }
|
|
123
|
+
.chip.accepted::before { background: var(--good); } .chip.disputed::before { background: var(--critical); } .chip.candidate::before { background: var(--warning); }
|
|
124
|
+
.muted { color: var(--ink-3); }
|
|
125
|
+
.empty { color: var(--ink-3); font-style: italic; }
|
|
126
|
+
pre.entry { white-space: pre-wrap; background: var(--paper); border: 1px solid var(--rule); border-radius: 6px; padding: 14px; font-size: 13px; line-height: 1.55; }
|
|
127
|
+
details.table-view { margin-top: 8px; } details.table-view summary { cursor: pointer; color: var(--ink-3); font: 12px ui-monospace, Menlo, monospace; }
|
|
128
|
+
.events { font: 12.5px/1.7 ui-monospace, Menlo, monospace; color: var(--ink-2); white-space: pre-wrap; margin: 0; overflow-x: auto; }
|
|
129
|
+
form.settings { display: grid; gap: 14px; max-width: 760px; }
|
|
130
|
+
.field { display: grid; grid-template-columns: 240px 1fr; gap: 6px 16px; align-items: start; }
|
|
131
|
+
@media (max-width: 700px) { .field { grid-template-columns: 1fr; } }
|
|
132
|
+
.field label { padding-top: 6px; color: var(--ink-2); font-size: 14px; }
|
|
133
|
+
.field .help { grid-column: 2; color: var(--ink-3); font-size: 12.5px; margin-top: -2px; }
|
|
134
|
+
@media (max-width: 700px) { .field .help { grid-column: 1; } }
|
|
135
|
+
input[type=text], input[type=number], select, textarea { font: 13.5px ui-monospace, Menlo, monospace; color: var(--ink); background: var(--surface); border: 1px solid var(--rule); border-radius: 5px; padding: 6px 8px; width: 100%; }
|
|
136
|
+
textarea { min-height: 84px; resize: vertical; }
|
|
137
|
+
.checks { display: flex; flex-wrap: wrap; gap: 6px 16px; padding-top: 6px; }
|
|
138
|
+
.rows td { padding: 4px 6px; }
|
|
139
|
+
.rows input[type=text] { min-width: 90px; }
|
|
140
|
+
button { font: 600 13px ui-monospace, Menlo, monospace; text-transform: uppercase; letter-spacing: 0.06em; color: var(--surface); background: var(--ink); border: 0; border-radius: 5px; padding: 9px 16px; cursor: pointer; justify-self: start; }
|
|
141
|
+
.notice { border: 1px solid var(--rule); border-left: 3px solid var(--good); background: var(--surface); padding: 10px 14px; border-radius: 4px; margin-bottom: 14px; }
|
|
142
|
+
.notice.error { border-left-color: var(--critical); }
|
|
143
|
+
.notice ul { margin: 6px 0 0; padding-left: 18px; }
|
|
144
|
+
footer { margin-top: 36px; color: var(--ink-3); font-size: 12.5px; border-top: 1px solid var(--rule); padding-top: 12px; }
|
|
145
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
146
|
+
.dial .needle { transform-origin: 110px 104px; animation: settle 1.1s cubic-bezier(.2,.9,.2,1.15) both; }
|
|
147
|
+
@keyframes settle { from { transform: rotate(-90deg); } }
|
|
148
|
+
.panel { animation: rise .35s ease-out both; }
|
|
149
|
+
.grid > .panel:nth-child(2) { animation-delay: .04s; } .grid > .panel:nth-child(3) { animation-delay: .08s; } .grid > .panel:nth-child(4) { animation-delay: .12s; }
|
|
150
|
+
@keyframes rise { from { opacity: 0; transform: translateY(4px); } }
|
|
151
|
+
}
|
|
152
|
+
`;
|
|
153
|
+
const NAV = [
|
|
154
|
+
["/", "Overview"],
|
|
155
|
+
["/automation", "Automation"],
|
|
156
|
+
["/entries", "Entries"],
|
|
157
|
+
["/members", "Members"],
|
|
158
|
+
["/coverage", "Coverage"],
|
|
159
|
+
["/impact", "Impact"],
|
|
160
|
+
["/documents", "Documents"],
|
|
161
|
+
["/desire-paths", "Desire paths"],
|
|
162
|
+
["/events", "Events"],
|
|
163
|
+
["/settings", "Settings"],
|
|
164
|
+
];
|
|
165
|
+
export function layout(opts) {
|
|
166
|
+
const current = (href) => href === "/" ? opts.path === "/" : opts.path === href || opts.path.startsWith(`${href}/`);
|
|
167
|
+
return `<!doctype html>
|
|
168
|
+
<html lang="en"${opts.theme ? ` data-theme="${opts.theme}"` : ""}>
|
|
169
|
+
<head>
|
|
170
|
+
<meta charset="utf-8">
|
|
171
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
172
|
+
<title>${esc(opts.title)} · Homeostat</title>
|
|
173
|
+
<style>${CSS}</style>
|
|
174
|
+
</head>
|
|
175
|
+
<body>
|
|
176
|
+
<div class="shell">
|
|
177
|
+
<header class="top">
|
|
178
|
+
<div class="brand"><h1>Homeostat</h1><span class="sub mono" title="${esc(opts.kbRoot)}">${esc(opts.kbRoot.split("/").filter(Boolean).at(-1) ?? opts.kbRoot)}</span></div>
|
|
179
|
+
<nav aria-label="Sections">${NAV.map(([href, label]) => `<a href="${href}"${current(href) ? ' aria-current="page"' : ""}>${label}</a>`).join("")}</nav>
|
|
180
|
+
</header>
|
|
181
|
+
<main>${opts.body}</main>
|
|
182
|
+
<footer>Local dashboard. It reads this checkout and its git-ignored event log; nothing leaves this machine. Settings changes are written to homeostat.yaml here: commit them on a branch and open a PR.</footer>
|
|
183
|
+
</div>
|
|
184
|
+
</body>
|
|
185
|
+
</html>`;
|
|
186
|
+
}
|
|
187
|
+
const fmtTime = (iso) => (iso ? iso.replace("T", " ").slice(0, 16) : "—");
|
|
188
|
+
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
189
|
+
function lampFor(a) {
|
|
190
|
+
if (!a.enabled)
|
|
191
|
+
return { cls: "", text: "Disabled" };
|
|
192
|
+
if (!a.lastRun)
|
|
193
|
+
return { cls: "warning", text: "No run recorded here" };
|
|
194
|
+
return a.lastRun.ok
|
|
195
|
+
? { cls: "good", text: "Last run succeeded" }
|
|
196
|
+
: { cls: "critical", text: "Last run failed" };
|
|
197
|
+
}
|
|
198
|
+
function unit(a) {
|
|
199
|
+
const lamp = lampFor(a);
|
|
200
|
+
const name = a.agent === "curator" ? "Daily run" : "Deep dive";
|
|
201
|
+
return `<div class="unit">
|
|
202
|
+
<div class="head"><span class="lamp ${lamp.cls}" aria-hidden="true"></span><h3>${name}</h3><span class="state">${lamp.text}</span></div>
|
|
203
|
+
<div class="mono muted">${esc(a.schedule)} UTC</div>
|
|
204
|
+
<div>Last run <span class="mono">${fmtTime(a.lastRun?.ts)}</span>${a.lastRun?.summary ? ` · ${esc(a.lastRun.summary)}` : ""}</div>
|
|
205
|
+
<div>Next run <span class="mono">${fmtTime(a.nextRun)}</span></div>
|
|
206
|
+
<div>Failures this week <span class="mono">${a.failures7d}</span></div>
|
|
207
|
+
</div>`;
|
|
208
|
+
}
|
|
209
|
+
/** The latest events as a compact table: when, what, and the detail cut to one line. */
|
|
210
|
+
function recentTable(events) {
|
|
211
|
+
return `<table class="recent"><tbody>${events
|
|
212
|
+
.map((e) => {
|
|
213
|
+
const detail = describeEvent(e);
|
|
214
|
+
return `<tr><td class="when mono">${esc(e.ts.slice(5, 16).replace("T", " "))}</td><td class="what mono">${esc(e.type)}</td><td title="${esc(detail)}">${esc(detail)}</td></tr>`;
|
|
215
|
+
})
|
|
216
|
+
.join("")}</tbody></table>`;
|
|
217
|
+
}
|
|
218
|
+
export function overviewPage(h, agents, imp, recent) {
|
|
219
|
+
const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
|
|
220
|
+
const daily = imp.injectionsByDay.map((d) => ({
|
|
221
|
+
label: d.day.slice(5),
|
|
222
|
+
value: d.count,
|
|
223
|
+
title: `${d.day}: ${d.count} injections`,
|
|
224
|
+
}));
|
|
225
|
+
return `<div class="grid">
|
|
226
|
+
<section class="panel span-4" aria-labelledby="stability"><h2 id="stability">Stability</h2>
|
|
227
|
+
${dial(h.stability, { title: "Share of entries accepted, fresh and unflagged", caption: "accepted · fresh · unflagged" })}
|
|
228
|
+
<p class="lede">Of ${h.total} entries, the share an agent can rely on today: accepted, confirmed within the stale limit, and not flagged.</p></section>
|
|
229
|
+
<section class="panel span-8" aria-labelledby="kb"><h2 id="kb">Knowledge base</h2>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
234
|
+
</div>`;
|
|
235
|
+
}
|
|
236
|
+
export function entriesPage(entries, status) {
|
|
237
|
+
const shown = entries
|
|
238
|
+
.filter((e) => !status || e.frontmatter.status === status)
|
|
239
|
+
.sort((a, b) => a.frontmatter.area.localeCompare(b.frontmatter.area) ||
|
|
240
|
+
b.frontmatter.score - a.frontmatter.score);
|
|
241
|
+
const filters = ["all", ...STATUSES]
|
|
242
|
+
.map((s) => {
|
|
243
|
+
const active = (s === "all" && !status) || s === status;
|
|
244
|
+
return `<a href="/entries${s === "all" ? "" : `?status=${s}`}"${active ? ' aria-current="page"' : ""}>${s}</a>`;
|
|
245
|
+
})
|
|
246
|
+
.join(" · ");
|
|
247
|
+
return `<section class="panel"><h2>Entries</h2><p class="lede mono">${filters}</p>
|
|
248
|
+
<div class="table-wrap"><table><thead><tr><th>Status</th><th class="num">Score</th><th>Entry</th><th>Area</th><th>Owner</th><th>Confirmed</th></tr></thead><tbody>
|
|
249
|
+
${shown
|
|
250
|
+
.map((e) => `<tr><td><span class="chip ${e.frontmatter.status}">${e.frontmatter.status}</span></td><td class="num">${e.frontmatter.score}</td><td><a href="/entries/${esc(e.frontmatter.id)}">${esc(e.frontmatter.title)}</a><div class="mono muted">${esc(e.frontmatter.id)}${e.frontmatter.flags?.length ? ` · ${e.frontmatter.flags.length} flag(s)` : ""}</div></td><td>${esc(e.frontmatter.area)}</td><td>${esc(e.frontmatter.owner)}</td><td class="mono">${esc(e.frontmatter.lastConfirmed)}</td></tr>`)
|
|
251
|
+
.join("")}
|
|
252
|
+
</tbody></table></div>${shown.length ? "" : '<p class="empty">No entries with this status.</p>'}</section>`;
|
|
253
|
+
}
|
|
254
|
+
export function entryPage(entry, config) {
|
|
255
|
+
return `<section class="panel"><p class="mono"><a href="/entries">← Entries</a></p><pre class="entry">${esc(explainEntry(entry, config))}</pre>
|
|
256
|
+
<p class="lede">To dispute it, run <code>homeostat flag ${esc(entry.frontmatter.id)} --reason "…"</code> or use the flag tool from any agent.</p></section>`;
|
|
257
|
+
}
|
|
258
|
+
export function membersPage(rowsData) {
|
|
259
|
+
return `<section class="panel"><h2>Members</h2><p class="lede">What each person's sessions, reviews and captures have fed into the shared knowledge base. Listed alphabetically: this is contribution to a shared product, not a ranking.</p>
|
|
260
|
+
<div class="table-wrap"><table><thead><tr><th>Member</th><th>Role</th><th class="num">Entries authored</th><th class="num">Accepted</th><th class="num">Accepted share</th><th class="num">Captured, 30 days</th><th class="num">Flags raised</th></tr></thead><tbody>
|
|
261
|
+
${rowsData
|
|
262
|
+
.map((c) => `<tr><td class="mono">${esc(c.member)}</td><td>${esc(c.role)}</td><td class="num">${c.authored}</td><td class="num">${c.accepted}</td><td class="num">${c.acceptedRatio === undefined ? "—" : pct(c.acceptedRatio)}</td><td class="num">${c.captured30d}</td><td class="num">${c.flagsRaised}</td></tr>`)
|
|
263
|
+
.join("")}
|
|
264
|
+
</tbody></table></div></section>`;
|
|
265
|
+
}
|
|
266
|
+
export function coveragePage(cov) {
|
|
267
|
+
const bare = cov.areas.filter((a) => a.accepted === 0);
|
|
268
|
+
const data = cov.areas.map((a) => ({
|
|
269
|
+
label: a.area,
|
|
270
|
+
value: a.accepted,
|
|
271
|
+
title: `${a.area}: ${a.accepted} accepted, ${a.candidates} awaiting review`,
|
|
272
|
+
}));
|
|
273
|
+
return `<div class="grid"><section class="panel span-8"><h2>Accepted rules by area</h2>${rows(data, { title: "Accepted entries per area", labelWidth: 180 })}${dataTable(data, ["Area", "Accepted"])}
|
|
274
|
+
<div class="table-wrap" style="margin-top:12px"><table><thead><tr><th>Area</th><th>Paths</th><th class="num">Accepted</th><th class="num">Awaiting review</th></tr></thead><tbody>${cov.areas
|
|
275
|
+
.map((a) => `<tr><td>${esc(a.area)}</td><td class="mono">${esc(a.globs.join(", "))}</td><td class="num">${a.accepted}</td><td class="num">${a.candidates}</td></tr>`)
|
|
276
|
+
.join("")}</tbody></table></div></section>
|
|
277
|
+
<section class="panel span-4"><h2>Gaps</h2>${bare.length ? `<p class="lede">No accepted rules yet for:</p><ul>${bare.map((a) => `<li class="mono">${esc(a.area)}</li>`).join("")}</ul>` : '<p class="lede">Every area has at least one accepted rule.</p>'}
|
|
278
|
+
<h2 style="margin-top:18px">Source repos</h2>${cov.sources.length ? `<table><thead><tr><th>Repo</th><th class="num">Entries from its reviews</th></tr></thead><tbody>${cov.sources.map((s) => `<tr><td class="mono">${esc(s.repo)}</td><td class="num">${s.entries}</td></tr>`).join("")}</tbody></table>` : '<p class="empty">No sources configured (Settings → Repos and sources).</p>'}</section></div>`;
|
|
279
|
+
}
|
|
280
|
+
export function impactPage(imp) {
|
|
281
|
+
const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
|
|
282
|
+
const inj = imp.injectionsByDay.map((d) => ({
|
|
283
|
+
label: d.day.slice(5),
|
|
284
|
+
value: d.count,
|
|
285
|
+
title: `${d.day}: ${d.count} injections`,
|
|
286
|
+
}));
|
|
287
|
+
const mcp = imp.mcpCallsByDay.map((d) => ({
|
|
288
|
+
label: d.day.slice(5),
|
|
289
|
+
value: d.count,
|
|
290
|
+
title: `${d.day}: ${d.count} MCP calls`,
|
|
291
|
+
}));
|
|
292
|
+
const top = imp.topRules.map((r) => ({ label: r.id, value: r.injections }));
|
|
293
|
+
return `<div class="grid"><section class="panel"><div class="stats" style="--cols: 4">${stat(imp.sessions, "sessions with rules")}${stat(imp.rulesPerInjection, "rules per session")}${stat(imp.tokensPerInjection, "tokens per session")}${stat(imp.toolUse.reduce((n, t) => n + t.calls, 0), "MCP tool calls")}</div></section>
|
|
294
|
+
<section class="panel span-6"><h2>Injections per day</h2>${columns(inj, { title: "Injections per day, last 14 days", labelled: "ends", tickEvery: 7 })}${dataTable(inj, ["Day", "Injections"])}</section>
|
|
295
|
+
<section class="panel span-6"><h2>MCP calls per day</h2>${columns(mcp, { title: "MCP calls per day, last 14 days", labelled: "ends", tickEvery: 7 })}${dataTable(mcp, ["Day", "Calls"])}</section>
|
|
296
|
+
<section class="panel span-8"><h2>Most-used rules</h2>${top.length ? rows(top, { title: "Times each rule was put into a session" }) + dataTable(top, ["Rule", "Injections"]) : '<p class="empty">No injections recorded yet.</p>'}</section>
|
|
297
|
+
<section class="panel span-4"><h2>Tools agents call</h2>${imp.toolUse.length ? `<table><thead><tr><th>Tool</th><th class="num">Calls</th></tr></thead><tbody>${imp.toolUse.map((t) => `<tr><td class="mono">${esc(t.tool)}</td><td class="num">${t.calls}</td></tr>`).join("")}</tbody></table>` : '<p class="empty">No MCP calls recorded yet.</p>'}</section></div>`;
|
|
298
|
+
}
|
|
299
|
+
export function documentsPage(usage) {
|
|
300
|
+
const decaying = usage.filter((u) => u.decaying);
|
|
301
|
+
const stat = (n, k) => `<div class="stat"><span class="n">${n}</span><span class="k">${k}</span></div>`;
|
|
302
|
+
const docs = new Set(usage.map((u) => u.doc)).size;
|
|
303
|
+
const used = usage.filter((u) => u.reads + u.injected > 0).length;
|
|
304
|
+
return `<div class="grid"><section class="panel"><div class="stats" style="--cols: 4">${stat(docs, "documents")}${stat(usage.length, "sections")}${stat(used, "used in 90 days")}${stat(decaying.length, "to review")}</div></section>
|
|
305
|
+
<section class="panel"><h2>Section usage</h2><p class="lede">Reads through MCP and sessions each section was given in full, from this machine's event log over the last 90 days. A section nobody used or confirmed in 90 days is a review candidate: its owner confirms, updates or removes it.</p>
|
|
306
|
+
${usage.length
|
|
307
|
+
? `<div class="table-wrap"><table><thead><tr><th>Section</th><th class="num">Reads</th><th class="num">In sessions</th><th>Last used</th><th>Confirmed</th><th></th></tr></thead><tbody>${usage
|
|
308
|
+
.map((u) => `<tr><td><span class="mono">${esc(u.doc)}#${esc(u.id)}</span><br>${esc(u.heading)}</td><td class="num">${u.reads}</td><td class="num">${u.injected}</td><td class="mono">${u.lastUsed ? fmtTime(u.lastUsed) : "never"}</td><td class="mono">${esc(u.confirmed)}</td><td>${u.decaying ? '<span class="chip disputed">review</span>' : ""}</td></tr>`)
|
|
309
|
+
.join("")}</tbody></table></div>`
|
|
310
|
+
: '<p class="empty">No documents yet. Add them under knowledge/<area>/docs/, or run homeostat docs convert or homeostat seed.</p>'}</section></div>`;
|
|
311
|
+
}
|
|
312
|
+
export function desirePathsPage(paths) {
|
|
313
|
+
return `<section class="panel"><h2>Desire paths</h2><p class="lede">Tool names agents tried that are not the real ones. Resolved guesses already work as aliases; unresolved ones that keep coming back are worth turning into real tools.</p>
|
|
314
|
+
${paths.length
|
|
315
|
+
? `<div class="table-wrap"><table><thead><tr><th>Name tried</th><th>Resolved to</th><th class="num">Times</th><th>Last</th></tr></thead><tbody>${paths
|
|
316
|
+
.map((p) => `<tr><td class="mono">${esc(p.name)}</td><td>${p.resolved ? `<span class="mono">${esc(p.resolved)}</span>` : '<span class="chip disputed">unknown</span>'}</td><td class="num">${p.count}</td><td class="mono">${fmtTime(p.last)}</td></tr>`)
|
|
317
|
+
.join("")}</tbody></table></div>`
|
|
318
|
+
: '<p class="empty">No guessed tool names yet.</p>'}</section>`;
|
|
319
|
+
}
|
|
320
|
+
export function eventsPage(events) {
|
|
321
|
+
return `<section class="panel"><h2>Events, last 7 days</h2>${events.length ? `<pre class="events">${esc(events.map(formatEvent).join("\n"))}</pre>` : '<p class="empty">No events in the last 7 days.</p>'}</section>`;
|
|
322
|
+
}
|
|
323
|
+
export function settingsIndex() {
|
|
324
|
+
return `<section class="panel"><h2>Settings</h2><p class="lede">Every key in homeostat.yaml, as forms. Changes are validated before anything is written, and comments in the file are kept.</p>
|
|
325
|
+
<table><tbody>${SECTIONS.map((s) => `<tr><td><a href="/settings/${s.id}">${esc(s.title)}</a></td><td class="muted">${esc(s.intro)}</td></tr>`).join("")}</tbody></table></section>`;
|
|
326
|
+
}
|
|
327
|
+
function input(field, value) {
|
|
328
|
+
const name = esc(field.path.join("."));
|
|
329
|
+
const id = `f-${name.replace(/\./g, "-")}`;
|
|
330
|
+
let control;
|
|
331
|
+
switch (field.kind) {
|
|
332
|
+
case "checkbox":
|
|
333
|
+
control = `<div class="checks"><input type="checkbox" id="${id}" name="${name}"${value ? " checked" : ""}></div>`;
|
|
334
|
+
break;
|
|
335
|
+
case "checks":
|
|
336
|
+
control = `<div class="checks">${(field.options ?? [])
|
|
337
|
+
.map((o) => `<label><input type="checkbox" name="${name}" value="${esc(o)}"${value?.includes(o) ? " checked" : ""}> ${esc(o)}</label>`)
|
|
338
|
+
.join("")}</div>`;
|
|
339
|
+
break;
|
|
340
|
+
case "select":
|
|
341
|
+
control = `<select id="${id}" name="${name}">${(field.options ?? [])
|
|
342
|
+
.map((o) => `<option value="${esc(o)}"${String(value ?? "") === o ? " selected" : ""}>${esc(o || "(none)")}</option>`)
|
|
343
|
+
.join("")}</select>`;
|
|
344
|
+
break;
|
|
345
|
+
case "lines":
|
|
346
|
+
control = `<textarea id="${id}" name="${name}">${esc((value ?? []).join("\n"))}</textarea>`;
|
|
347
|
+
break;
|
|
348
|
+
case "number":
|
|
349
|
+
control = `<input type="number" id="${id}" name="${name}" value="${esc(value ?? "")}">`;
|
|
350
|
+
break;
|
|
351
|
+
default:
|
|
352
|
+
control = `<input type="text" id="${id}" name="${name}" value="${esc(value ?? "")}">`;
|
|
353
|
+
}
|
|
354
|
+
return `<div class="field"><label for="${id}">${esc(field.label)}</label>${control}${field.help ? `<div class="help">${esc(field.help)}</div>` : ""}</div>`;
|
|
355
|
+
}
|
|
356
|
+
function rowEditor(section, config) {
|
|
357
|
+
if (section.rows === "team") {
|
|
358
|
+
const members = [...config.team.members, ...Array(3).fill(undefined)];
|
|
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
|
|
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>`)
|
|
361
|
+
.join("")}</tbody></table></div><p class="muted">Blank rows add members. Areas are comma-separated.</p>`;
|
|
362
|
+
}
|
|
363
|
+
if (section.rows === "teams") {
|
|
364
|
+
const teams = [...Object.entries(config.teams), ...Array(2).fill(undefined)];
|
|
365
|
+
const cell = (i, key, value, label) => `<td><input type="text" name="teamrow.${i}.${key}" value="${esc(value)}" aria-label="${label}"></td>`;
|
|
366
|
+
return `<div class="table-wrap"><table class="rows"><thead><tr><th>Team</th><th>Handle</th><th>Members</th><th>Areas</th><th>Repos</th><th>Remove</th></tr></thead><tbody>${teams
|
|
367
|
+
.map(([name, t] = ["", undefined], i) => [
|
|
368
|
+
"<tr>",
|
|
369
|
+
cell(i, "name", name, "Team name"),
|
|
370
|
+
cell(i, "handle", t?.handle ?? "", "Team handle"),
|
|
371
|
+
cell(i, "members", t?.members.join(", ") ?? "", "Members"),
|
|
372
|
+
cell(i, "areas", t?.areas.join(", ") ?? "", "Areas"),
|
|
373
|
+
cell(i, "repos", t?.repos.join(", ") ?? "", "Repos"),
|
|
374
|
+
`<td>${t ? `<input type="checkbox" name="teamrow.${i}.remove" aria-label="Remove ${esc(name)}">` : ""}</td></tr>`,
|
|
375
|
+
].join(""))
|
|
376
|
+
.join("")}</tbody></table></div><p class="muted">Blank rows add teams. Lists are comma-separated; the handle is an @org/team.</p>`;
|
|
377
|
+
}
|
|
378
|
+
const areas = [...Object.entries(config.areas), ...Array(3).fill(undefined)];
|
|
379
|
+
return `<div class="table-wrap"><table class="rows"><thead><tr><th>Area</th><th>Path globs</th><th>Remove</th></tr></thead><tbody>${areas
|
|
380
|
+
.map((a, i) => `<tr><td><input type="text" name="area.${i}.name" value="${esc(a?.[0] ?? "")}" aria-label="Area name"></td><td><input type="text" name="area.${i}.globs" value="${esc(a?.[1]?.join(", ") ?? "")}" aria-label="Path globs"></td><td>${a ? `<input type="checkbox" name="area.${i}.remove" aria-label="Remove ${esc(a[0])}">` : ""}</td></tr>`)
|
|
381
|
+
.join("")}</tbody></table></div><p class="muted">Blank rows add areas. Globs are comma-separated.</p>`;
|
|
382
|
+
}
|
|
383
|
+
export function settingsPage(section, config, token, notice) {
|
|
384
|
+
const banner = notice?.saved
|
|
385
|
+
? '<div class="notice" role="status">Saved to homeostat.yaml. Commit it on a branch and open a PR.</div>'
|
|
386
|
+
: notice?.issues?.length
|
|
387
|
+
? `<div class="notice error" role="alert">Not saved: the result would be invalid.<ul>${notice.issues.map((i) => `<li class="mono">${esc(i.message)}</li>`).join("")}</ul></div>`
|
|
388
|
+
: "";
|
|
389
|
+
const body = section.rows
|
|
390
|
+
? rowEditor(section, config)
|
|
391
|
+
: (section.fields ?? []).map((f) => input(f, currentValue(config, f))).join("");
|
|
392
|
+
return `<section class="panel"><p class="mono"><a href="/settings">← Settings</a></p><h2>${esc(section.title)}</h2><p class="lede">${esc(section.intro)}</p>${banner}
|
|
393
|
+
<form class="settings" method="post" action="/settings/${section.id}"><input type="hidden" name="_token" value="${esc(token)}">${body}<button type="submit">Save</button></form></section>`;
|
|
394
|
+
}
|
|
395
|
+
export function messagePage(title, text) {
|
|
396
|
+
return `<section class="panel"><h2>${esc(title)}</h2><p class="lede">${esc(text)}</p></section>`;
|
|
397
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeostat/dashboard",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The local dashboard for homeostat.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"homeostat",
|
|
7
|
+
"dashboard"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/ysugarmen/homeostat#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/ysugarmen/homeostat/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/ysugarmen/homeostat.git",
|
|
16
|
+
"directory": "packages/dashboard"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": "Yonatan Sugarmen",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/src/index.d.ts",
|
|
24
|
+
"default": "./dist/src/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist/src",
|
|
29
|
+
"!dist/src/**/*.tsbuildinfo",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.18"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"yaml": "^2.9.1",
|
|
37
|
+
"@homeostat/core": "0.1.1"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
}
|
|
6
43
|
}
|