akm-cli 0.10.261005-beta → 0.10.261007-beta
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/CHANGELOG.md +65 -0
- package/STABILITY.md +9 -0
- package/dist/assets/ui/app.js +91 -0
- package/dist/assets/ui/asset.js +67 -0
- package/dist/assets/ui/bundles.js +80 -0
- package/dist/assets/ui/changes.js +32 -0
- package/dist/assets/ui/charts.js +93 -0
- package/dist/assets/ui/config.js +69 -0
- package/dist/assets/ui/diff-view.js +202 -0
- package/dist/assets/ui/diff.js +261 -0
- package/dist/assets/ui/index.html +30 -0
- package/dist/assets/ui/lib.js +238 -0
- package/dist/assets/ui/markdown.js +103 -0
- package/dist/assets/ui/overview.js +145 -0
- package/dist/assets/ui/proposal-actions.js +273 -0
- package/dist/assets/ui/proposals.js +219 -0
- package/dist/assets/ui/search.js +62 -0
- package/dist/assets/ui/styles.css +1415 -0
- package/dist/cli.js +3 -0
- package/dist/commands/health/checks.js +5 -3
- package/dist/commands/health/egress.js +5 -1
- package/dist/commands/health/engine-usage.js +2 -2
- package/dist/commands/health/llm-usage.js +2 -2
- package/dist/commands/health/windows.js +5 -5
- package/dist/commands/health.js +40 -8
- package/dist/commands/improve/reflect-noise.js +4 -0
- package/dist/commands/improve/reflect.js +14 -5
- package/dist/commands/metrics/metrics-cli.js +3 -3
- package/dist/commands/proposal/propose-cli.js +5 -0
- package/dist/commands/proposal/propose.js +6 -2
- package/dist/commands/proposal/repository.js +34 -29
- package/dist/commands/read/search.js +2 -1
- package/dist/commands/read/show.js +18 -12
- package/dist/commands/sources/source-clone.js +123 -11
- package/dist/commands/ui/api.js +556 -0
- package/dist/commands/ui/git-read.js +101 -0
- package/dist/commands/ui/open-browser.js +30 -0
- package/dist/commands/ui/proposal-actions.js +179 -0
- package/dist/commands/ui/redact.js +88 -0
- package/dist/commands/ui/server.js +352 -0
- package/dist/commands/ui/ui-cli.js +62 -0
- package/dist/core/asset/resolve-ref.js +7 -3
- package/dist/indexer/indexer.js +14 -2
- package/dist/indexer/search/db-search.js +7 -3
- package/dist/output/format-exempt.js +3 -0
- package/dist/storage/repositories/improve-runs-repository.js +6 -0
- package/dist/storage/repositories/index-db.js +10 -2
- package/dist/storage/repositories/metrics-repository.js +17 -0
- package/docs/architecture/0.10-process-rules.md +3 -4
- package/docs/architecture/improvement.md +2 -1
- package/docs/architecture/internals/improve-workflow.md +2 -2
- package/docs/reference/README.md +1 -0
- package/docs/reference/cli.md +68 -0
- package/docs/reference/ui-api.md +576 -0
- package/package.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Shared helpers for the akm ui client: HTML escaping, the API client, formatting.
|
|
2
|
+
//
|
|
3
|
+
// Every string that reaches the DOM goes through `html` (a tagged template that
|
|
4
|
+
// escapes each interpolated value) and `mount` (the only place in the client
|
|
5
|
+
// that writes markup into the page). Nothing else assigns markup, so reading
|
|
6
|
+
// this file is enough to audit how page content is escaped.
|
|
7
|
+
|
|
8
|
+
const ENTITIES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
9
|
+
|
|
10
|
+
/** Escape text for use in element content or a quoted attribute value. */
|
|
11
|
+
export function esc(value) {
|
|
12
|
+
return String(value ?? "").replace(/[&<>"']/g, (c) => ENTITIES[c]);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Markup that is already safe: only `html` creates one. */
|
|
16
|
+
class SafeHtml {
|
|
17
|
+
constructor(text) {
|
|
18
|
+
this.text = text;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function part(value) {
|
|
23
|
+
if (value instanceof SafeHtml) return value.text;
|
|
24
|
+
if (Array.isArray(value)) return value.map(part).join("");
|
|
25
|
+
if (value === null || value === undefined || value === false) return "";
|
|
26
|
+
return esc(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Tagged template: literal parts are trusted markup, every interpolated value is escaped unless it is itself `html`. */
|
|
30
|
+
export function html(strings, ...values) {
|
|
31
|
+
let out = strings[0];
|
|
32
|
+
for (let i = 0; i < values.length; i++) out += part(values[i]) + strings[i + 1];
|
|
33
|
+
return new SafeHtml(out);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Replace the contents of `element` with `safe`. The one markup write in the client. */
|
|
37
|
+
export function mount(element, safe) {
|
|
38
|
+
if (!(safe instanceof SafeHtml)) throw new TypeError("mount() takes the result of html``");
|
|
39
|
+
element.innerHTML = safe.text;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- token and API client --------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const TOKEN_KEY = "akm-ui-token";
|
|
45
|
+
let token = null;
|
|
46
|
+
|
|
47
|
+
/** Read the launch token from the URL (then drop it from the address bar) or from sessionStorage. */
|
|
48
|
+
export function initToken() {
|
|
49
|
+
const params = new URLSearchParams(location.search);
|
|
50
|
+
token = params.get("token");
|
|
51
|
+
try {
|
|
52
|
+
if (token) sessionStorage.setItem(TOKEN_KEY, token);
|
|
53
|
+
else token = sessionStorage.getItem(TOKEN_KEY);
|
|
54
|
+
} catch {
|
|
55
|
+
// sessionStorage can be unavailable; the URL token still works for this load.
|
|
56
|
+
}
|
|
57
|
+
if (params.has("token")) history.replaceState(null, "", location.pathname + location.hash);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ApiError extends Error {
|
|
61
|
+
constructor(status, code, message) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.status = status;
|
|
64
|
+
this.code = code;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Send one request and resolve to the response's `data`; failures are an {@link ApiError} with the server's `code`. */
|
|
69
|
+
async function request(url, init) {
|
|
70
|
+
let response;
|
|
71
|
+
try {
|
|
72
|
+
response = await fetch(url, init);
|
|
73
|
+
} catch {
|
|
74
|
+
throw new ApiError(0, "NETWORK", "Could not reach the akm ui server. It may have stopped; run `akm ui` again.");
|
|
75
|
+
}
|
|
76
|
+
let body = null;
|
|
77
|
+
try {
|
|
78
|
+
body = await response.json();
|
|
79
|
+
} catch {
|
|
80
|
+
// Fall through to the generic error below.
|
|
81
|
+
}
|
|
82
|
+
if (response.status === 401) {
|
|
83
|
+
throw new ApiError(401, "UNAUTHORIZED", "The session token is missing or no longer valid.");
|
|
84
|
+
}
|
|
85
|
+
if (!body || body.ok !== true) {
|
|
86
|
+
throw new ApiError(
|
|
87
|
+
response.status,
|
|
88
|
+
body?.code ?? "BAD_RESPONSE",
|
|
89
|
+
body?.error ?? `Unexpected response (HTTP ${response.status}).`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return body.data;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** GET `path` with optional query values (empty ones are dropped); resolves to the response's `data`. */
|
|
96
|
+
export function api(path, query = {}) {
|
|
97
|
+
const search = new URLSearchParams();
|
|
98
|
+
for (const [key, value] of Object.entries(query)) {
|
|
99
|
+
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
|
|
100
|
+
}
|
|
101
|
+
const url = search.size > 0 ? `${path}?${search}` : path;
|
|
102
|
+
return request(url, { headers: { "X-Akm-Ui-Token": token ?? "" }, cache: "no-store" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** POST `body` as JSON to `path`. Only the proposal accept/reject routes take one; the token goes in the header, never the URL. */
|
|
106
|
+
export function post(path, body = {}) {
|
|
107
|
+
return request(path, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: { "X-Akm-Ui-Token": token ?? "", "Content-Type": "application/json" },
|
|
110
|
+
body: JSON.stringify(body),
|
|
111
|
+
cache: "no-store",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let metaPromise = null;
|
|
116
|
+
/** `/api/meta`, fetched once. */
|
|
117
|
+
export function getMeta() {
|
|
118
|
+
if (!metaPromise) {
|
|
119
|
+
metaPromise = api("/api/meta");
|
|
120
|
+
metaPromise.catch(() => {
|
|
121
|
+
metaPromise = null;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return metaPromise;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- formatting ------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export function fmtNum(value) {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) ? value.toLocaleString() : "-";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function fmtPct(value) {
|
|
134
|
+
return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value * 100)}%` : "-";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function fmtMs(ms) {
|
|
138
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return "-";
|
|
139
|
+
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
140
|
+
const seconds = ms / 1000;
|
|
141
|
+
if (seconds < 60) return `${seconds < 10 ? seconds.toFixed(1) : Math.round(seconds)} s`;
|
|
142
|
+
const minutes = Math.floor(seconds / 60);
|
|
143
|
+
if (minutes < 60) return `${minutes}m ${Math.round(seconds - minutes * 60)}s`;
|
|
144
|
+
const hours = Math.floor(minutes / 60);
|
|
145
|
+
return `${hours}h ${minutes - hours * 60}m`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** "3h ago" style age of an ISO timestamp. */
|
|
149
|
+
export function ago(iso) {
|
|
150
|
+
const t = Date.parse(iso ?? "");
|
|
151
|
+
if (Number.isNaN(t)) return "-";
|
|
152
|
+
const seconds = Math.round((Date.now() - t) / 1000);
|
|
153
|
+
if (seconds < 0) return "just now";
|
|
154
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
155
|
+
const minutes = Math.round(seconds / 60);
|
|
156
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
157
|
+
const hours = Math.round(minutes / 60);
|
|
158
|
+
if (hours < 48) return `${hours}h ago`;
|
|
159
|
+
return `${Math.round(hours / 24)}d ago`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function fmtDateTime(iso) {
|
|
163
|
+
const t = Date.parse(iso ?? "");
|
|
164
|
+
return Number.isNaN(t) ? "-" : new Date(t).toLocaleString();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A relative time with the absolute time as its tooltip. */
|
|
168
|
+
export function when(iso) {
|
|
169
|
+
return html`<time datetime="${iso ?? ""}" title="${fmtDateTime(iso)}">${ago(iso)}</time>`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- small building blocks -------------------------------------------------
|
|
173
|
+
|
|
174
|
+
export function assetHref(ref) {
|
|
175
|
+
return `#/asset?ref=${encodeURIComponent(ref)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function proposalHref(id) {
|
|
179
|
+
return `#/proposals/${encodeURIComponent(id)}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Status pill: `kind` is ok | warn | fail | neutral | info. */
|
|
183
|
+
export function badge(text, kind = "neutral") {
|
|
184
|
+
return html`<span class="badge badge-${kind}">${text}</span>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function statusKind(status) {
|
|
188
|
+
if (status === "pass" || status === "ok") return "ok";
|
|
189
|
+
if (status === "warn") return "warn";
|
|
190
|
+
if (status === "fail") return "fail";
|
|
191
|
+
return "neutral";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function tableWrap(inner) {
|
|
195
|
+
return html`<div class="table-wrap">${inner}</div>`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function emptyState(title, detail = "") {
|
|
199
|
+
return html`<div class="state state-empty"><p class="state-title">${title}</p>${detail ? html`<p>${detail}</p>` : ""}</div>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function loadingState(label = "Loading") {
|
|
203
|
+
return html`<div class="state state-loading" role="status"><span class="spinner" aria-hidden="true"></span>${label}...</div>`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function errorState(error) {
|
|
207
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
208
|
+
return html`<div class="state state-error" role="alert">
|
|
209
|
+
<p class="state-title">Not signed in</p>
|
|
210
|
+
<p>${error.message} Open the URL printed by <code>akm ui</code> (it ends in <code>?token=...</code>) to start a session.</p>
|
|
211
|
+
</div>`;
|
|
212
|
+
}
|
|
213
|
+
const code = error instanceof ApiError ? error.code : "ERROR";
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
return html`<div class="state state-error" role="alert">
|
|
216
|
+
<p class="state-title">Could not load this page</p>
|
|
217
|
+
<p>${message}</p>
|
|
218
|
+
<p class="muted"><code>${code}</code></p>
|
|
219
|
+
</div>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function pageHeader(title, subtitle = "") {
|
|
223
|
+
return html`<header class="page-head"><h1>${title}</h1>${subtitle ? html`<p class="muted">${subtitle}</p>` : ""}</header>`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A definition list from [label, value] pairs; null/undefined/empty values are skipped. */
|
|
227
|
+
export function definitionList(pairs) {
|
|
228
|
+
const rows = pairs.filter(([, value]) => value !== null && value !== undefined && value !== "");
|
|
229
|
+
return html`<dl class="kv">${rows.map(([label, value]) => html`<dt>${label}</dt><dd>${value}</dd>`)}</dl>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function pretty(value) {
|
|
233
|
+
return JSON.stringify(value, null, 2);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function percent(part, total) {
|
|
237
|
+
return total > 0 ? Math.round((part / total) * 1000) / 10 : 0;
|
|
238
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// A deliberately tiny markdown renderer: headings, lists, fenced code, inline code, bold, links and [[wikilinks]].
|
|
2
|
+
// It never touches raw text except through `html`, which escapes every interpolated value.
|
|
3
|
+
|
|
4
|
+
import { assetHref, html } from "./lib.js";
|
|
5
|
+
|
|
6
|
+
const SAFE_URL = /^(https?:\/\/|mailto:|#)/i;
|
|
7
|
+
const INLINE = /`([^`]+)`|\[\[([^\]]+)\]\]|\[([^\]]+)\]\(([^)\s]+)\)|\*\*([^*]+)\*\*/g;
|
|
8
|
+
|
|
9
|
+
function link(text, url) {
|
|
10
|
+
if (!SAFE_URL.test(url)) return html`${text}`;
|
|
11
|
+
const external = /^(https?:|mailto:)/i.test(url);
|
|
12
|
+
return external
|
|
13
|
+
? html`<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
|
14
|
+
: html`<a href="${url}">${text}</a>`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Inline spans of one line of text. */
|
|
18
|
+
export function inline(text) {
|
|
19
|
+
const out = [];
|
|
20
|
+
let last = 0;
|
|
21
|
+
for (const m of text.matchAll(INLINE)) {
|
|
22
|
+
out.push(text.slice(last, m.index));
|
|
23
|
+
if (m[1] !== undefined) out.push(html`<code>${m[1]}</code>`);
|
|
24
|
+
else if (m[2] !== undefined)
|
|
25
|
+
out.push(html`<a href="${assetHref(m[2].split("|")[0].trim())}"><code>${m[2]}</code></a>`);
|
|
26
|
+
else if (m[3] !== undefined) out.push(link(m[3], m[4]));
|
|
27
|
+
else out.push(html`<strong>${m[5]}</strong>`);
|
|
28
|
+
last = m.index + m[0].length;
|
|
29
|
+
}
|
|
30
|
+
out.push(text.slice(last));
|
|
31
|
+
return html`${out}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Render markdown text to safe HTML. */
|
|
35
|
+
export function markdown(source) {
|
|
36
|
+
const lines = String(source ?? "")
|
|
37
|
+
.replace(/\r\n?/g, "\n")
|
|
38
|
+
.split("\n");
|
|
39
|
+
const blocks = [];
|
|
40
|
+
let paragraph = [];
|
|
41
|
+
let list = null;
|
|
42
|
+
const flushParagraph = () => {
|
|
43
|
+
if (paragraph.length > 0) blocks.push(html`<p>${inline(paragraph.join(" "))}</p>`);
|
|
44
|
+
paragraph = [];
|
|
45
|
+
};
|
|
46
|
+
const flushList = () => {
|
|
47
|
+
if (list) blocks.push(list.ordered ? html`<ol>${list.items}</ol>` : html`<ul>${list.items}</ul>`);
|
|
48
|
+
list = null;
|
|
49
|
+
};
|
|
50
|
+
for (let i = 0; i < lines.length; i++) {
|
|
51
|
+
const line = lines[i];
|
|
52
|
+
const fence = /^```\s*(\S*)/.exec(line);
|
|
53
|
+
if (fence) {
|
|
54
|
+
flushParagraph();
|
|
55
|
+
flushList();
|
|
56
|
+
const code = [];
|
|
57
|
+
for (i++; i < lines.length && !/^```/.test(lines[i]); i++) code.push(lines[i]);
|
|
58
|
+
blocks.push(html`<pre class="code"><code>${code.join("\n")}</code></pre>`);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
62
|
+
if (heading) {
|
|
63
|
+
flushParagraph();
|
|
64
|
+
flushList();
|
|
65
|
+
const level = Math.min(heading[1].length + 1, 6);
|
|
66
|
+
blocks.push(html`<h${level}>${inline(heading[2])}</h${level}>`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const item = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/.exec(line);
|
|
70
|
+
if (item) {
|
|
71
|
+
flushParagraph();
|
|
72
|
+
const ordered = item[2] !== undefined;
|
|
73
|
+
if (list && list.ordered !== ordered) flushList();
|
|
74
|
+
list ??= { ordered, items: [] };
|
|
75
|
+
list.items.push(html`<li>${inline(item[3])}</li>`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (line.trim() === "") {
|
|
79
|
+
flushParagraph();
|
|
80
|
+
flushList();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
flushList();
|
|
84
|
+
paragraph.push(line.trim());
|
|
85
|
+
}
|
|
86
|
+
flushParagraph();
|
|
87
|
+
flushList();
|
|
88
|
+
return html`<div class="md">${blocks}</div>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Split leading `---` frontmatter from `content`: { fields: [[key, value]], body }. */
|
|
92
|
+
export function splitFrontmatter(content) {
|
|
93
|
+
const text = String(content ?? "").replace(/\r\n?/g, "\n");
|
|
94
|
+
const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(text);
|
|
95
|
+
if (!m) return { fields: [], body: text };
|
|
96
|
+
const fields = [];
|
|
97
|
+
for (const line of m[1].split("\n")) {
|
|
98
|
+
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
|
|
99
|
+
if (kv) fields.push([kv[1], kv[2]]);
|
|
100
|
+
else if (fields.length > 0 && line.trim() !== "") fields[fields.length - 1][1] += `\n${line}`;
|
|
101
|
+
}
|
|
102
|
+
return { fields, body: text.slice(m[0].length) };
|
|
103
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Overview: health status, check results, metrics, recent improve runs and the 14-day usage chart.
|
|
2
|
+
|
|
3
|
+
import { miniBar, usageChart } from "./charts.js";
|
|
4
|
+
import {
|
|
5
|
+
api,
|
|
6
|
+
badge,
|
|
7
|
+
emptyState,
|
|
8
|
+
fmtMs,
|
|
9
|
+
fmtNum,
|
|
10
|
+
fmtPct,
|
|
11
|
+
getMeta,
|
|
12
|
+
html,
|
|
13
|
+
pageHeader,
|
|
14
|
+
pretty,
|
|
15
|
+
statusKind,
|
|
16
|
+
tableWrap,
|
|
17
|
+
when,
|
|
18
|
+
} from "./lib.js";
|
|
19
|
+
|
|
20
|
+
const GROUPS = [
|
|
21
|
+
{ status: "fail", title: "Failing", open: true },
|
|
22
|
+
{ status: "warn", title: "Warnings", open: true },
|
|
23
|
+
{ status: "unknown", title: "Unknown", open: false },
|
|
24
|
+
{ status: "pass", title: "Passing", open: false },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
function checkItem(check) {
|
|
28
|
+
const evidence = check.evidence && Object.keys(check.evidence).length > 0 ? check.evidence : null;
|
|
29
|
+
return html`<li class="check">
|
|
30
|
+
<div class="check-head"><span class="dot dot-${statusKind(check.status)}" aria-hidden="true"></span><strong class="mono">${check.name}</strong>
|
|
31
|
+
<span class="muted small">${check.kind}, ${check.confidence} confidence</span></div>
|
|
32
|
+
<p class="check-msg">${check.message}</p>
|
|
33
|
+
${evidence ? html`<details><summary>Evidence</summary><pre class="code">${pretty(evidence)}</pre></details>` : ""}
|
|
34
|
+
</li>`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function healthSection(health) {
|
|
38
|
+
const checks = [...health.hardChecks, ...health.advisories];
|
|
39
|
+
return html`<section><h2>Health checks</h2>${GROUPS.map((g) => {
|
|
40
|
+
const items = checks.filter((c) => c.status === g.status);
|
|
41
|
+
if (items.length === 0) return "";
|
|
42
|
+
return html`<details class="group" ${g.open ? html`open` : ""}><summary><span class="dot dot-${statusKind(g.status)}" aria-hidden="true"></span>${g.title} <span class="num muted">${items.length}</span></summary><ul class="checks">${items.map(checkItem)}</ul></details>`;
|
|
43
|
+
})}${checks.length === 0 ? emptyState("No health checks ran.") : ""}</section>`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function statusStrip(health, meta) {
|
|
47
|
+
const checks = health ? [...health.hardChecks, ...health.advisories] : [];
|
|
48
|
+
const count = (status) => checks.filter((c) => c.status === status).length;
|
|
49
|
+
const overall = health?.status ?? "unknown";
|
|
50
|
+
return html`<div class="strip">
|
|
51
|
+
<div class="strip-main"><span class="strip-label">Overall health</span>${badge(health ? overall : "unavailable", statusKind(overall))}</div>
|
|
52
|
+
<div class="strip-item"><span class="num big ${count("fail") > 0 ? "text-fail" : ""}">${count("fail")}</span><span class="muted">failing</span></div>
|
|
53
|
+
<div class="strip-item"><span class="num big ${count("warn") > 0 ? "text-warn" : ""}">${count("warn")}</span><span class="muted">warning</span></div>
|
|
54
|
+
<div class="strip-item"><span class="num big">${count("unknown")}</span><span class="muted">unknown</span></div>
|
|
55
|
+
<div class="strip-item"><span class="num big">${count("pass")}</span><span class="muted">passing</span></div>
|
|
56
|
+
<div class="strip-item strip-right"><span class="mono">akm ${meta ? meta.version : "?"}</span>${health ? html`<span class="muted small">window since ${when(health.since)}</span>` : ""}</div>
|
|
57
|
+
</div>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function tile(label, value, sub = "") {
|
|
61
|
+
return html`<div class="tile"><div class="tile-label">${label}</div><div class="tile-value num">${value}</div>${sub ? html`<div class="tile-sub muted">${sub}</div>` : ""}</div>`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function metricsSection(m) {
|
|
65
|
+
if (!m) return "";
|
|
66
|
+
const u = m.usage;
|
|
67
|
+
const day = (iso) => String(iso).slice(0, 10);
|
|
68
|
+
return html`<section><h2>Metrics</h2><p class="muted">${day(m.window.since)} to ${day(m.window.until)}</p>
|
|
69
|
+
<div class="tiles">
|
|
70
|
+
${tile("Searches", fmtNum(u.totals.searches), `${fmtNum(u.totals.zeroResultSearches)} with no results`)}
|
|
71
|
+
${tile("Shows", fmtNum(u.totals.shows), `${fmtNum(u.totals.distinctAssets)} distinct assets`)}
|
|
72
|
+
${tile("Curates", fmtNum(u.totals.curates))}
|
|
73
|
+
${tile("Select rate", fmtPct(u.selectRate), `${fmtNum(u.totals.selects)} selects`)}
|
|
74
|
+
${tile("Median search", fmtMs(u.searchMedianMs))}
|
|
75
|
+
${tile("Feedback", `+${fmtNum(m.feedback.totals.positive)} / -${fmtNum(m.feedback.totals.negative)}`, "positive / negative")}
|
|
76
|
+
${tile("LLM calls", fmtNum(m.llm.calls), `${fmtNum(m.llm.failures)} failed, ${fmtNum(m.llm.totalTokens)} tokens`)}
|
|
77
|
+
${tile("Pending proposals", fmtNum(m.proposals.byStatus.pending ?? 0))}
|
|
78
|
+
${tile("Task runs", fmtNum(m.tasks.runs), `${fmtNum(m.tasks.failed)} failed`)}
|
|
79
|
+
${tile("Index builds", fmtNum(m.index.runs), `median ${fmtMs(m.index.medianMs)}`)}
|
|
80
|
+
</div>
|
|
81
|
+
${m.proposals.acceptRateBySource.length > 0 ? html`<h3>Proposal outcomes by source</h3>${tableWrap(html`<table><thead><tr><th>Source</th><th class="r">Total</th><th class="r">Accepted</th><th class="r">Rejected</th><th class="r">Pending</th><th class="r">Accept rate</th></tr></thead><tbody>${m.proposals.acceptRateBySource.map((r) => html`<tr><td class="mono">${r.source}</td><td class="r num">${fmtNum(r.total)}</td><td class="r num">${fmtNum(r.accepted)}</td><td class="r num">${fmtNum(r.rejected)}</td><td class="r num">${fmtNum(r.pending)}</td><td class="r num">${fmtPct(r.acceptRate)}</td></tr>`)}</tbody></table>`)}` : ""}
|
|
82
|
+
</section>`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncText(sync) {
|
|
86
|
+
if (!sync) return "-";
|
|
87
|
+
if (sync.skipped) return sync.reason ? `skipped (${sync.reason})` : "skipped";
|
|
88
|
+
return [sync.committed ? "committed" : "no changes", sync.pushed ? "pushed" : ""].filter(Boolean).join(", ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function triageText(t) {
|
|
92
|
+
if (!t) return "-";
|
|
93
|
+
const parts = [
|
|
94
|
+
[t.promoted, "promoted"],
|
|
95
|
+
[t.rejected, "rejected"],
|
|
96
|
+
[t.deferred, "deferred"],
|
|
97
|
+
[t.failed, "failed"],
|
|
98
|
+
[t.skippedByCap, "capped"],
|
|
99
|
+
]
|
|
100
|
+
.filter(([n]) => n > 0)
|
|
101
|
+
.map(([n, label]) => `${n} ${label}`);
|
|
102
|
+
return parts.length > 0 ? parts.join(", ") : "none";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runsSection(runs) {
|
|
106
|
+
if (runs.length === 0) {
|
|
107
|
+
return html`<section><h2>Recent improve runs</h2>${emptyState("No improve runs yet.", "Runs appear here after akm improve completes.")}</section>`;
|
|
108
|
+
}
|
|
109
|
+
const maxDuration = Math.max(0, ...runs.map((r) => r.durationMs ?? 0));
|
|
110
|
+
const maxCalls = Math.max(0, ...runs.map((r) => r.modelCalls?.total ?? 0));
|
|
111
|
+
return html`<section><h2>Recent improve runs</h2><p class="muted">Last ${runs.length}, newest first. Bars share one scale per column.</p>
|
|
112
|
+
${tableWrap(
|
|
113
|
+
html`<table class="runs"><thead><tr><th>Started</th><th>Result</th><th colspan="2">Duration</th><th colspan="2">Model calls</th><th>Sync</th><th>Triage</th></tr></thead><tbody>${runs.map(
|
|
114
|
+
(r) => {
|
|
115
|
+
const calls = r.modelCalls?.total ?? 0;
|
|
116
|
+
const failed = r.modelCalls?.failures ?? 0;
|
|
117
|
+
return html`<tr>
|
|
118
|
+
<td>${when(r.startedAt)}<div class="muted small mono">${r.id}</div></td>
|
|
119
|
+
<td>${r.ok ? badge("ok", "ok") : badge("failed", "fail")}</td>
|
|
120
|
+
<td class="r num">${fmtMs(r.durationMs)}</td><td>${miniBar(r.durationMs ?? 0, maxDuration, 0, `duration ${fmtMs(r.durationMs)}`)}</td>
|
|
121
|
+
<td class="r num">${calls}${failed > 0 ? html` <span class="text-fail">(${failed} failed)</span>` : ""}</td><td>${miniBar(calls, maxCalls, failed, `${calls} model calls, ${failed} failed`)}</td>
|
|
122
|
+
<td>${syncText(r.sync)}</td>
|
|
123
|
+
<td>${triageText(r.triage)}</td>
|
|
124
|
+
</tr>`;
|
|
125
|
+
},
|
|
126
|
+
)}</tbody></table>`,
|
|
127
|
+
)}</section>`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function usageSection(days) {
|
|
131
|
+
const any = days.some((d) => d.search + d.curate + d.show + d.feedbackPositive + d.feedbackNegative > 0);
|
|
132
|
+
return html`<section><h2>Usage, last 14 days</h2>${any ? usageChart(days) : emptyState("No usage events in the last 14 days.", "Searches, shows, curates and feedback you make with akm are counted here.")}</section>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function render() {
|
|
136
|
+
const [data, meta] = await Promise.all([api("/api/overview"), getMeta().catch(() => null)]);
|
|
137
|
+
const errors = data.errors ? Object.entries(data.errors) : [];
|
|
138
|
+
return html`${pageHeader("Overview", "Health, usage and recent improve runs.")}
|
|
139
|
+
${statusStrip(data.health, meta)}
|
|
140
|
+
${errors.length > 0 ? html`<div class="notice notice-warn" role="alert"><strong>Some sections could not load.</strong><ul>${errors.map(([name, message]) => html`<li><span class="mono">${name}</span>: ${message}</li>`)}</ul></div>` : ""}
|
|
141
|
+
${data.health ? healthSection(data.health) : ""}
|
|
142
|
+
${metricsSection(data.metrics)}
|
|
143
|
+
${runsSection(data.improveRuns ?? [])}
|
|
144
|
+
${usageSection(data.usageByDay ?? [])}`;
|
|
145
|
+
}
|