@crewhaus/hangar-ui 0.5.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/README.md +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers shared by every Hangar view. This module is deliberately
|
|
3
|
+
* DOM-free and side-effect-free so the same file runs in the browser (as a
|
|
4
|
+
* plain ES module) and under `bun test` (imported directly by the package's
|
|
5
|
+
* unit tests). Anything time-dependent takes an explicit `nowMs`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const MINUTE_MS = 60_000;
|
|
9
|
+
const HOUR_MS = 3_600_000;
|
|
10
|
+
const DAY_MS = 86_400_000;
|
|
11
|
+
|
|
12
|
+
/** Window for the "Recently active" smart group. */
|
|
13
|
+
export const RECENT_ACTIVITY_MS = 48 * HOUR_MS;
|
|
14
|
+
|
|
15
|
+
/** Default transcript retention used for the TTL countdown column. */
|
|
16
|
+
export const DEFAULT_SESSION_TTL_DAYS = 30;
|
|
17
|
+
|
|
18
|
+
/** Parse an ISO timestamp to epoch ms, or null when absent/invalid. */
|
|
19
|
+
export function parseTs(iso) {
|
|
20
|
+
if (typeof iso !== "string" || iso === "") return null;
|
|
21
|
+
const ms = Date.parse(iso);
|
|
22
|
+
return Number.isNaN(ms) ? null : ms;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** "just now" / "5m ago" / "3h ago" / "6d ago" / "in 2h"; "—" when unknown. */
|
|
26
|
+
export function fmtRelativeTime(iso, nowMs) {
|
|
27
|
+
const ts = typeof iso === "number" ? iso : parseTs(iso);
|
|
28
|
+
if (ts === null) return "—";
|
|
29
|
+
const diff = nowMs - ts;
|
|
30
|
+
const abs = Math.abs(diff);
|
|
31
|
+
let label;
|
|
32
|
+
if (abs < 45_000) return "just now";
|
|
33
|
+
if (abs < HOUR_MS) label = `${Math.round(abs / MINUTE_MS)}m`;
|
|
34
|
+
else if (abs < DAY_MS) label = `${Math.round(abs / HOUR_MS)}h`;
|
|
35
|
+
else label = `${Math.round(abs / DAY_MS)}d`;
|
|
36
|
+
return diff >= 0 ? `${label} ago` : `in ${label}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* USD micros → dollars. The server's money unit is integer USD micros
|
|
41
|
+
* everywhere (rollups, cost folds, gutter events); this is the ONE place
|
|
42
|
+
* the client converts. Returns null for anything non-numeric so the result
|
|
43
|
+
* feeds `fmtUsd` (which renders null as "—").
|
|
44
|
+
*/
|
|
45
|
+
export function usdFromMicros(micros) {
|
|
46
|
+
if (typeof micros !== "number" || !Number.isFinite(micros)) return null;
|
|
47
|
+
return micros / 1_000_000;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Format a dollar figure; "—" for null/undefined/NaN. */
|
|
51
|
+
export function fmtUsd(value) {
|
|
52
|
+
if (typeof value !== "number" || Number.isNaN(value)) return "—";
|
|
53
|
+
if (value !== 0 && Math.abs(value) < 0.01) return `$${value.toFixed(4)}`;
|
|
54
|
+
if (Math.abs(value) >= 1000) return `$${Math.round(value).toLocaleString("en-US")}`;
|
|
55
|
+
return `$${value.toFixed(2)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Format a 0..1 rate as a percentage; "—" when unknown. */
|
|
59
|
+
export function fmtPct(rate) {
|
|
60
|
+
if (typeof rate !== "number" || Number.isNaN(rate)) return "—";
|
|
61
|
+
// one decimal max, and 0.55*100's float dust must still read "55%"
|
|
62
|
+
const pct = Math.round(rate * 1000) / 10;
|
|
63
|
+
return `${Number.isInteger(pct) ? pct : pct.toFixed(1)}%`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Format an integer count; "—" when unknown. */
|
|
67
|
+
export function fmtCount(n) {
|
|
68
|
+
return typeof n === "number" && Number.isFinite(n) ? String(n) : "—";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Last `segments` path components of a directory, for row subtitles. */
|
|
72
|
+
export function dirTail(dir, segments = 2) {
|
|
73
|
+
if (typeof dir !== "string" || dir === "") return "";
|
|
74
|
+
const parts = dir.split(/[\\/]+/).filter((p) => p !== "");
|
|
75
|
+
return parts.slice(-segments).join("/");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Truncate a string to `max` chars with an ellipsis. */
|
|
79
|
+
export function clampText(s, max = 80) {
|
|
80
|
+
const str = String(s ?? "");
|
|
81
|
+
return str.length <= max ? str : `${str.slice(0, Math.max(0, max - 1))}…`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* TTL countdown for a session transcript: `updatedAt` + `ttlDays` versus
|
|
86
|
+
* now. Returns `{ expired, msLeft, label }` — label reads "expires in 12d",
|
|
87
|
+
* "expires in 4h", or "expired".
|
|
88
|
+
*/
|
|
89
|
+
export function ttlCountdown(updatedAtIso, nowMs, ttlDays = DEFAULT_SESSION_TTL_DAYS) {
|
|
90
|
+
const ts = parseTs(updatedAtIso);
|
|
91
|
+
if (ts === null) return { expired: false, msLeft: null, label: "—" };
|
|
92
|
+
const msLeft = ts + ttlDays * DAY_MS - nowMs;
|
|
93
|
+
if (msLeft <= 0) return { expired: true, msLeft, label: "expired" };
|
|
94
|
+
const label =
|
|
95
|
+
msLeft >= DAY_MS
|
|
96
|
+
? `expires in ${Math.ceil(msLeft / DAY_MS)}d`
|
|
97
|
+
: msLeft >= HOUR_MS
|
|
98
|
+
? `expires in ${Math.ceil(msLeft / HOUR_MS)}h`
|
|
99
|
+
: "expires in <1h";
|
|
100
|
+
return { expired: false, msLeft, label };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Tolerant comparator: null/undefined/"" sort last, numbers numerically,
|
|
105
|
+
* everything else case-insensitively as strings.
|
|
106
|
+
*/
|
|
107
|
+
export function compareValues(a, b) {
|
|
108
|
+
const aEmpty = a === null || a === undefined || a === "";
|
|
109
|
+
const bEmpty = b === null || b === undefined || b === "";
|
|
110
|
+
if (aEmpty && bEmpty) return 0;
|
|
111
|
+
if (aEmpty) return 1;
|
|
112
|
+
if (bEmpty) return -1;
|
|
113
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
114
|
+
const as = String(a).toLowerCase();
|
|
115
|
+
const bs = String(b).toLowerCase();
|
|
116
|
+
return as < bs ? -1 : as > bs ? 1 : 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stable sort of `rows` by `getter(row)` in direction `dir` ("asc"|"desc").
|
|
121
|
+
* Pinned rows always sort first regardless of key (registry pin semantics).
|
|
122
|
+
*/
|
|
123
|
+
export function sortRows(rows, getter, dir = "asc") {
|
|
124
|
+
const sign = dir === "desc" ? -1 : 1;
|
|
125
|
+
return rows
|
|
126
|
+
.map((row, i) => ({ row, i }))
|
|
127
|
+
.sort((x, y) => {
|
|
128
|
+
const pin = (y.row.pinned === true ? 1 : 0) - (x.row.pinned === true ? 1 : 0);
|
|
129
|
+
if (pin !== 0) return pin;
|
|
130
|
+
const c = compareValues(getter(x.row), getter(y.row));
|
|
131
|
+
return c !== 0 ? sign * c : x.i - y.i;
|
|
132
|
+
})
|
|
133
|
+
.map((x) => x.row);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Normalize one `/api/harnesses` feed into flat row objects the Library can
|
|
138
|
+
* render. The server's row shape is registry fields + flattened
|
|
139
|
+
* `capabilities`/`evalHealthy`/`cachedAt` + a NESTED `rollup`
|
|
140
|
+
* (`sessionCount`, `spend7d` in USD micros, `lastEval`, `cachedAt`) that is
|
|
141
|
+
* null until hydrated. Tolerant by design: the feed may be a bare array or
|
|
142
|
+
* `{ harnesses: [...] }`, and every field falls back rather than throwing.
|
|
143
|
+
*/
|
|
144
|
+
export function normalizeRows(feed) {
|
|
145
|
+
const list = Array.isArray(feed)
|
|
146
|
+
? feed
|
|
147
|
+
: feed && Array.isArray(feed.harnesses)
|
|
148
|
+
? feed.harnesses
|
|
149
|
+
: [];
|
|
150
|
+
return list.map((r) => {
|
|
151
|
+
const src = r && typeof r === "object" ? r : {};
|
|
152
|
+
const rollup = src.rollup && typeof src.rollup === "object" ? src.rollup : null;
|
|
153
|
+
const caps = Array.isArray(src.capabilities) ? src.capabilities.map(String) : [];
|
|
154
|
+
// Prefer the flat legacy fields when present (tolerance), else the
|
|
155
|
+
// nested rollup; join the row-level evalHealthy flag onto lastEval so
|
|
156
|
+
// `evalHealth` can color the dot.
|
|
157
|
+
const rawEval =
|
|
158
|
+
src.lastEval && typeof src.lastEval === "object"
|
|
159
|
+
? src.lastEval
|
|
160
|
+
: rollup?.lastEval && typeof rollup.lastEval === "object"
|
|
161
|
+
? rollup.lastEval
|
|
162
|
+
: null;
|
|
163
|
+
const lastEval =
|
|
164
|
+
rawEval !== null &&
|
|
165
|
+
typeof rawEval.healthy !== "boolean" &&
|
|
166
|
+
typeof src.evalHealthy === "boolean"
|
|
167
|
+
? { ...rawEval, healthy: src.evalHealthy }
|
|
168
|
+
: rawEval;
|
|
169
|
+
return {
|
|
170
|
+
id: typeof src.id === "string" ? src.id : "",
|
|
171
|
+
dir: typeof src.dir === "string" ? src.dir : "",
|
|
172
|
+
specName:
|
|
173
|
+
typeof src.specName === "string" && src.specName !== ""
|
|
174
|
+
? src.specName
|
|
175
|
+
: typeof src.name === "string"
|
|
176
|
+
? src.name
|
|
177
|
+
: "",
|
|
178
|
+
target: typeof src.target === "string" ? src.target : "",
|
|
179
|
+
model: typeof src.model === "string" ? src.model : null,
|
|
180
|
+
groups: Array.isArray(src.groups) ? src.groups.map(String) : [],
|
|
181
|
+
tags: Array.isArray(src.tags) ? src.tags.map(String) : [],
|
|
182
|
+
pinned: src.pinned === true,
|
|
183
|
+
notes: typeof src.notes === "string" ? src.notes : "",
|
|
184
|
+
missingSince: typeof src.missingSince === "string" ? src.missingSince : null,
|
|
185
|
+
lastSeen: typeof src.lastSeen === "string" ? src.lastSeen : null,
|
|
186
|
+
lastActivityAt:
|
|
187
|
+
typeof src.lastActivityAt === "string"
|
|
188
|
+
? src.lastActivityAt
|
|
189
|
+
: typeof src.lastSeen === "string"
|
|
190
|
+
? src.lastSeen
|
|
191
|
+
: null,
|
|
192
|
+
sessions:
|
|
193
|
+
typeof src.sessions === "number"
|
|
194
|
+
? src.sessions
|
|
195
|
+
: typeof src.sessionCount === "number"
|
|
196
|
+
? src.sessionCount
|
|
197
|
+
: rollup && typeof rollup.sessionCount === "number"
|
|
198
|
+
? rollup.sessionCount
|
|
199
|
+
: null,
|
|
200
|
+
spend7dUsd:
|
|
201
|
+
typeof src.spend7dUsd === "number"
|
|
202
|
+
? src.spend7dUsd
|
|
203
|
+
: rollup
|
|
204
|
+
? usdFromMicros(rollup.spend7d)
|
|
205
|
+
: null,
|
|
206
|
+
lastEval,
|
|
207
|
+
// M2 fleet-feed additions: the supervision state (a cheap runfile
|
|
208
|
+
// read, null when the dir is missing) and the parked-approval count —
|
|
209
|
+
// neither opens a transcript, so the feed stays a fold.
|
|
210
|
+
supervision: typeof src.supervision === "string" ? src.supervision : null,
|
|
211
|
+
pendingApprovals: typeof src.pendingApprovals === "number" ? src.pendingApprovals : 0,
|
|
212
|
+
capabilities: caps,
|
|
213
|
+
budgeted: typeof src.budgeted === "boolean" ? src.budgeted : caps.includes("budget"),
|
|
214
|
+
cachedAt:
|
|
215
|
+
typeof src.cachedAt === "string"
|
|
216
|
+
? src.cachedAt
|
|
217
|
+
: rollup && typeof rollup.cachedAt === "string"
|
|
218
|
+
? rollup.cachedAt
|
|
219
|
+
: typeof src.rollupStaleCachedAt === "string"
|
|
220
|
+
? src.rollupStaleCachedAt
|
|
221
|
+
: null,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The oldest non-null `cachedAt` across rows — the honest fleet-level as-of
|
|
228
|
+
* time (a rollup line must never claim to be fresher than its stalest row).
|
|
229
|
+
* Null when no row carries one.
|
|
230
|
+
*/
|
|
231
|
+
export function oldestCachedAt(rows) {
|
|
232
|
+
let oldest = null;
|
|
233
|
+
let oldestMs = Number.POSITIVE_INFINITY;
|
|
234
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
235
|
+
const ms = parseTs(row && typeof row === "object" ? row.cachedAt : null);
|
|
236
|
+
if (ms !== null && ms < oldestMs) {
|
|
237
|
+
oldestMs = ms;
|
|
238
|
+
oldest = row.cachedAt;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return oldest;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Eval-health derivation for the red/green dot. The dot is ALWAYS paired
|
|
246
|
+
* with text (color-blind-safe invariant), so this returns both.
|
|
247
|
+
* state: "pass" | "fail" | "unknown" | "none"
|
|
248
|
+
*/
|
|
249
|
+
export function evalHealth(lastEval) {
|
|
250
|
+
if (!lastEval || typeof lastEval !== "object") return { state: "none", label: "no evals" };
|
|
251
|
+
const rate = typeof lastEval.passRate === "number" ? lastEval.passRate : null;
|
|
252
|
+
const pct = rate !== null ? fmtPct(rate) : "—";
|
|
253
|
+
if (lastEval.healthy === true) return { state: "pass", label: pct };
|
|
254
|
+
if (lastEval.healthy === false) return { state: "fail", label: pct };
|
|
255
|
+
return { state: "unknown", label: pct };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** True when a Library row warrants a "needs attention" flag in M1 terms. */
|
|
259
|
+
export function needsAttention(row) {
|
|
260
|
+
if (row.missingSince !== null && row.missingSince !== undefined) return true;
|
|
261
|
+
return evalHealth(row.lastEval).state === "fail";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The one-sentence fleet rollup, e.g. "12 harnesses · 2 need attention". */
|
|
265
|
+
export function rollupLine(rows) {
|
|
266
|
+
const n = rows.length;
|
|
267
|
+
const parts = [`${n} ${n === 1 ? "harness" : "harnesses"}`];
|
|
268
|
+
const attention = rows.filter((r) => needsAttention(r)).length;
|
|
269
|
+
if (attention > 0) parts.push(`${attention} need${attention === 1 ? "s" : ""} attention`);
|
|
270
|
+
return parts.join(" · ");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The computed (never stored) smart groups, in display order. */
|
|
274
|
+
export const SMART_GROUPS = [
|
|
275
|
+
{ id: "failing-evals", label: "Failing evals" },
|
|
276
|
+
{ id: "unbudgeted", label: "Unbudgeted" },
|
|
277
|
+
{ id: "has-thredz", label: "Has Thredz" },
|
|
278
|
+
{ id: "recently-active", label: "Recently active" },
|
|
279
|
+
{ id: "ungrouped", label: "Ungrouped" },
|
|
280
|
+
{ id: "missing", label: "Missing" },
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
/** Membership test for one smart group against one normalized row. */
|
|
284
|
+
export function smartGroupMatch(id, row, nowMs) {
|
|
285
|
+
switch (id) {
|
|
286
|
+
case "failing-evals":
|
|
287
|
+
return evalHealth(row.lastEval).state === "fail";
|
|
288
|
+
case "unbudgeted":
|
|
289
|
+
return row.budgeted !== true;
|
|
290
|
+
case "has-thredz":
|
|
291
|
+
return row.capabilities.includes("thredz");
|
|
292
|
+
case "recently-active": {
|
|
293
|
+
const ts = parseTs(row.lastActivityAt);
|
|
294
|
+
return ts !== null && nowMs - ts <= RECENT_ACTIVITY_MS && nowMs - ts >= 0;
|
|
295
|
+
}
|
|
296
|
+
case "ungrouped":
|
|
297
|
+
return row.groups.length === 0;
|
|
298
|
+
case "missing":
|
|
299
|
+
return row.missingSince !== null && row.missingSince !== undefined;
|
|
300
|
+
default:
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** All smart groups with their member rows (client-side, from row data). */
|
|
306
|
+
export function deriveSmartGroups(rows, nowMs) {
|
|
307
|
+
return SMART_GROUPS.map((g) => ({
|
|
308
|
+
id: g.id,
|
|
309
|
+
label: g.label,
|
|
310
|
+
rows: rows.filter((r) => smartGroupMatch(g.id, r, nowMs)),
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Map the server's `HarnessHealth` detail field (registered / pinnedEnvs /
|
|
316
|
+
* evalHealthy / evalNote / openIncidents / hasAudit) into the Overview
|
|
317
|
+
* health checklist: `[{ state, label, reason }]` with dot states matching
|
|
318
|
+
* `dot()` ("ok" | "warn" | "bad" | "off"). Tolerant — absent fields are
|
|
319
|
+
* skipped, junk input yields [] (the card renders its empty state).
|
|
320
|
+
*/
|
|
321
|
+
export function healthChecks(health) {
|
|
322
|
+
if (!health || typeof health !== "object") return [];
|
|
323
|
+
const checks = [];
|
|
324
|
+
if (typeof health.registered === "boolean") {
|
|
325
|
+
const pins = Array.isArray(health.pinnedEnvs) ? health.pinnedEnvs.map(String) : [];
|
|
326
|
+
checks.push({
|
|
327
|
+
// Unregistered WITH env pins = a pin pointing at nothing — bad.
|
|
328
|
+
state: health.registered ? "ok" : pins.length > 0 ? "bad" : "warn",
|
|
329
|
+
label: health.registered ? "spec registered" : "spec not registered",
|
|
330
|
+
reason: pins.length > 0 ? `pinned envs: ${pins.join(", ")}` : "",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (typeof health.evalHealthy === "boolean") {
|
|
334
|
+
checks.push({
|
|
335
|
+
state: health.evalHealthy ? "ok" : "bad",
|
|
336
|
+
label: health.evalHealthy ? "evals healthy" : "eval regression",
|
|
337
|
+
reason: typeof health.evalNote === "string" ? health.evalNote : "",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
if (typeof health.openIncidents === "number") {
|
|
341
|
+
const n = health.openIncidents;
|
|
342
|
+
checks.push({
|
|
343
|
+
state: n === 0 ? "ok" : "bad",
|
|
344
|
+
label: n === 0 ? "no open incidents" : `${n} open incident${n === 1 ? "" : "s"}`,
|
|
345
|
+
reason: "",
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
if (typeof health.hasAudit === "boolean") {
|
|
349
|
+
checks.push({
|
|
350
|
+
state: health.hasAudit ? "ok" : "off",
|
|
351
|
+
label: health.hasAudit ? "audit trail present" : "no audit trail",
|
|
352
|
+
reason: "",
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
return checks;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Interleave a server transcript view (`{ turns, tools, gutter }`, each item
|
|
360
|
+
* carrying a 0-based `line` anchor) into one render-ordered list of
|
|
361
|
+
* `{ type: "turn"|"tool"|"gutter", ...item }`. Ordering: by line, then
|
|
362
|
+
* turn < tool < gutter at the same line (a tool_use is emitted at its
|
|
363
|
+
* assistant turn's line), then source order. Pure + tolerant: junk input
|
|
364
|
+
* yields [].
|
|
365
|
+
*/
|
|
366
|
+
export function interleaveTranscript(view) {
|
|
367
|
+
const src = view && typeof view === "object" ? view : {};
|
|
368
|
+
const staged = [];
|
|
369
|
+
const stage = (type, items, rank) => {
|
|
370
|
+
if (!Array.isArray(items)) return;
|
|
371
|
+
for (const item of items) {
|
|
372
|
+
if (!item || typeof item !== "object") continue;
|
|
373
|
+
const line = typeof item.line === "number" ? item.line : 0;
|
|
374
|
+
staged.push({ type, line, rank, seq: staged.length, item });
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
stage("turn", src.turns, 0);
|
|
378
|
+
stage("tool", src.tools, 1);
|
|
379
|
+
stage("gutter", src.gutter, 2);
|
|
380
|
+
staged.sort((a, b) => a.line - b.line || a.rank - b.rank || a.seq - b.seq);
|
|
381
|
+
return staged.map((s) => ({ type: s.type, ...s.item }));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* SVG path string for a mini trend line ("M x y L x y …"). Returns "" for
|
|
386
|
+
* fewer than two finite points. Pure string math — the caller puts it in a
|
|
387
|
+
* `<path d>` attribute, so nothing here can inject markup.
|
|
388
|
+
*/
|
|
389
|
+
export function sparklinePath(values, width = 120, height = 28, pad = 2) {
|
|
390
|
+
const pts = (Array.isArray(values) ? values : []).filter(
|
|
391
|
+
(v) => typeof v === "number" && Number.isFinite(v),
|
|
392
|
+
);
|
|
393
|
+
if (pts.length < 2) return "";
|
|
394
|
+
const min = Math.min(...pts);
|
|
395
|
+
const max = Math.max(...pts);
|
|
396
|
+
const range = max - min;
|
|
397
|
+
const innerW = width - 2 * pad;
|
|
398
|
+
const innerH = height - 2 * pad;
|
|
399
|
+
const step = innerW / (pts.length - 1);
|
|
400
|
+
const coords = pts.map((v, i) => {
|
|
401
|
+
const x = pad + i * step;
|
|
402
|
+
const y = range === 0 ? height / 2 : pad + (1 - (v - min) / range) * innerH;
|
|
403
|
+
return `${Math.round(x * 100) / 100} ${Math.round(y * 100) / 100}`;
|
|
404
|
+
});
|
|
405
|
+
return `M${coords[0]} L${coords.slice(1).join(" L")}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Rectangles for a mini bar chart (7-day spend). Returns
|
|
410
|
+
* `[{ x, y, w, h }]` in SVG user units; zero values keep a 0.5px baseline
|
|
411
|
+
* sliver so "no spend" days are visibly present, not missing data.
|
|
412
|
+
*/
|
|
413
|
+
export function barRects(values, width = 140, height = 36, gap = 3) {
|
|
414
|
+
const vals = (Array.isArray(values) ? values : []).map((v) =>
|
|
415
|
+
typeof v === "number" && Number.isFinite(v) && v > 0 ? v : 0,
|
|
416
|
+
);
|
|
417
|
+
const n = vals.length;
|
|
418
|
+
if (n === 0) return [];
|
|
419
|
+
const barW = (width - gap * (n - 1)) / n;
|
|
420
|
+
const max = Math.max(...vals);
|
|
421
|
+
return vals.map((v, i) => {
|
|
422
|
+
const h = max > 0 && v > 0 ? Math.max(1, (v / max) * (height - 1)) : 0.5;
|
|
423
|
+
const round = (x) => Math.round(x * 100) / 100;
|
|
424
|
+
return { x: round(i * (barW + gap)), y: round(height - h), w: round(barW), h: round(h) };
|
|
425
|
+
});
|
|
426
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The activity digest — "what changed since yesterday", across the fleet,
|
|
3
|
+
* grouped by kind.
|
|
4
|
+
*
|
|
5
|
+
* Every item is a POINTER, never a payload copy (the server builds the
|
|
6
|
+
* digest from mtimes and small capped folds — no transcript is ever opened
|
|
7
|
+
* for it), so every row's job is to be a good link: each one deep-links into
|
|
8
|
+
* the screen that owns the thing that changed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { api } from "../api.js";
|
|
12
|
+
import { clear, dot, el, emptyState, skeleton } from "../dom.js";
|
|
13
|
+
import { hrefHarness } from "../router.js";
|
|
14
|
+
import { activityGroups, activityHref } from "../supervision.js";
|
|
15
|
+
import { fmtRelativeTime } from "../util.js";
|
|
16
|
+
|
|
17
|
+
/** The windows the digest offers; `since` is the server's own grammar. */
|
|
18
|
+
const WINDOWS = [
|
|
19
|
+
{ since: "1d", label: "24 hours" },
|
|
20
|
+
{ since: "7d", label: "7 days" },
|
|
21
|
+
{ since: "30d", label: "30 days" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export async function renderActivity(root) {
|
|
25
|
+
clear(root).appendChild(skeleton(6));
|
|
26
|
+
await draw(root, "1d", null);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function draw(host, since, enabled) {
|
|
30
|
+
let payload;
|
|
31
|
+
try {
|
|
32
|
+
payload = await api.activity(since);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
clear(host).appendChild(
|
|
35
|
+
el("div", { class: "card error-card" }, [
|
|
36
|
+
el("p", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
clear(host);
|
|
42
|
+
const nowMs = Date.now();
|
|
43
|
+
const items = payload && Array.isArray(payload.items) ? payload.items : [];
|
|
44
|
+
const groups = activityGroups(items, enabled);
|
|
45
|
+
|
|
46
|
+
host.appendChild(
|
|
47
|
+
el(
|
|
48
|
+
"div",
|
|
49
|
+
{ class: "filter-bar", role: "group", "aria-label": "digest window" },
|
|
50
|
+
WINDOWS.map((w) =>
|
|
51
|
+
el("button", {
|
|
52
|
+
class: `btn btn-ghost${w.since === since ? " active" : ""}`,
|
|
53
|
+
type: "button",
|
|
54
|
+
text: w.label,
|
|
55
|
+
onClick: () => draw(host, w.since, enabled),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
host.appendChild(
|
|
61
|
+
el("div", { class: "rollup" }, [
|
|
62
|
+
el("span", {
|
|
63
|
+
text: `${items.length} change${items.length === 1 ? "" : "s"} since ${
|
|
64
|
+
payload && typeof payload.since === "string"
|
|
65
|
+
? fmtRelativeTime(payload.since, nowMs)
|
|
66
|
+
: since
|
|
67
|
+
}`,
|
|
68
|
+
}),
|
|
69
|
+
payload && payload.truncated === true
|
|
70
|
+
? el("span", { class: "chip chip-warn", text: "capped — older items not shown" })
|
|
71
|
+
: null,
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (items.length === 0) {
|
|
76
|
+
host.appendChild(
|
|
77
|
+
emptyState("Nothing has changed in this window", "crewhaus run (or start a daemon)"),
|
|
78
|
+
);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Per-group filters: a switched-off kind still shows its count, so the
|
|
83
|
+
// digest never hides how much it is hiding.
|
|
84
|
+
host.appendChild(
|
|
85
|
+
el(
|
|
86
|
+
"div",
|
|
87
|
+
{ class: "filter-bar", role: "group", "aria-label": "activity kinds" },
|
|
88
|
+
groups.map((g) =>
|
|
89
|
+
el("button", {
|
|
90
|
+
class: `btn btn-ghost${g.shown ? " active" : ""}`,
|
|
91
|
+
type: "button",
|
|
92
|
+
text: `${g.label} ${g.count}`,
|
|
93
|
+
onClick: () => {
|
|
94
|
+
const next = new Set(
|
|
95
|
+
enabled === null ? groups.filter((x) => x.shown).map((x) => x.kind) : enabled,
|
|
96
|
+
);
|
|
97
|
+
if (next.has(g.kind)) next.delete(g.kind);
|
|
98
|
+
else next.add(g.kind);
|
|
99
|
+
draw(host, since, next);
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const visible = groups.filter((g) => g.shown);
|
|
107
|
+
if (visible.length === 0) {
|
|
108
|
+
host.appendChild(emptyState("Every kind is filtered out — re-enable one above"));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
for (const group of visible) {
|
|
112
|
+
host.appendChild(
|
|
113
|
+
el("section", { class: "card" }, [
|
|
114
|
+
el("h3", { class: "card-title" }, [
|
|
115
|
+
el("span", { text: group.label }),
|
|
116
|
+
el("span", { class: "muted card-sub", text: `${group.count}` }),
|
|
117
|
+
]),
|
|
118
|
+
el(
|
|
119
|
+
"ul",
|
|
120
|
+
{ class: "check-list" },
|
|
121
|
+
group.items.map((item) => {
|
|
122
|
+
const href = activityHref(item);
|
|
123
|
+
const label = typeof item.label === "string" ? item.label : group.label;
|
|
124
|
+
return el("li", { class: "activity-row" }, [
|
|
125
|
+
dot("off", fmtRelativeTime(item.at ?? null, nowMs)),
|
|
126
|
+
href !== null
|
|
127
|
+
? el("a", { class: "name-link", href, text: label })
|
|
128
|
+
: el("span", { text: label }),
|
|
129
|
+
el("a", {
|
|
130
|
+
class: "muted sub",
|
|
131
|
+
href: hrefHarness(String(item.harnessId ?? "")),
|
|
132
|
+
text: String(item.specName ?? item.harnessId ?? ""),
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
]),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|