@homeostat/dashboard 0.0.0-stage → 0.1.0
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/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 +166 -0
- package/dist/src/settings.d.ts +32 -0
- package/dist/src/settings.js +358 -0
- package/dist/src/views.d.ts +36 -0
- package/dist/src/views.js +396 -0
- package/package.json +40 -3
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CONFIG_FILE, readEvents, readKbFiles, sectionUsage, validate, } from "@homeostat/core";
|
|
6
|
+
import { agentLiveness, contributions, coverage, desirePaths, health, impact, } from "./metrics.js";
|
|
7
|
+
import { applySection, SECTIONS } from "./settings.js";
|
|
8
|
+
import { coveragePage, desirePathsPage, documentsPage, entriesPage, entryPage, eventsPage, impactPage, layout, membersPage, messagePage, overviewPage, settingsIndex, settingsPage, } from "./views.js";
|
|
9
|
+
const MAX_BODY = 256 * 1024;
|
|
10
|
+
function readBody(req) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
let size = 0;
|
|
13
|
+
const chunks = [];
|
|
14
|
+
req.on("data", (c) => {
|
|
15
|
+
size += c.length;
|
|
16
|
+
if (size > MAX_BODY) {
|
|
17
|
+
reject(new Error("body too large"));
|
|
18
|
+
req.destroy();
|
|
19
|
+
}
|
|
20
|
+
else
|
|
21
|
+
chunks.push(c);
|
|
22
|
+
});
|
|
23
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
24
|
+
req.on("error", reject);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const sameToken = (a, b) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
28
|
+
/**
|
|
29
|
+
* The local dashboard: setup forms and metrics, rendered on the server from the KB checkout and
|
|
30
|
+
* its event log on every request, so it is always live. Binds to loopback and rejects requests
|
|
31
|
+
* whose Host or Origin is not this server, so other web pages cannot drive its forms.
|
|
32
|
+
*/
|
|
33
|
+
export function startDashboard(opts) {
|
|
34
|
+
const host = opts.host ?? "127.0.0.1";
|
|
35
|
+
const token = randomBytes(24).toString("hex");
|
|
36
|
+
const now = opts.now ?? (() => new Date());
|
|
37
|
+
// DNS-rebinding protection keys on the hostname: a browser only sends one of these when it
|
|
38
|
+
// really is talking to this machine. Any port, so a container's port mapping still works.
|
|
39
|
+
const allowedNames = new Set([
|
|
40
|
+
"localhost",
|
|
41
|
+
"127.0.0.1",
|
|
42
|
+
"[::1]",
|
|
43
|
+
...(host === "0.0.0.0" ? [] : [host]),
|
|
44
|
+
]);
|
|
45
|
+
const allowed = (hostPort) => {
|
|
46
|
+
try {
|
|
47
|
+
return Boolean(hostPort) && allowedNames.has(new URL(`http://${hostPort}`).hostname);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const snapshot = () => {
|
|
54
|
+
const kb = validate(readKbFiles(opts.kbRoot));
|
|
55
|
+
if (!kb.config)
|
|
56
|
+
return undefined;
|
|
57
|
+
const since = new Date(now().getTime() - 90 * 86_400_000).toISOString();
|
|
58
|
+
return {
|
|
59
|
+
config: kb.config,
|
|
60
|
+
entries: kb.entries.map((e) => e.entry),
|
|
61
|
+
docs: kb.docs,
|
|
62
|
+
events: readEvents(opts.kbRoot, { since }),
|
|
63
|
+
now: now(),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
const send = (res, status, html) => {
|
|
67
|
+
res.writeHead(status, {
|
|
68
|
+
"content-type": "text/html; charset=utf-8",
|
|
69
|
+
"cache-control": "no-store",
|
|
70
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'",
|
|
71
|
+
"x-content-type-options": "nosniff",
|
|
72
|
+
"referrer-policy": "no-referrer",
|
|
73
|
+
});
|
|
74
|
+
res.end(html);
|
|
75
|
+
};
|
|
76
|
+
const handler = async (req, res) => {
|
|
77
|
+
// DNS-rebinding and cross-site protection: only our own Host, and our own Origin on writes.
|
|
78
|
+
if (!allowed(req.headers.host))
|
|
79
|
+
return send(res, 421, "Misdirected request");
|
|
80
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
81
|
+
const t = url.searchParams.get("theme");
|
|
82
|
+
const theme = t === "light" || t === "dark" ? { theme: t } : {};
|
|
83
|
+
const page = (title, body, status = 200) => send(res, status, layout({ title, path: url.pathname, kbRoot: opts.kbRoot, body, ...theme }));
|
|
84
|
+
const snap = snapshot();
|
|
85
|
+
if (!snap) {
|
|
86
|
+
return page("No knowledge base", messagePage("No knowledge base", `${CONFIG_FILE} is missing or invalid in ${opts.kbRoot}. Run homeostat validate there.`), 500);
|
|
87
|
+
}
|
|
88
|
+
if (req.method === "POST") {
|
|
89
|
+
const origin = req.headers.origin;
|
|
90
|
+
if (origin && !allowed(new URL(origin).host))
|
|
91
|
+
return send(res, 403, "Cross-origin request refused");
|
|
92
|
+
const m = /^\/settings\/([a-z]+)$/.exec(url.pathname);
|
|
93
|
+
const section = m && SECTIONS.find((s) => s.id === m[1]);
|
|
94
|
+
if (!section)
|
|
95
|
+
return page("Not found", messagePage("Not found", "No such settings section."), 404);
|
|
96
|
+
let form;
|
|
97
|
+
try {
|
|
98
|
+
form = new URLSearchParams(await readBody(req));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return send(res, 413, "Too large");
|
|
102
|
+
}
|
|
103
|
+
if (!sameToken(form.get("_token") ?? "", token))
|
|
104
|
+
return send(res, 403, "Stale form: reload the page and try again");
|
|
105
|
+
const path = join(opts.kbRoot, CONFIG_FILE);
|
|
106
|
+
const result = applySection(readFileSync(path, "utf8"), section.id, form);
|
|
107
|
+
if (!result.text)
|
|
108
|
+
return page(section.title, settingsPage(section, snap.config, token, { issues: result.issues }), 422);
|
|
109
|
+
writeFileSync(path, result.text);
|
|
110
|
+
const fresh = snapshot();
|
|
111
|
+
return page(section.title, settingsPage(section, fresh?.config ?? snap.config, token, { saved: true }));
|
|
112
|
+
}
|
|
113
|
+
if (req.method !== "GET")
|
|
114
|
+
return send(res, 405, "Method not allowed");
|
|
115
|
+
const week = new Date(snap.now.getTime() - 7 * 86_400_000).toISOString();
|
|
116
|
+
switch (url.pathname) {
|
|
117
|
+
case "/": {
|
|
118
|
+
const recent = snap.events.slice(-12).reverse();
|
|
119
|
+
return page("Overview", overviewPage(health(snap), agentLiveness(snap), impact(snap), recent));
|
|
120
|
+
}
|
|
121
|
+
case "/entries":
|
|
122
|
+
return page("Entries", entriesPage(snap.entries, url.searchParams.get("status")));
|
|
123
|
+
case "/members":
|
|
124
|
+
return page("Members", membersPage(contributions(snap)));
|
|
125
|
+
case "/coverage":
|
|
126
|
+
return page("Coverage", coveragePage(coverage(snap)));
|
|
127
|
+
case "/impact":
|
|
128
|
+
return page("Impact", impactPage(impact(snap)));
|
|
129
|
+
case "/documents":
|
|
130
|
+
return page("Documents", documentsPage(sectionUsage(snap.docs ?? [], snap.events, snap.now)));
|
|
131
|
+
case "/desire-paths":
|
|
132
|
+
return page("Desire paths", desirePathsPage(desirePaths(snap)));
|
|
133
|
+
case "/events":
|
|
134
|
+
return page("Events", eventsPage(snap.events.filter((e) => e.ts >= week).reverse()));
|
|
135
|
+
case "/settings":
|
|
136
|
+
return page("Settings", settingsIndex());
|
|
137
|
+
}
|
|
138
|
+
const entry = /^\/entries\/([a-z0-9-]+)$/.exec(url.pathname);
|
|
139
|
+
if (entry) {
|
|
140
|
+
const found = snap.entries.find((e) => e.frontmatter.id === entry[1]);
|
|
141
|
+
return found
|
|
142
|
+
? page(found.frontmatter.title, entryPage(found, snap.config))
|
|
143
|
+
: page("Not found", messagePage("Not found", `No entry with id ${entry[1]}.`), 404);
|
|
144
|
+
}
|
|
145
|
+
const settings = /^\/settings\/([a-z]+)$/.exec(url.pathname);
|
|
146
|
+
const section = settings && SECTIONS.find((s) => s.id === settings[1]);
|
|
147
|
+
if (section)
|
|
148
|
+
return page(section.title, settingsPage(section, snap.config, token));
|
|
149
|
+
return page("Not found", messagePage("Not found", "Nothing here."), 404);
|
|
150
|
+
};
|
|
151
|
+
const server = createServer((req, res) => {
|
|
152
|
+
handler(req, res).catch((e) => {
|
|
153
|
+
send(res, 500, `Dashboard error: ${String(e.message).replace(/</g, "<")}`);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
server.listen(opts.port ?? 4747, host, () => {
|
|
158
|
+
const address = server.address();
|
|
159
|
+
const port = typeof address === "object" && address ? address.port : (opts.port ?? 4747);
|
|
160
|
+
resolve({
|
|
161
|
+
server,
|
|
162
|
+
url: `http://${host === "127.0.0.1" || host === "0.0.0.0" ? "localhost" : host}:${port}`,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Config, type Issue } from "@homeostat/core";
|
|
2
|
+
/** One editable config key. `path` is its location in homeostat.yaml. */
|
|
3
|
+
export interface Field {
|
|
4
|
+
path: string[];
|
|
5
|
+
label: string;
|
|
6
|
+
kind: "text" | "number" | "select" | "checkbox" | "checks" | "lines";
|
|
7
|
+
options?: readonly string[];
|
|
8
|
+
help?: string;
|
|
9
|
+
/** Empty means "remove the key" rather than an empty value. */
|
|
10
|
+
optional?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface Section {
|
|
13
|
+
id: string;
|
|
14
|
+
title: string;
|
|
15
|
+
intro: string;
|
|
16
|
+
fields?: Field[];
|
|
17
|
+
/** Row editors for the two keyed lists. */
|
|
18
|
+
rows?: "team" | "areas" | "teams";
|
|
19
|
+
}
|
|
20
|
+
/** Every section of homeostat.yaml, so every key is editable without touching YAML (4.2). */
|
|
21
|
+
export declare const SECTIONS: Section[];
|
|
22
|
+
/** The current value of a field, from the parsed (defaulted) config. */
|
|
23
|
+
export declare function currentValue(config: Config, field: Field): unknown;
|
|
24
|
+
export interface ApplyResult {
|
|
25
|
+
text?: string;
|
|
26
|
+
issues: Issue[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Applies one submitted section to the YAML text, keeping comments and untouched keys, and
|
|
30
|
+
* validates the result. Returns the new text only when it is valid.
|
|
31
|
+
*/
|
|
32
|
+
export declare function applySection(yamlText: string, sectionId: string, form: URLSearchParams): ApplyResult;
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { DEFAULT_AGENT_MODEL, DEFAULT_WEIGHTS, EXPORT_TARGETS, parseConfig, } from "@homeostat/core";
|
|
2
|
+
import { isMap, parseDocument } from "yaml";
|
|
3
|
+
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
4
|
+
const agentFields = (agent, label) => [
|
|
5
|
+
{ path: ["agents", agent, "enabled"], label: `${label}: enabled`, kind: "checkbox" },
|
|
6
|
+
{
|
|
7
|
+
path: ["agents", agent, "schedule"],
|
|
8
|
+
label: `${label}: schedule`,
|
|
9
|
+
kind: "text",
|
|
10
|
+
help: "Five-field cron, UTC",
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
path: ["agents", agent, "model"],
|
|
14
|
+
label: `${label}: model`,
|
|
15
|
+
kind: "text",
|
|
16
|
+
help: `Claude model id, e.g. ${DEFAULT_AGENT_MODEL}`,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
path: ["agents", agent, "effort"],
|
|
20
|
+
label: `${label}: effort`,
|
|
21
|
+
kind: "select",
|
|
22
|
+
options: EFFORTS,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
path: ["agents", agent, "lookbackHours"],
|
|
26
|
+
label: `${label}: look back (hours)`,
|
|
27
|
+
kind: "number",
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
/** Every section of homeostat.yaml, so every key is editable without touching YAML (4.2). */
|
|
31
|
+
export const SECTIONS = [
|
|
32
|
+
{
|
|
33
|
+
id: "team",
|
|
34
|
+
title: "Team and members",
|
|
35
|
+
intro: "Who contributes and reviews. Seniors own the config and areas nobody claims; handles feed CODEOWNERS.",
|
|
36
|
+
rows: "team",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "areas",
|
|
40
|
+
title: "Areas",
|
|
41
|
+
intro: "Parts of your code repos, as path globs. Core is implicit and applies everywhere.",
|
|
42
|
+
rows: "areas",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "teams",
|
|
46
|
+
title: "Teams",
|
|
47
|
+
intro: "Which team owns which areas: it approves their rules and documents. Every session still reads every team's rules for the files it touches.",
|
|
48
|
+
rows: "teams",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "sources",
|
|
52
|
+
title: "Repos and sources",
|
|
53
|
+
intro: "Where the curator reads merged pull requests, and the knowledge-base repo it opens PRs on.",
|
|
54
|
+
fields: [
|
|
55
|
+
{
|
|
56
|
+
path: ["sources", "host"],
|
|
57
|
+
label: "Git host",
|
|
58
|
+
kind: "select",
|
|
59
|
+
options: ["", "bitbucket", "github"],
|
|
60
|
+
help: "Empty removes the section",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
path: ["sources", "kbRepo"],
|
|
64
|
+
label: "Knowledge-base repo",
|
|
65
|
+
kind: "text",
|
|
66
|
+
help: "owner/name",
|
|
67
|
+
},
|
|
68
|
+
{ path: ["sources", "baseBranch"], label: "Base branch", kind: "text" },
|
|
69
|
+
{
|
|
70
|
+
path: ["sources", "repos"],
|
|
71
|
+
label: "Code repos",
|
|
72
|
+
kind: "lines",
|
|
73
|
+
help: "One owner/name per line",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
path: ["sources", "exclude", "repos"],
|
|
77
|
+
label: "Never read these repos",
|
|
78
|
+
kind: "lines",
|
|
79
|
+
help: "One owner/name per line",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
path: ["sources", "exclude", "paths"],
|
|
83
|
+
label: "Never read comments on these files",
|
|
84
|
+
kind: "lines",
|
|
85
|
+
help: "One glob per line, e.g. **/secrets/**",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: "scoring",
|
|
91
|
+
title: "Scoring rules",
|
|
92
|
+
intro: "Points per signal and the threshold at which a curator's entry is accepted without a reviewer.",
|
|
93
|
+
fields: [
|
|
94
|
+
...Object.keys(DEFAULT_WEIGHTS).map((k) => ({ path: ["scoring", "weights", k], label: `Weight: ${k}`, kind: "number" })),
|
|
95
|
+
{ path: ["scoring", "autoMergeThreshold"], label: "Auto-merge threshold", kind: "number" },
|
|
96
|
+
{ path: ["scoring", "autoMerge", "enabled"], label: "Auto-merge", kind: "checkbox" },
|
|
97
|
+
{
|
|
98
|
+
path: ["scoring", "autoMerge", "areas"],
|
|
99
|
+
label: "Auto-merge areas",
|
|
100
|
+
kind: "lines",
|
|
101
|
+
help: "One per line; empty means every area",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: ["scoring", "autoMerge", "excludeAreas"],
|
|
105
|
+
label: "Areas that always need a reviewer",
|
|
106
|
+
kind: "lines",
|
|
107
|
+
help: "One per line, e.g. core",
|
|
108
|
+
},
|
|
109
|
+
{ path: ["scoring", "autoMerge", "maxPerRun"], label: "Auto-merges per run", kind: "number" },
|
|
110
|
+
{
|
|
111
|
+
path: ["scoring", "reviewCadence"],
|
|
112
|
+
label: "Review cadence",
|
|
113
|
+
kind: "select",
|
|
114
|
+
options: ["daily", "weekly", "biweekly"],
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
id: "agents",
|
|
120
|
+
title: "Agent schedules",
|
|
121
|
+
intro: "How the agents reach Claude, when they run, and with which model.",
|
|
122
|
+
fields: [
|
|
123
|
+
{
|
|
124
|
+
path: ["agents", "runtime"],
|
|
125
|
+
label: "Claude runtime",
|
|
126
|
+
kind: "select",
|
|
127
|
+
options: ["api", "claude-code", "bedrock", "vertex"],
|
|
128
|
+
help: "api: Console key. claude-code: a Claude Code login (personal use, or a Team/Enterprise plan). bedrock / vertex: your own AWS or Google Cloud account",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
path: ["agents", "bedrock", "region"],
|
|
132
|
+
label: "Bedrock region",
|
|
133
|
+
kind: "text",
|
|
134
|
+
optional: true,
|
|
135
|
+
help: "e.g. eu-west-1; empty uses AWS_REGION",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
path: ["agents", "vertex", "projectId"],
|
|
139
|
+
label: "Vertex project",
|
|
140
|
+
kind: "text",
|
|
141
|
+
optional: true,
|
|
142
|
+
help: "Google Cloud project id",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
path: ["agents", "vertex", "region"],
|
|
146
|
+
label: "Vertex region",
|
|
147
|
+
kind: "text",
|
|
148
|
+
optional: true,
|
|
149
|
+
help: "global, us, eu or a region",
|
|
150
|
+
},
|
|
151
|
+
...agentFields("curator", "Curator"),
|
|
152
|
+
...agentFields("deepDive", "Deep dive"),
|
|
153
|
+
{
|
|
154
|
+
path: ["agents", "deepDive", "staleAfterDays"],
|
|
155
|
+
label: "Deep dive: retire after (days unconfirmed)",
|
|
156
|
+
kind: "number",
|
|
157
|
+
},
|
|
158
|
+
{ path: ["agents", "handoff", "enabled"], label: "Handoff notes: enabled", kind: "checkbox" },
|
|
159
|
+
{
|
|
160
|
+
path: ["agents", "handoff", "minEdits"],
|
|
161
|
+
label: "Handoff notes: edits before asking",
|
|
162
|
+
kind: "number",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
id: "distribution",
|
|
168
|
+
title: "Distribution",
|
|
169
|
+
intro: "Which agents receive the rules as files, and how much context each session gets.",
|
|
170
|
+
fields: [
|
|
171
|
+
{
|
|
172
|
+
path: ["distribution", "exports"],
|
|
173
|
+
label: "Export targets",
|
|
174
|
+
kind: "checks",
|
|
175
|
+
options: EXPORT_TARGETS,
|
|
176
|
+
},
|
|
177
|
+
{ path: ["distribution", "tokenBudget"], label: "Token budget per session", kind: "number" },
|
|
178
|
+
{
|
|
179
|
+
path: ["distribution", "docTokenBudget"],
|
|
180
|
+
label: "Token budget for documents",
|
|
181
|
+
kind: "number",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "notifications",
|
|
187
|
+
title: "Notifications",
|
|
188
|
+
intro: "The weekly digest. The webhook URL stays in your CI secrets; only its variable name lives here.",
|
|
189
|
+
fields: [
|
|
190
|
+
{
|
|
191
|
+
path: ["notifications", "slackWebhookEnv"],
|
|
192
|
+
label: "Slack webhook variable",
|
|
193
|
+
kind: "text",
|
|
194
|
+
optional: true,
|
|
195
|
+
help: "e.g. HOMEOSTAT_SLACK_WEBHOOK",
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
path: ["notifications", "digestDay"],
|
|
199
|
+
label: "Digest day",
|
|
200
|
+
kind: "select",
|
|
201
|
+
options: ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"],
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
id: "security",
|
|
207
|
+
title: "Security",
|
|
208
|
+
intro: "Domains rules may link to. Any other URL in a rule fails validation until a member signs the entry off; the git host is always allowed.",
|
|
209
|
+
fields: [
|
|
210
|
+
{
|
|
211
|
+
path: ["security", "allowedDomains"],
|
|
212
|
+
label: "Allowed domains",
|
|
213
|
+
kind: "lines",
|
|
214
|
+
help: "One domain per line, e.g. docs.example.com",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
path: ["security", "teamOnlyAreas"],
|
|
218
|
+
label: "Team-only areas",
|
|
219
|
+
kind: "lines",
|
|
220
|
+
help: "Never exported; only their members' sessions see them",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
path: ["security", "mcpWrites"],
|
|
224
|
+
label: "Let agents save and flag through MCP",
|
|
225
|
+
kind: "checkbox",
|
|
226
|
+
},
|
|
227
|
+
{ path: ["security", "mcpWriteLimit"], label: "MCP writes per session", kind: "number" },
|
|
228
|
+
{
|
|
229
|
+
path: ["security", "eventRetentionDays"],
|
|
230
|
+
label: "Keep local events for (days)",
|
|
231
|
+
kind: "number",
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
},
|
|
235
|
+
];
|
|
236
|
+
/** The current value of a field, from the parsed (defaulted) config. */
|
|
237
|
+
export function currentValue(config, field) {
|
|
238
|
+
let v = config;
|
|
239
|
+
for (const k of field.path)
|
|
240
|
+
v = v?.[k];
|
|
241
|
+
return v;
|
|
242
|
+
}
|
|
243
|
+
const lines = (s) => s
|
|
244
|
+
.split(/[\n,]/)
|
|
245
|
+
.map((x) => x.trim())
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
/**
|
|
248
|
+
* Applies one submitted section to the YAML text, keeping comments and untouched keys, and
|
|
249
|
+
* validates the result. Returns the new text only when it is valid.
|
|
250
|
+
*/
|
|
251
|
+
export function applySection(yamlText, sectionId, form) {
|
|
252
|
+
const section = SECTIONS.find((s) => s.id === sectionId);
|
|
253
|
+
if (!section)
|
|
254
|
+
return {
|
|
255
|
+
issues: [
|
|
256
|
+
{ code: "settings.section", severity: "error", message: `unknown section ${sectionId}` },
|
|
257
|
+
],
|
|
258
|
+
};
|
|
259
|
+
const doc = parseDocument(yamlText);
|
|
260
|
+
if (section.rows === "team") {
|
|
261
|
+
const members = rowIndices(form, "member").flatMap((i) => {
|
|
262
|
+
const id = form.get(`member.${i}.id`)?.trim();
|
|
263
|
+
if (!id || form.get(`member.${i}.remove`))
|
|
264
|
+
return [];
|
|
265
|
+
const handle = form.get(`member.${i}.handle`)?.trim();
|
|
266
|
+
const areas = lines(form.get(`member.${i}.areas`) ?? "");
|
|
267
|
+
return [
|
|
268
|
+
{
|
|
269
|
+
id,
|
|
270
|
+
...(handle ? { handle } : {}),
|
|
271
|
+
role: form.get(`member.${i}.role`) ?? "member",
|
|
272
|
+
areas,
|
|
273
|
+
...(form.get(`member.${i}.optOut`) ? { optOut: true } : {}),
|
|
274
|
+
},
|
|
275
|
+
];
|
|
276
|
+
});
|
|
277
|
+
doc.setIn(["team", "members"], doc.createNode(members));
|
|
278
|
+
}
|
|
279
|
+
else if (section.rows === "teams") {
|
|
280
|
+
const teams = {};
|
|
281
|
+
for (const i of rowIndices(form, "teamrow")) {
|
|
282
|
+
const name = form.get(`teamrow.${i}.name`)?.trim();
|
|
283
|
+
if (!name || form.get(`teamrow.${i}.remove`))
|
|
284
|
+
continue;
|
|
285
|
+
const handle = form.get(`teamrow.${i}.handle`)?.trim();
|
|
286
|
+
teams[name] = {
|
|
287
|
+
...(handle ? { handle } : {}),
|
|
288
|
+
members: lines(form.get(`teamrow.${i}.members`) ?? ""),
|
|
289
|
+
areas: lines(form.get(`teamrow.${i}.areas`) ?? ""),
|
|
290
|
+
repos: lines(form.get(`teamrow.${i}.repos`) ?? ""),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (Object.keys(teams).length)
|
|
294
|
+
doc.setIn(["teams"], doc.createNode(teams));
|
|
295
|
+
else if (doc.has("teams"))
|
|
296
|
+
doc.delete("teams");
|
|
297
|
+
}
|
|
298
|
+
else if (section.rows === "areas") {
|
|
299
|
+
const areas = {};
|
|
300
|
+
for (const i of rowIndices(form, "area")) {
|
|
301
|
+
const name = form.get(`area.${i}.name`)?.trim();
|
|
302
|
+
if (!name || form.get(`area.${i}.remove`))
|
|
303
|
+
continue;
|
|
304
|
+
areas[name] = lines(form.get(`area.${i}.globs`) ?? "");
|
|
305
|
+
}
|
|
306
|
+
doc.setIn(["areas"], doc.createNode(areas));
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
for (const field of section.fields ?? []) {
|
|
310
|
+
const name = field.path.join(".");
|
|
311
|
+
const raw = form.get(name);
|
|
312
|
+
if (field.path.join(".") === "sources.host" && !raw) {
|
|
313
|
+
doc.deleteIn(["sources"]);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
if (field.kind === "checkbox")
|
|
317
|
+
doc.setIn(field.path, form.get(name) === "on");
|
|
318
|
+
else if (field.kind === "checks")
|
|
319
|
+
doc.setIn(field.path, doc.createNode(form.getAll(name)));
|
|
320
|
+
else if (field.kind === "lines")
|
|
321
|
+
doc.setIn(field.path, doc.createNode(lines(raw ?? "")));
|
|
322
|
+
else if (field.kind === "number") {
|
|
323
|
+
// An empty or missing number means "use the default", so the key is removed.
|
|
324
|
+
if (raw === null || raw === "") {
|
|
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()) {
|
|
332
|
+
if (doc.hasIn(field.path))
|
|
333
|
+
doc.deleteIn(field.path);
|
|
334
|
+
}
|
|
335
|
+
else if (raw !== null)
|
|
336
|
+
doc.setIn(field.path, raw.trim());
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// Drop sections that became empty maps, so the defaults apply.
|
|
340
|
+
for (const key of ["notifications", "distribution", "scoring", "security"]) {
|
|
341
|
+
const node = doc.get(key, true);
|
|
342
|
+
if (isMap(node) && node.items.length === 0)
|
|
343
|
+
doc.delete(key);
|
|
344
|
+
}
|
|
345
|
+
const text = doc.toString();
|
|
346
|
+
const { config, issues } = parseConfig(text);
|
|
347
|
+
return config ? { text, issues: [] } : { issues };
|
|
348
|
+
}
|
|
349
|
+
/** Row numbers present in the form for a row editor (`member.0.id`, `member.3.id`, ...). */
|
|
350
|
+
function rowIndices(form, prefix) {
|
|
351
|
+
const seen = new Set();
|
|
352
|
+
for (const key of form.keys()) {
|
|
353
|
+
const m = new RegExp(`^${prefix}\\.(\\d+)\\.`).exec(key);
|
|
354
|
+
if (m)
|
|
355
|
+
seen.add(Number(m[1]));
|
|
356
|
+
}
|
|
357
|
+
return [...seen].sort((a, b) => a - b);
|
|
358
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Config, type Entry, type Event, type Issue, type SectionUsage } from "@homeostat/core";
|
|
2
|
+
import type { AgentLiveness, Contribution, DesirePath, Health, Impact } from "./metrics.ts";
|
|
3
|
+
import { type Section } from "./settings.ts";
|
|
4
|
+
export declare function layout(opts: {
|
|
5
|
+
title: string;
|
|
6
|
+
path: string;
|
|
7
|
+
kbRoot: string;
|
|
8
|
+
body: string;
|
|
9
|
+
theme?: "light" | "dark";
|
|
10
|
+
}): string;
|
|
11
|
+
export declare function overviewPage(h: Health, agents: AgentLiveness[], imp: Impact, recent: Event[]): string;
|
|
12
|
+
export declare function entriesPage(entries: readonly Entry[], status: string | null): string;
|
|
13
|
+
export declare function entryPage(entry: Entry, config: Config): string;
|
|
14
|
+
export declare function membersPage(rowsData: Contribution[]): string;
|
|
15
|
+
export declare function coveragePage(cov: {
|
|
16
|
+
areas: {
|
|
17
|
+
area: string;
|
|
18
|
+
globs: readonly string[];
|
|
19
|
+
accepted: number;
|
|
20
|
+
candidates: number;
|
|
21
|
+
}[];
|
|
22
|
+
sources: {
|
|
23
|
+
repo: string;
|
|
24
|
+
entries: number;
|
|
25
|
+
}[];
|
|
26
|
+
}): string;
|
|
27
|
+
export declare function impactPage(imp: Impact): string;
|
|
28
|
+
export declare function documentsPage(usage: readonly SectionUsage[]): string;
|
|
29
|
+
export declare function desirePathsPage(paths: DesirePath[]): string;
|
|
30
|
+
export declare function eventsPage(events: Event[]): string;
|
|
31
|
+
export declare function settingsIndex(): string;
|
|
32
|
+
export declare function settingsPage(section: Section, config: Config, token: string, notice?: {
|
|
33
|
+
saved?: boolean;
|
|
34
|
+
issues?: Issue[];
|
|
35
|
+
}): string;
|
|
36
|
+
export declare function messagePage(title: string, text: string): string;
|