@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,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared M2 action affordances: the CLI twin display, buttons that are
|
|
3
|
+
* disabled WITH THEIR REASON, the control.v1 wake/drain control, and the
|
|
4
|
+
* preflight-refusal modal.
|
|
5
|
+
*
|
|
6
|
+
* Two invariants live here rather than in each screen:
|
|
7
|
+
*
|
|
8
|
+
* - **Every action shows the command it runs.** The CLI and the console
|
|
9
|
+
* drive the same harness-local state tree, so a twin is not decoration:
|
|
10
|
+
* it is how an operator verifies what a click will do, and how they take
|
|
11
|
+
* the same action from a terminal when the console is not running.
|
|
12
|
+
* - **A refusal is rendered, never toasted away.** A control.v1 envelope
|
|
13
|
+
* with `expected: true` (no control port, lane not armed, draining) is a
|
|
14
|
+
* FACT about this bundle: the control renders disabled with the server's
|
|
15
|
+
* sentence. Only `tick_in_flight` retries.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { copyBtn, dot, el, toast } from "../dom.js";
|
|
19
|
+
import { NO_TWIN_NOTES, cliTwinFor, controlOutcome } from "../supervision.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The CLI twin line: the exact command, monospaced, with a copy button.
|
|
23
|
+
* `note` renders instead when there is no CLI verb yet — an honest absence
|
|
24
|
+
* beats an invented command.
|
|
25
|
+
*/
|
|
26
|
+
export function cliTwin(command, note = null) {
|
|
27
|
+
if (typeof command !== "string" || command === "") {
|
|
28
|
+
return note === null
|
|
29
|
+
? null
|
|
30
|
+
: el("div", { class: "twin twin-none" }, [
|
|
31
|
+
el("span", { class: "twin-label", text: "CLI twin" }),
|
|
32
|
+
el("span", { class: "muted", text: note }),
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
return el("div", { class: "twin" }, [
|
|
36
|
+
el("span", { class: "twin-label", text: "CLI twin" }),
|
|
37
|
+
el("code", { class: "twin-cmd", text: command }),
|
|
38
|
+
copyBtn(command, "copy"),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The twin for a named action (see `cliTwinFor`), or its honest absence. */
|
|
43
|
+
export function actionTwin(action, args) {
|
|
44
|
+
return cliTwin(cliTwinFor(action, args), NO_TWIN_NOTES[action] ?? null);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A button that either works or says why it does not. `gate` is
|
|
49
|
+
* `{ enabled, reason }` — the shape `procActions` and `pokeReason` both
|
|
50
|
+
* produce — and a disabled button keeps its reason in the title AND in a
|
|
51
|
+
* sibling line, because a tooltip is invisible to a keyboard user.
|
|
52
|
+
*/
|
|
53
|
+
export function gatedBtn(label, gate, onClick, cls = "btn") {
|
|
54
|
+
const g = gate && typeof gate === "object" ? gate : { enabled: true, reason: null };
|
|
55
|
+
const btn = el("button", {
|
|
56
|
+
class: cls,
|
|
57
|
+
type: "button",
|
|
58
|
+
text: label,
|
|
59
|
+
title: g.enabled ? label : (g.reason ?? "unavailable"),
|
|
60
|
+
});
|
|
61
|
+
if (!g.enabled) btn.disabled = true;
|
|
62
|
+
else btn.addEventListener("click", onClick);
|
|
63
|
+
return btn;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A gated button plus the sentence explaining a disabled state. */
|
|
67
|
+
export function gatedAction(label, gate, onClick, cls = "btn") {
|
|
68
|
+
const g = gate && typeof gate === "object" ? gate : { enabled: true, reason: null };
|
|
69
|
+
return el("div", { class: "gated" }, [
|
|
70
|
+
gatedBtn(label, g, onClick, cls),
|
|
71
|
+
g.enabled || !g.reason ? null : el("span", { class: "muted gated-why", text: g.reason }),
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run one write with a busy button; failures surface (never silently), and
|
|
77
|
+
* the caller's `onOk` reloads. Returns the promise so callers can await.
|
|
78
|
+
*/
|
|
79
|
+
export async function runAction(btn, label, fn, onOk) {
|
|
80
|
+
const original = btn.textContent;
|
|
81
|
+
btn.disabled = true;
|
|
82
|
+
btn.textContent = `${label}…`;
|
|
83
|
+
try {
|
|
84
|
+
const result = await fn();
|
|
85
|
+
btn.textContent = original;
|
|
86
|
+
btn.disabled = false;
|
|
87
|
+
if (onOk) onOk(result);
|
|
88
|
+
return result;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
btn.textContent = original;
|
|
91
|
+
btn.disabled = false;
|
|
92
|
+
toast(`${label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A control.v1 control (Wake / Drain). `gate` decides whether it is offered
|
|
99
|
+
* at all (a lane the spec never declared has nothing to poke); the ENVELOPE
|
|
100
|
+
* decides what happens after a click:
|
|
101
|
+
*
|
|
102
|
+
* - `expected` refusal ⇒ the button disables itself and shows the reason;
|
|
103
|
+
* - `tick_in_flight` ⇒ retryable: a spinner, then the same button back;
|
|
104
|
+
* - anything else ⇒ a real fault, surfaced as an error.
|
|
105
|
+
*/
|
|
106
|
+
export function controlBtn(label, gate, call, twinArgs, onSettled) {
|
|
107
|
+
const state = el("div", { class: "control-state" });
|
|
108
|
+
const btn = gatedBtn(label, gate, async () => {
|
|
109
|
+
btn.disabled = true;
|
|
110
|
+
const spinner = el("span", { class: "muted", text: "…" });
|
|
111
|
+
state.replaceChildren(spinner);
|
|
112
|
+
let envelope;
|
|
113
|
+
try {
|
|
114
|
+
envelope = await call();
|
|
115
|
+
} catch (err) {
|
|
116
|
+
state.replaceChildren(
|
|
117
|
+
el("span", { class: "muted", text: err instanceof Error ? err.message : String(err) }),
|
|
118
|
+
);
|
|
119
|
+
btn.disabled = false;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const outcome = controlOutcome(envelope);
|
|
123
|
+
if (outcome.ok) {
|
|
124
|
+
state.replaceChildren(dot("ok", "accepted"));
|
|
125
|
+
btn.disabled = false;
|
|
126
|
+
} else if (outcome.retryable) {
|
|
127
|
+
// A tick is already running — the same call succeeds shortly.
|
|
128
|
+
state.replaceChildren(dot("warn", `${outcome.reason} — retry in a moment`));
|
|
129
|
+
btn.disabled = false;
|
|
130
|
+
} else {
|
|
131
|
+
// `expected` ⇒ this control is disabled-with-reason from here on.
|
|
132
|
+
state.replaceChildren(dot(outcome.dot, outcome.reason));
|
|
133
|
+
btn.disabled = outcome.disabled;
|
|
134
|
+
if (!outcome.expected) toast(`${label} failed: ${outcome.reason}`);
|
|
135
|
+
}
|
|
136
|
+
if (onSettled) onSettled(outcome);
|
|
137
|
+
});
|
|
138
|
+
const wrap = el("div", { class: "control-action" }, [
|
|
139
|
+
el("div", { class: "control-row" }, [btn, state]),
|
|
140
|
+
gate && gate.enabled === false && gate.reason
|
|
141
|
+
? el("span", { class: "muted gated-why", text: gate.reason })
|
|
142
|
+
: null,
|
|
143
|
+
twinArgs ? actionTwin(twinArgs.action, twinArgs) : null,
|
|
144
|
+
]);
|
|
145
|
+
return wrap;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The preflight-refusal modal. Every refused item lists its remediation, and
|
|
150
|
+
* "Start anyway" appears ONLY when every item is acknowledgeable — a missing
|
|
151
|
+
* channel secret is unforceable because the compiled daemon's own boot gate
|
|
152
|
+
* exits 2 on exactly that set, so forcing it would spawn a process
|
|
153
|
+
* guaranteed to die.
|
|
154
|
+
*/
|
|
155
|
+
export function refusalModal(model, handlers = {}) {
|
|
156
|
+
const backdrop = el("div", { class: "modal-backdrop", role: "presentation" });
|
|
157
|
+
const close = () => backdrop.remove();
|
|
158
|
+
const body = el("div", { class: "modal-body" });
|
|
159
|
+
|
|
160
|
+
if (model.items.length > 0) {
|
|
161
|
+
body.appendChild(
|
|
162
|
+
el(
|
|
163
|
+
"ul",
|
|
164
|
+
{ class: "refusal-list" },
|
|
165
|
+
model.items.map((item) =>
|
|
166
|
+
el("li", { class: "refusal-item" }, [
|
|
167
|
+
el("div", { class: "refusal-head" }, [
|
|
168
|
+
dot(item.acknowledgeable ? "warn" : "bad", item.level),
|
|
169
|
+
el("span", { class: "mono muted", text: item.area }),
|
|
170
|
+
el("span", { text: item.message }),
|
|
171
|
+
]),
|
|
172
|
+
item.remediation
|
|
173
|
+
? el("div", { class: "reason muted" }, [
|
|
174
|
+
el("strong", { text: "Fix: " }),
|
|
175
|
+
item.remediation,
|
|
176
|
+
])
|
|
177
|
+
: null,
|
|
178
|
+
item.acknowledgeable
|
|
179
|
+
? null
|
|
180
|
+
: el("div", { class: "reason" }, [
|
|
181
|
+
el("span", {
|
|
182
|
+
class: "chip chip-warn",
|
|
183
|
+
text: "unforceable — the daemon's own boot gate exits 2 on this",
|
|
184
|
+
}),
|
|
185
|
+
]),
|
|
186
|
+
]),
|
|
187
|
+
),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
} else {
|
|
191
|
+
body.appendChild(el("p", { text: model.message }));
|
|
192
|
+
}
|
|
193
|
+
if (model.forceHint) body.appendChild(el("p", { class: "muted", text: model.forceHint }));
|
|
194
|
+
|
|
195
|
+
const actions = el("div", { class: "modal-actions" });
|
|
196
|
+
if (model.canForce && handlers.onForce) {
|
|
197
|
+
const forceBtn = el("button", {
|
|
198
|
+
class: "btn btn-danger",
|
|
199
|
+
type: "button",
|
|
200
|
+
text: "Start anyway",
|
|
201
|
+
});
|
|
202
|
+
forceBtn.addEventListener("click", () => {
|
|
203
|
+
close();
|
|
204
|
+
handlers.onForce(model.acknowledge);
|
|
205
|
+
});
|
|
206
|
+
actions.appendChild(forceBtn);
|
|
207
|
+
}
|
|
208
|
+
if (model.action && model.action.jobKind !== null && handlers.onRemedy) {
|
|
209
|
+
// A remedy is a BUTTON, never a toast: "no compiled bundle" has an
|
|
210
|
+
// obvious next step and the console can run it.
|
|
211
|
+
const remedyBtn = el("button", {
|
|
212
|
+
class: "btn btn-primary",
|
|
213
|
+
type: "button",
|
|
214
|
+
text: model.action.label,
|
|
215
|
+
});
|
|
216
|
+
remedyBtn.addEventListener("click", () => {
|
|
217
|
+
close();
|
|
218
|
+
handlers.onRemedy(model.action.jobKind);
|
|
219
|
+
});
|
|
220
|
+
actions.appendChild(remedyBtn);
|
|
221
|
+
} else if (model.action) {
|
|
222
|
+
actions.appendChild(el("span", { class: "muted", text: model.action.hint }));
|
|
223
|
+
}
|
|
224
|
+
actions.appendChild(
|
|
225
|
+
el("button", { class: "btn btn-ghost", type: "button", text: "Close", onClick: close }),
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
backdrop.appendChild(
|
|
229
|
+
el("div", { class: "modal card", role: "dialog", "aria-modal": "true" }, [
|
|
230
|
+
el("h3", { class: "card-title" }, [
|
|
231
|
+
el("span", { text: model.title }),
|
|
232
|
+
model.canForce ? null : el("span", { class: "chip chip-warn", text: "not forceable" }),
|
|
233
|
+
]),
|
|
234
|
+
model.items.length > 0 ? el("p", { class: "muted", text: model.message }) : null,
|
|
235
|
+
body,
|
|
236
|
+
model.canForce && handlers.onForce
|
|
237
|
+
? cliTwin(
|
|
238
|
+
cliTwinFor("start", {
|
|
239
|
+
dir: handlers.dir,
|
|
240
|
+
force: true,
|
|
241
|
+
acknowledge: model.acknowledge,
|
|
242
|
+
}),
|
|
243
|
+
)
|
|
244
|
+
: null,
|
|
245
|
+
actions,
|
|
246
|
+
]),
|
|
247
|
+
);
|
|
248
|
+
backdrop.addEventListener("click", (e) => {
|
|
249
|
+
if (e.target === backdrop) close();
|
|
250
|
+
});
|
|
251
|
+
document.body.appendChild(backdrop);
|
|
252
|
+
return backdrop;
|
|
253
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Costs tab — per-model spend table plus the 7-day bar mini-chart (inline
|
|
3
|
+
* SVG, no chart lib). The route returns `{ id, costs }` where `costs` holds
|
|
4
|
+
* integer USD-micros figures (`byModel`, zero-filled `days`, rolling
|
|
5
|
+
* `spend7dUsdMicros`, `truncatedFiles`); dollars are derived client-side in
|
|
6
|
+
* one place (`usdFromMicros`). Capped reads are flagged, never hidden.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { api } from "../api.js";
|
|
10
|
+
import { clear, el, emptyState, skeleton, svgEl } from "../dom.js";
|
|
11
|
+
import { barRects, fmtCount, fmtUsd, usdFromMicros } from "../util.js";
|
|
12
|
+
|
|
13
|
+
export async function renderCosts(root, ctx) {
|
|
14
|
+
clear(root).appendChild(skeleton(6));
|
|
15
|
+
const data = await api.costs(ctx.id);
|
|
16
|
+
clear(root);
|
|
17
|
+
const costs = data?.costs && typeof data.costs === "object" ? data.costs : null;
|
|
18
|
+
const models = Array.isArray(costs?.byModel) ? costs.byModel : [];
|
|
19
|
+
const days = Array.isArray(costs?.days) ? costs.days : [];
|
|
20
|
+
if (costs === null || (models.length === 0 && costs.calls === 0)) {
|
|
21
|
+
root.appendChild(
|
|
22
|
+
emptyState("No cost data yet", "crewhaus run (cost tracking is on by default)"),
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (days.length > 0) {
|
|
28
|
+
const shown = days.slice(-7);
|
|
29
|
+
const values = shown.map((d) => usdFromMicros(d.usdMicros) ?? 0);
|
|
30
|
+
const rects = barRects(values, 420, 64);
|
|
31
|
+
root.appendChild(
|
|
32
|
+
el("div", { class: "card" }, [
|
|
33
|
+
el("h3", { class: "card-title" }, [
|
|
34
|
+
el("span", { text: "Last 7 days" }),
|
|
35
|
+
el("span", {
|
|
36
|
+
class: "muted card-sub",
|
|
37
|
+
text: fmtUsd(usdFromMicros(costs.spend7dUsdMicros)),
|
|
38
|
+
}),
|
|
39
|
+
typeof costs.truncatedFiles === "number" && costs.truncatedFiles > 0
|
|
40
|
+
? el("span", {
|
|
41
|
+
class: "chip chip-warn",
|
|
42
|
+
title: "some session logs hit the read cap — totals are floors, not lies",
|
|
43
|
+
text: `${costs.truncatedFiles} capped file${costs.truncatedFiles === 1 ? "" : "s"}`,
|
|
44
|
+
})
|
|
45
|
+
: null,
|
|
46
|
+
]),
|
|
47
|
+
svgEl(
|
|
48
|
+
"svg",
|
|
49
|
+
{
|
|
50
|
+
class: "bars bars-lg",
|
|
51
|
+
viewBox: "0 0 420 64",
|
|
52
|
+
role: "img",
|
|
53
|
+
"aria-label": "daily spend",
|
|
54
|
+
},
|
|
55
|
+
rects.map((r) => svgEl("rect", { x: r.x, y: r.y, width: r.w, height: r.h, rx: "1.5" })),
|
|
56
|
+
),
|
|
57
|
+
el(
|
|
58
|
+
"div",
|
|
59
|
+
{ class: "bar-labels" },
|
|
60
|
+
shown.map((d) => el("span", { class: "muted", text: String(d.day ?? "").slice(5) })),
|
|
61
|
+
),
|
|
62
|
+
]),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (models.length > 0) {
|
|
67
|
+
const tbody = el("tbody");
|
|
68
|
+
for (const m of models) {
|
|
69
|
+
tbody.appendChild(
|
|
70
|
+
el("tr", null, [
|
|
71
|
+
el("td", { class: "mono", text: String(m.modelId ?? "?") }),
|
|
72
|
+
el("td", { text: String(m.provider ?? "—") }),
|
|
73
|
+
el("td", { class: "num", text: fmtCount(numOr(m.calls)) }),
|
|
74
|
+
el("td", { class: "num", text: fmtCount(numOr(m.inputTokens)) }),
|
|
75
|
+
el("td", { class: "num", text: fmtCount(numOr(m.outputTokens)) }),
|
|
76
|
+
el("td", { class: "num", text: fmtUsd(usdFromMicros(m.usdMicros)) }),
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
root.appendChild(
|
|
81
|
+
el("div", { class: "table-scroll" }, [
|
|
82
|
+
el("table", { class: "fleet" }, [
|
|
83
|
+
el(
|
|
84
|
+
"thead",
|
|
85
|
+
null,
|
|
86
|
+
el(
|
|
87
|
+
"tr",
|
|
88
|
+
null,
|
|
89
|
+
["Model", "Provider", "Calls", "In tokens", "Out tokens", "Cost"].map((h) =>
|
|
90
|
+
el("th", { text: h }),
|
|
91
|
+
),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
tbody,
|
|
95
|
+
]),
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function numOr(v) {
|
|
102
|
+
return typeof v === "number" ? v : null;
|
|
103
|
+
}
|