@crewhaus/hangar-ui 0.5.8 → 0.7.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/assets/hangar.css +30 -0
- package/assets/js/app.js +3 -0
- package/assets/js/dom.js +55 -12
- package/assets/js/router.js +7 -0
- package/assets/js/routes.js +15 -1
- package/assets/js/supervision.js +70 -1
- package/assets/js/views/channels.js +66 -9
- package/assets/js/views/models.js +430 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/assets/hangar.css
CHANGED
|
@@ -786,6 +786,36 @@ table.fleet {
|
|
|
786
786
|
font-size: 10.5px;
|
|
787
787
|
}
|
|
788
788
|
|
|
789
|
+
/* 0.6.0 — the Models tab's share meter and its chip row. A share is always
|
|
790
|
+
rendered NEXT TO its number, never instead of one: the bar is the shape of
|
|
791
|
+
the split, the figure is the claim. */
|
|
792
|
+
.meter {
|
|
793
|
+
min-width: 72px;
|
|
794
|
+
height: 8px;
|
|
795
|
+
border-radius: 999px;
|
|
796
|
+
background: var(--panel-2);
|
|
797
|
+
border: 1px solid var(--rule-2);
|
|
798
|
+
overflow: hidden;
|
|
799
|
+
}
|
|
800
|
+
.meter-fill {
|
|
801
|
+
height: 100%;
|
|
802
|
+
background: var(--accent);
|
|
803
|
+
opacity: 0.85;
|
|
804
|
+
}
|
|
805
|
+
.chips {
|
|
806
|
+
display: flex;
|
|
807
|
+
flex-wrap: wrap;
|
|
808
|
+
gap: 2px;
|
|
809
|
+
margin-bottom: 8px;
|
|
810
|
+
}
|
|
811
|
+
.chips .chip {
|
|
812
|
+
cursor: pointer;
|
|
813
|
+
font-family: var(--mono);
|
|
814
|
+
}
|
|
815
|
+
tr.row-best td {
|
|
816
|
+
background: var(--accent-ghost);
|
|
817
|
+
}
|
|
818
|
+
|
|
789
819
|
/* preflight + folds */
|
|
790
820
|
.fold {
|
|
791
821
|
border: 1px solid var(--rule);
|
package/assets/js/app.js
CHANGED
|
@@ -52,6 +52,7 @@ import { renderInspect } from "./views/inspect.js";
|
|
|
52
52
|
import { renderLibrary } from "./views/library.js";
|
|
53
53
|
import { renderMemoryFabric } from "./views/memory-fabric.js";
|
|
54
54
|
import { renderMemory } from "./views/memory.js";
|
|
55
|
+
import { renderModels } from "./views/models.js";
|
|
55
56
|
import { renderOverview } from "./views/overview.js";
|
|
56
57
|
import { renderPanes } from "./views/panes.js";
|
|
57
58
|
import { renderReview } from "./views/review.js";
|
|
@@ -79,6 +80,7 @@ const TAB_LABELS = {
|
|
|
79
80
|
data: "Datasets",
|
|
80
81
|
feedback: "Feedback",
|
|
81
82
|
costs: "Costs",
|
|
83
|
+
models: "Models",
|
|
82
84
|
creds: "Credentials",
|
|
83
85
|
channels: "Channels",
|
|
84
86
|
security: "Security",
|
|
@@ -441,6 +443,7 @@ async function renderHarnessPage(root, route) {
|
|
|
441
443
|
} else if (route.tab === "data") await renderData(tabRoot, ctx);
|
|
442
444
|
else if (route.tab === "feedback") await renderFeedback(tabRoot, ctx);
|
|
443
445
|
else if (route.tab === "costs") await renderCosts(tabRoot, ctx);
|
|
446
|
+
else if (route.tab === "models") await renderModels(tabRoot, ctx);
|
|
444
447
|
else if (route.tab === "creds") await renderCreds(tabRoot, ctx);
|
|
445
448
|
else if (route.tab === "channels") await renderChannels(tabRoot, ctx);
|
|
446
449
|
else if (route.tab === "security") await renderSecurity(tabRoot, ctx);
|
package/assets/js/dom.js
CHANGED
|
@@ -142,21 +142,64 @@ export function toast(message, kind = "error") {
|
|
|
142
142
|
return node;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
/**
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Copy `text`, falling back to the old selection trick.
|
|
147
|
+
*
|
|
148
|
+
* `navigator.clipboard` is a secure-context API. `127.0.0.1` counts as
|
|
149
|
+
* secure, so on the loopback console it always works — and over
|
|
150
|
+
* `http://192.168.x.x`, which is what `crewhaus hangar --lan` serves, the
|
|
151
|
+
* whole object is `undefined` and every copy button in the console reads
|
|
152
|
+
* "copy failed". That is invisible until someone opens the console from a
|
|
153
|
+
* phone, which is exactly who cannot retype a harness directory by hand.
|
|
154
|
+
*
|
|
155
|
+
* The fallback is `document.execCommand("copy")` over a throwaway textarea:
|
|
156
|
+
* deprecated, still implemented everywhere, and not gated on a secure
|
|
157
|
+
* context. The textarea is positioned off-screen rather than hidden because
|
|
158
|
+
* a `display: none` element cannot hold a selection.
|
|
159
|
+
*/
|
|
160
|
+
async function writeClipboard(text) {
|
|
161
|
+
if (navigator.clipboard !== undefined) {
|
|
149
162
|
try {
|
|
150
|
-
await navigator.clipboard.writeText(
|
|
151
|
-
|
|
152
|
-
btn.classList.add("ok");
|
|
153
|
-
setTimeout(() => {
|
|
154
|
-
btn.textContent = label;
|
|
155
|
-
btn.classList.remove("ok");
|
|
156
|
-
}, 1200);
|
|
163
|
+
await navigator.clipboard.writeText(text);
|
|
164
|
+
return true;
|
|
157
165
|
} catch {
|
|
158
|
-
|
|
166
|
+
// Permission refused, or a non-secure context that still exposes the
|
|
167
|
+
// object. Fall through rather than giving up.
|
|
159
168
|
}
|
|
169
|
+
}
|
|
170
|
+
const scratch = el("textarea");
|
|
171
|
+
scratch.value = text;
|
|
172
|
+
scratch.setAttribute("readonly", "readonly");
|
|
173
|
+
scratch.setAttribute("aria-hidden", "true");
|
|
174
|
+
// Off-screen rather than hidden: a `display: none` element cannot hold a
|
|
175
|
+
// selection, and `opacity: 0` alone would still scroll the page.
|
|
176
|
+
scratch.style.position = "fixed";
|
|
177
|
+
scratch.style.top = "-1000px";
|
|
178
|
+
scratch.style.opacity = "0";
|
|
179
|
+
document.body.appendChild(scratch);
|
|
180
|
+
try {
|
|
181
|
+
scratch.select();
|
|
182
|
+
scratch.setSelectionRange(0, text.length);
|
|
183
|
+
return document.execCommand("copy");
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
186
|
+
} finally {
|
|
187
|
+
scratch.remove();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Click-to-copy button. Works on the loopback console and over a LAN
|
|
192
|
+
* address alike — see {@link writeClipboard}. */
|
|
193
|
+
export function copyBtn(value, label = "copy") {
|
|
194
|
+
const btn = el("button", { class: "btn btn-ghost btn-copy", type: "button", text: label });
|
|
195
|
+
btn.addEventListener("click", async () => {
|
|
196
|
+
const copied = await writeClipboard(value);
|
|
197
|
+
btn.textContent = copied ? "copied" : "copy failed";
|
|
198
|
+
if (copied) btn.classList.add("ok");
|
|
199
|
+
setTimeout(() => {
|
|
200
|
+
btn.textContent = label;
|
|
201
|
+
btn.classList.remove("ok");
|
|
202
|
+
}, 1200);
|
|
160
203
|
});
|
|
161
204
|
return btn;
|
|
162
205
|
}
|
package/assets/js/router.js
CHANGED
|
@@ -52,6 +52,9 @@ export const HARNESS_TABS = [
|
|
|
52
52
|
"data",
|
|
53
53
|
"feedback",
|
|
54
54
|
"costs",
|
|
55
|
+
// 0.6.0 (design §8.3): the models tab sits after Costs because it answers
|
|
56
|
+
// the question Costs raises — WHICH model, under which settings, spent it.
|
|
57
|
+
"models",
|
|
55
58
|
"creds",
|
|
56
59
|
"channels",
|
|
57
60
|
"security",
|
|
@@ -73,6 +76,10 @@ export const HARNESS_TABS = [
|
|
|
73
76
|
* state that tab renders is a real answer and a shared URL must not 404. */
|
|
74
77
|
export const M3_TABS = [
|
|
75
78
|
"data",
|
|
79
|
+
// 0.6.0: `models` takes trailing segments generically too — a route
|
|
80
|
+
// timeline is `#/h/<id>/models/routes/<sess>`, and naming that sub-screen
|
|
81
|
+
// is the view's business, not the router's.
|
|
82
|
+
"models",
|
|
76
83
|
"feedback",
|
|
77
84
|
"creds",
|
|
78
85
|
"channels",
|
package/assets/js/routes.js
CHANGED
|
@@ -783,7 +783,7 @@ export const ROUTES = {
|
|
|
783
783
|
advisorReportRun: {
|
|
784
784
|
method: "POST",
|
|
785
785
|
path: "/api/h/:id/advisor/reports",
|
|
786
|
-
body: "AdvisorReportRun", // {kind: model-usage|costs|usefulness|optimization}
|
|
786
|
+
body: "AdvisorReportRun", // {kind: model-usage|costs|usefulness|optimization|routing|hybrid}
|
|
787
787
|
group: "advisor",
|
|
788
788
|
},
|
|
789
789
|
advisorReport: {
|
|
@@ -803,6 +803,19 @@ export const ROUTES = {
|
|
|
803
803
|
},
|
|
804
804
|
advisorFleet: { method: "GET", path: "/api/advisor", group: "advisor" },
|
|
805
805
|
|
|
806
|
+
// ---- group "models": per-model settings, hybrid routing, the scoreboard -
|
|
807
|
+
// 0.6.0 (design §8.3). READ-ONLY, all four: the console renders the
|
|
808
|
+
// `models:` registry, the declared pool, per-role/per-profile spend, the
|
|
809
|
+
// learned arms and their leaderboard, and one run's route timeline — and
|
|
810
|
+
// it writes none of them. Hangar never touches an arm or a prior; the
|
|
811
|
+
// roster, the rules and the floor stay human-owned behind `propose`.
|
|
812
|
+
models: { method: "GET", path: "/api/h/:id/models", group: "models" },
|
|
813
|
+
// The route timeline for ONE session, read from the session JSONL (§8.1:
|
|
814
|
+
// Hangar reads the durable log, never the bus).
|
|
815
|
+
modelRoutes: { method: "GET", path: "/api/h/:id/models/routes/:sess", group: "models" },
|
|
816
|
+
modelArms: { method: "GET", path: "/api/h/:id/models/arms", group: "models" },
|
|
817
|
+
modelLeaderboard: { method: "GET", path: "/api/h/:id/models/leaderboard", group: "models" },
|
|
818
|
+
|
|
806
819
|
// ---- group "runtime": the mcp-server + dev run classes -----------------
|
|
807
820
|
// Both are PROCESSES: they get runfiles, ledger rows and ledger-claimed
|
|
808
821
|
// ports, and their live output rides the ONE existing SSE feed
|
|
@@ -841,6 +854,7 @@ export const M3_GROUPS = [
|
|
|
841
854
|
"inspect",
|
|
842
855
|
"runtime",
|
|
843
856
|
"advisor",
|
|
857
|
+
"models",
|
|
844
858
|
];
|
|
845
859
|
|
|
846
860
|
/** Every route key belonging to `group`, in map order. The views render
|
package/assets/js/supervision.js
CHANGED
|
@@ -700,6 +700,15 @@ function shortJson(value, max = 120) {
|
|
|
700
700
|
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
701
701
|
}
|
|
702
702
|
|
|
703
|
+
/** `model` first, then the optional facets, then the router's reason — joined with dots. */
|
|
704
|
+
function routeDetail(e, parts) {
|
|
705
|
+
const head = parts
|
|
706
|
+
.filter((part) => part !== null && part !== undefined && part !== "")
|
|
707
|
+
.map(String);
|
|
708
|
+
const reason = typeof e.reason === "string" && e.reason !== "" ? e.reason : null;
|
|
709
|
+
return [...head, ...(reason ? [reason] : [])].join(" · ");
|
|
710
|
+
}
|
|
711
|
+
|
|
703
712
|
/**
|
|
704
713
|
* One TraceEvent → its feed card. Known kinds get a written line; unknown
|
|
705
714
|
* kinds still render (tolerant-reader contract) with a clipped payload
|
|
@@ -763,8 +772,68 @@ export function traceCard(event) {
|
|
|
763
772
|
remediation: typeof e.remediation === "string" ? e.remediation : null,
|
|
764
773
|
};
|
|
765
774
|
case "model_route":
|
|
775
|
+
// 0.6.0 (design §8.3) — the event carries `model` (wire id) and
|
|
776
|
+
// `routeKey`, never `modelId`/`band`; the 0.5.x card read the latter
|
|
777
|
+
// and rendered an empty detail. Profile, policy and reason follow when
|
|
778
|
+
// present so a hybrid decision reads as a sentence.
|
|
779
|
+
return {
|
|
780
|
+
...base,
|
|
781
|
+
title: `model route${e.stage ? ` · ${String(e.stage)}` : ""}`,
|
|
782
|
+
detail: routeDetail(e, [
|
|
783
|
+
e.model,
|
|
784
|
+
e.profile ? `profile=${String(e.profile)}` : null,
|
|
785
|
+
e.routeKey ? `band=${String(e.routeKey)}` : null,
|
|
786
|
+
e.policy
|
|
787
|
+
? `policy=${String(e.policy)}${e.explored === true ? " (exploring)" : ""}`
|
|
788
|
+
: null,
|
|
789
|
+
e.ruleId ? `rule=${String(e.ruleId)}` : null,
|
|
790
|
+
]),
|
|
791
|
+
};
|
|
766
792
|
case "model_tier_route":
|
|
767
|
-
return {
|
|
793
|
+
return {
|
|
794
|
+
...base,
|
|
795
|
+
title: "model tier",
|
|
796
|
+
detail: routeDetail(e, [
|
|
797
|
+
e.model,
|
|
798
|
+
e.tier ? `tier=${String(e.tier)}${e.escalated === true ? " (escalated)" : ""}` : null,
|
|
799
|
+
]),
|
|
800
|
+
};
|
|
801
|
+
case "model_failover":
|
|
802
|
+
return {
|
|
803
|
+
...base,
|
|
804
|
+
title: "model failover",
|
|
805
|
+
detail: `${String(e.from ?? "?")} → ${String(e.to ?? "?")}${
|
|
806
|
+
e.reason ? ` · ${String(e.reason)}` : ""
|
|
807
|
+
}`,
|
|
808
|
+
};
|
|
809
|
+
case "model_stage":
|
|
810
|
+
return {
|
|
811
|
+
...base,
|
|
812
|
+
dot: e.outcome === "failed" ? "warn" : base.dot,
|
|
813
|
+
title: `stage ${String(e.stage ?? "?")} ${String(e.outcome ?? "")}`.trim(),
|
|
814
|
+
detail: [
|
|
815
|
+
e.model ? String(e.model) : null,
|
|
816
|
+
e.profile ? `profile=${String(e.profile)}` : null,
|
|
817
|
+
e.role ? `role=${String(e.role)}` : null,
|
|
818
|
+
e.strategy ? `strategy=${String(e.strategy)}` : null,
|
|
819
|
+
e.cause ? `cause=${String(e.cause)}` : null,
|
|
820
|
+
]
|
|
821
|
+
.filter((part) => part !== null)
|
|
822
|
+
.join(" · "),
|
|
823
|
+
};
|
|
824
|
+
case "model_directive":
|
|
825
|
+
return {
|
|
826
|
+
...base,
|
|
827
|
+
dot: e.accepted === false ? "warn" : base.dot,
|
|
828
|
+
title: `/model ${String(e.requested ?? "?")} ${e.accepted === false ? "refused" : "accepted"}`,
|
|
829
|
+
detail: [
|
|
830
|
+
e.resolved ? `→ ${String(e.resolved)}` : null,
|
|
831
|
+
e.source ? `source=${String(e.source)}` : null,
|
|
832
|
+
e.reason ? String(e.reason) : null,
|
|
833
|
+
]
|
|
834
|
+
.filter((part) => part !== null)
|
|
835
|
+
.join(" · "),
|
|
836
|
+
};
|
|
768
837
|
case "janitor_action":
|
|
769
838
|
return { ...base, title: "janitor", detail: String(e.action ?? "") };
|
|
770
839
|
default:
|
|
@@ -28,6 +28,51 @@ import { api } from "../api.js";
|
|
|
28
28
|
import { clear, collapsible, dot, el, jsonPre, numberedCode, skeleton, toast } from "../dom.js";
|
|
29
29
|
import { m3, m3Card, m3Empty } from "./creds.js";
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* True when `hostname` names this machine — the browser-side half of the
|
|
33
|
+
* CLI's `isLoopbackHost`, deliberately re-implemented rather than imported:
|
|
34
|
+
* the console's JS is hand-written browser ES modules with no build step and
|
|
35
|
+
* no imports out of `assets/`.
|
|
36
|
+
*
|
|
37
|
+
* `location.hostname` hands IPv6 literals over WITHOUT their brackets, so
|
|
38
|
+
* `::1` arrives bare; both spellings are accepted. An empty hostname (a
|
|
39
|
+
* `file://` document) is NOT loopback — falling through to the honest note
|
|
40
|
+
* is the safe direction to be wrong in.
|
|
41
|
+
*/
|
|
42
|
+
export function isLoopbackHostname(hostname) {
|
|
43
|
+
const host = typeof hostname === "string" ? hostname.trim().toLowerCase() : "";
|
|
44
|
+
if (host === "") return false;
|
|
45
|
+
const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
46
|
+
if (bare === "localhost") return true;
|
|
47
|
+
if (bare === "::1" || bare === "0:0:0:0:0:0:0:1") return true;
|
|
48
|
+
// The whole of 127.0.0.0/8 — a daemon on 127.0.0.2 is every bit as local.
|
|
49
|
+
const octets = bare.split(".");
|
|
50
|
+
if (octets.length !== 4) return false;
|
|
51
|
+
if (!octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255)) return false;
|
|
52
|
+
return octets[0] === "127";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether to OFFER the daemon's dashboard as a link, or just name it.
|
|
57
|
+
*
|
|
58
|
+
* The daemon's control-UI port is loopback by design — `service-setup`
|
|
59
|
+
* never tunnels it and it carries no auth — so the address the server sends
|
|
60
|
+
* is only usable by a browser sitting at that machine. Since
|
|
61
|
+
* `crewhaus hangar --lan` puts this console on a phone, "usable" is now a
|
|
62
|
+
* question with two answers, and only the browser can tell which: it knows
|
|
63
|
+
* the host it reached the console on.
|
|
64
|
+
*
|
|
65
|
+
* A link that quietly goes nowhere is worse than a line of text that says
|
|
66
|
+
* where the thing is, so a remote viewer gets the address and the reason
|
|
67
|
+
* instead of a dead anchor.
|
|
68
|
+
*/
|
|
69
|
+
export function dashboardReach(dashboardUrl, viewerHostname) {
|
|
70
|
+
if (typeof dashboardUrl !== "string" || dashboardUrl === "") return null;
|
|
71
|
+
return isLoopbackHostname(viewerHostname)
|
|
72
|
+
? { reachable: true, url: dashboardUrl }
|
|
73
|
+
: { reachable: false, url: dashboardUrl };
|
|
74
|
+
}
|
|
75
|
+
|
|
31
76
|
export async function renderChannels(root, ctx) {
|
|
32
77
|
clear(root).appendChild(skeleton(5));
|
|
33
78
|
const [channelsAnswer, gatewayAnswer] = await Promise.all([
|
|
@@ -431,16 +476,28 @@ function gatewayCard(body) {
|
|
|
431
476
|
: null,
|
|
432
477
|
]),
|
|
433
478
|
];
|
|
434
|
-
|
|
479
|
+
const reach = dashboardReach(body.dashboardUrl, window.location.hostname);
|
|
480
|
+
if (reach !== null) {
|
|
435
481
|
nodes.push(
|
|
436
|
-
el(
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
482
|
+
el(
|
|
483
|
+
"p",
|
|
484
|
+
null,
|
|
485
|
+
reach.reachable
|
|
486
|
+
? [
|
|
487
|
+
el("a", {
|
|
488
|
+
href: reach.url,
|
|
489
|
+
target: "_blank",
|
|
490
|
+
rel: "noreferrer",
|
|
491
|
+
text: "open the daemon's own dashboard",
|
|
492
|
+
}),
|
|
493
|
+
]
|
|
494
|
+
: [
|
|
495
|
+
el("span", {
|
|
496
|
+
class: "muted",
|
|
497
|
+
text: `the daemon's own dashboard is at ${reach.url} — it binds loopback and is not tunnelled, so it opens only from the machine running the daemon, not from here`,
|
|
498
|
+
}),
|
|
499
|
+
],
|
|
500
|
+
),
|
|
444
501
|
);
|
|
445
502
|
}
|
|
446
503
|
if (body.status !== null && body.status !== undefined) {
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models tab (0.6.0 design §8.3) — the answer to "which model served, with
|
|
3
|
+
* which settings, at what cost, and is the cheap lane winning?"
|
|
4
|
+
*
|
|
5
|
+
* Four panels, in the order an operator asks the questions:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Registry** — the `models:` profiles this spec declares, and the
|
|
8
|
+
* `model_pool` candidates that reference them (one card per pool: a
|
|
9
|
+
* crew role or a workflow step declares one as readily as `agent:`).
|
|
10
|
+
* 2. **Per-profile spend** — the same cost fold the Costs tab reads, split
|
|
11
|
+
* by `role` and by `profile` instead of by model. This is where a
|
|
12
|
+
* judge, an escalation or a sub-agent's spend becomes visible: those
|
|
13
|
+
* calls carry the attribution, and until 0.6.0 nothing rendered it.
|
|
14
|
+
* 3. **Leaderboard** — the learned arms ranked inside each routing band,
|
|
15
|
+
* with the arm a `learned` policy would exploit starred.
|
|
16
|
+
* 4. **Route timeline** — one run's durable routing lines in order, so the
|
|
17
|
+
* shape of a hybrid turn (draft → judge → escalate) can be read back.
|
|
18
|
+
*
|
|
19
|
+
* READ-ONLY. Nothing on this screen writes: the roster, the rules, the
|
|
20
|
+
* strategy membership and the conservative floor are human-owned and travel
|
|
21
|
+
* through `crewhaus propose`; the arms are the runtime's to write.
|
|
22
|
+
*
|
|
23
|
+
* The pure shaping functions are exported for unit tests; the renderers are
|
|
24
|
+
* thin DOM builders over them, in the M2 tradition.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { api } from "../api.js";
|
|
28
|
+
import { clear, collapsible, el, emptyState, jsonPre, skeleton } from "../dom.js";
|
|
29
|
+
import { fmtCount, fmtUsd, usdFromMicros } from "../util.js";
|
|
30
|
+
|
|
31
|
+
/** A spend row's share of the total, in [0, 1]. Pure. */
|
|
32
|
+
export function spendShare(usdMicros, totalUsdMicros) {
|
|
33
|
+
const total = typeof totalUsdMicros === "number" ? totalUsdMicros : 0;
|
|
34
|
+
const part = typeof usdMicros === "number" ? usdMicros : 0;
|
|
35
|
+
return total > 0 ? part / total : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Payload → the per-profile spend rows the table renders, biggest first.
|
|
40
|
+
* The server already ranks them; this normalizes the shape and attaches the
|
|
41
|
+
* share so the bar and the number cannot disagree. Pure.
|
|
42
|
+
*/
|
|
43
|
+
export function profileSpendRows(payload) {
|
|
44
|
+
const spend =
|
|
45
|
+
payload && typeof payload.spend === "object" && payload.spend !== null ? payload.spend : {};
|
|
46
|
+
const total = typeof spend.totalUsdMicros === "number" ? spend.totalUsdMicros : 0;
|
|
47
|
+
const rows = Array.isArray(spend.byProfile) ? spend.byProfile : [];
|
|
48
|
+
return rows.map((row) => ({
|
|
49
|
+
profile: String(row?.profile ?? "(none)"),
|
|
50
|
+
calls: typeof row?.calls === "number" ? row.calls : 0,
|
|
51
|
+
usdMicros: typeof row?.usdMicros === "number" ? row.usdMicros : 0,
|
|
52
|
+
share: spendShare(row?.usdMicros, total),
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Payload → the declared pools, one row per host that declares one. Pure.
|
|
58
|
+
*
|
|
59
|
+
* `model_pool` is not an `agent:` field — a crew role, a workflow step, a
|
|
60
|
+
* graph node or a sub-agent declares one too — so the tab renders a card per
|
|
61
|
+
* pool. A manager one version behind serves only `pool`; that single view is
|
|
62
|
+
* used as the one row rather than claiming nothing is declared.
|
|
63
|
+
*/
|
|
64
|
+
export function declaredPools(payload) {
|
|
65
|
+
const rows = Array.isArray(payload?.pools) ? payload.pools : [];
|
|
66
|
+
const shaped = rows
|
|
67
|
+
.filter((row) => row && typeof row.pool === "object" && row.pool !== null)
|
|
68
|
+
.map((row) => ({ hostPath: String(row.hostPath ?? "model_pool"), pool: row.pool }));
|
|
69
|
+
if (shaped.length > 0) return shaped;
|
|
70
|
+
const single = payload?.pool;
|
|
71
|
+
return single && typeof single === "object" && single.declared === true
|
|
72
|
+
? [{ hostPath: "model_pool", pool: single }]
|
|
73
|
+
: [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The same shaping for the per-role split. Pure. */
|
|
77
|
+
export function roleSpendRows(payload) {
|
|
78
|
+
const spend =
|
|
79
|
+
payload && typeof payload.spend === "object" && payload.spend !== null ? payload.spend : {};
|
|
80
|
+
const total = typeof spend.totalUsdMicros === "number" ? spend.totalUsdMicros : 0;
|
|
81
|
+
const rows = Array.isArray(spend.byRole) ? spend.byRole : [];
|
|
82
|
+
return rows.map((row) => ({
|
|
83
|
+
role: String(row?.role ?? "primary"),
|
|
84
|
+
calls: typeof row?.calls === "number" ? row.calls : 0,
|
|
85
|
+
usdMicros: typeof row?.usdMicros === "number" ? row.usdMicros : 0,
|
|
86
|
+
share: spendShare(row?.usdMicros, total),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* One timeline entry → the single line the gutter renders. Pure, and
|
|
92
|
+
* deliberately total: an unknown routing kind still renders its kind rather
|
|
93
|
+
* than vanishing, because a manager one version behind its harness must not
|
|
94
|
+
* silently drop events.
|
|
95
|
+
*/
|
|
96
|
+
export function timelineLine(entry) {
|
|
97
|
+
const kind = String(entry?.kind ?? "");
|
|
98
|
+
const p =
|
|
99
|
+
entry && typeof entry.payload === "object" && entry.payload !== null ? entry.payload : {};
|
|
100
|
+
const parts = [];
|
|
101
|
+
const push = (value) => {
|
|
102
|
+
if (value !== undefined && value !== null && String(value) !== "") parts.push(String(value));
|
|
103
|
+
};
|
|
104
|
+
switch (kind) {
|
|
105
|
+
case "model_route":
|
|
106
|
+
push(p.model);
|
|
107
|
+
if (p.profile) push(`profile=${p.profile}`);
|
|
108
|
+
if (p.band) push(`band=${p.band}`);
|
|
109
|
+
if (p.policy) push(`policy=${p.policy}${p.explored === true ? " (exploring)" : ""}`);
|
|
110
|
+
if (p.ruleId) push(`rule=${p.ruleId}`);
|
|
111
|
+
break;
|
|
112
|
+
case "model_tier_route":
|
|
113
|
+
push(p.model);
|
|
114
|
+
if (p.tier) push(`tier=${p.tier}${p.escalated === true ? " (escalated)" : ""}`);
|
|
115
|
+
break;
|
|
116
|
+
case "model_stage":
|
|
117
|
+
push(`${p.stage ?? "?"} · ${p.outcome ?? "?"}`);
|
|
118
|
+
if (p.strategy) push(`strategy=${p.strategy}`);
|
|
119
|
+
if (p.model) push(p.model);
|
|
120
|
+
if (p.profile) push(`profile=${p.profile}`);
|
|
121
|
+
if (p.cause) push(p.cause);
|
|
122
|
+
break;
|
|
123
|
+
case "model_directive":
|
|
124
|
+
push(`${p.requested ?? "?"} → ${p.resolved ?? "(refused)"}`);
|
|
125
|
+
push(p.accepted === true ? "accepted" : "refused");
|
|
126
|
+
if (p.reason) push(p.reason);
|
|
127
|
+
break;
|
|
128
|
+
case "model_failover":
|
|
129
|
+
push(`${p.from ?? "?"} → ${p.to ?? "?"}`);
|
|
130
|
+
if (p.reason) push(p.reason);
|
|
131
|
+
break;
|
|
132
|
+
case "judge_verdict":
|
|
133
|
+
push(p.verdict);
|
|
134
|
+
if (typeof p.score === "number") push(`score=${p.score}`);
|
|
135
|
+
if (p.judgeModel) push(`judge=${p.judgeModel}`);
|
|
136
|
+
break;
|
|
137
|
+
default:
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
kind,
|
|
142
|
+
turn: typeof entry?.turnNumber === "number" ? entry.turnNumber : null,
|
|
143
|
+
detail: parts.join(" · "),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function shareBar(share) {
|
|
148
|
+
const pct = Math.max(0, Math.min(1, share)) * 100;
|
|
149
|
+
return el("div", { class: "meter" }, [
|
|
150
|
+
el("div", { class: "meter-fill", style: `width:${pct.toFixed(1)}%` }),
|
|
151
|
+
]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function table(headers, rows) {
|
|
155
|
+
return el("div", { class: "table-scroll" }, [
|
|
156
|
+
el("table", { class: "fleet" }, [
|
|
157
|
+
el(
|
|
158
|
+
"thead",
|
|
159
|
+
null,
|
|
160
|
+
el(
|
|
161
|
+
"tr",
|
|
162
|
+
null,
|
|
163
|
+
headers.map((h) => el("th", { text: h })),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
el("tbody", null, rows),
|
|
167
|
+
]),
|
|
168
|
+
]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function card(title, sub, body) {
|
|
172
|
+
return el("div", { class: "card" }, [
|
|
173
|
+
el("h3", { class: "card-title" }, [
|
|
174
|
+
el("span", { text: title }),
|
|
175
|
+
sub === null ? null : el("span", { class: "muted card-sub", text: sub }),
|
|
176
|
+
]),
|
|
177
|
+
...(Array.isArray(body) ? body : [body]),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The whole tab. `ctx` is `{ id, … }`, the same shape every tab receives. */
|
|
182
|
+
export async function renderModels(root, ctx) {
|
|
183
|
+
clear(root).appendChild(skeleton(8));
|
|
184
|
+
const data = await api.models({ id: ctx.id });
|
|
185
|
+
clear(root);
|
|
186
|
+
const body = data && typeof data.body === "object" && data.body !== null ? data.body : data;
|
|
187
|
+
if (!body || typeof body !== "object") {
|
|
188
|
+
root.appendChild(emptyState("No model data", "crewhaus models list"));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const registry = Array.isArray(body.registry) ? body.registry : [];
|
|
193
|
+
const pools = declaredPools(body);
|
|
194
|
+
const arms = Array.isArray(body.arms) ? body.arms : [];
|
|
195
|
+
const leaderboard = Array.isArray(body.leaderboard) ? body.leaderboard : [];
|
|
196
|
+
const sessions = Array.isArray(body.sessions) ? body.sessions : [];
|
|
197
|
+
const profiles = profileSpendRows(body);
|
|
198
|
+
const roles = roleSpendRows(body);
|
|
199
|
+
|
|
200
|
+
if (typeof body.note === "string" && body.note !== "") {
|
|
201
|
+
root.appendChild(el("p", { class: "muted", text: body.note }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---- 1. the registry + the pool ----------------------------------------
|
|
205
|
+
root.appendChild(
|
|
206
|
+
card(
|
|
207
|
+
"Registry",
|
|
208
|
+
registry.length === 0 ? "no models: block declared" : `${registry.length} profile(s)`,
|
|
209
|
+
registry.length === 0
|
|
210
|
+
? el("p", {
|
|
211
|
+
class: "muted",
|
|
212
|
+
text: "declare models: to give a profile its own settings, tools and permissions once, then reference it as $name at any model slot.",
|
|
213
|
+
})
|
|
214
|
+
: table(
|
|
215
|
+
["Profile", "Model", "Settings"],
|
|
216
|
+
registry.map((row) =>
|
|
217
|
+
el("tr", null, [
|
|
218
|
+
el("td", { class: "mono", text: String(row?.name ?? "?") }),
|
|
219
|
+
el("td", { class: "mono", text: String(row?.model ?? "—") }),
|
|
220
|
+
el("td", {
|
|
221
|
+
class: "muted",
|
|
222
|
+
text: (Array.isArray(row?.settings) ? row.settings : [])
|
|
223
|
+
.filter((s) => s?.key !== "model")
|
|
224
|
+
.map((s) => `${s.key}=${s.value}`)
|
|
225
|
+
.join(" · "),
|
|
226
|
+
}),
|
|
227
|
+
]),
|
|
228
|
+
),
|
|
229
|
+
),
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if (pools.length === 0) {
|
|
234
|
+
root.appendChild(
|
|
235
|
+
card(
|
|
236
|
+
"Routing pool",
|
|
237
|
+
"not declared",
|
|
238
|
+
el("p", {
|
|
239
|
+
class: "muted",
|
|
240
|
+
text: "no model_pool anywhere in the spec — every turn is served by the one declared model.",
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
} else {
|
|
245
|
+
for (const { hostPath, pool: p } of pools) {
|
|
246
|
+
root.appendChild(
|
|
247
|
+
card(
|
|
248
|
+
`Routing pool · ${hostPath}`,
|
|
249
|
+
`policy ${p.policy ?? "static"}${p.scope ? ` · scope ${p.scope}` : ""}`,
|
|
250
|
+
table(
|
|
251
|
+
["Candidate", "Profile", "Tags", "Enabled"],
|
|
252
|
+
(Array.isArray(p.candidates) ? p.candidates : []).map((c) =>
|
|
253
|
+
el("tr", null, [
|
|
254
|
+
el("td", { class: "mono", text: String(c?.model ?? "—") }),
|
|
255
|
+
el("td", { class: "mono", text: String(c?.profile ?? "—") }),
|
|
256
|
+
el("td", {
|
|
257
|
+
class: "muted",
|
|
258
|
+
text: (Array.isArray(c?.tags) ? c.tags : []).join(", "),
|
|
259
|
+
}),
|
|
260
|
+
el("td", { text: c?.enabled === false ? "no" : "yes" }),
|
|
261
|
+
]),
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---- 2. per-role and per-profile spend ---------------------------------
|
|
270
|
+
const spendBody = [];
|
|
271
|
+
if (roles.length > 0) {
|
|
272
|
+
spendBody.push(
|
|
273
|
+
table(
|
|
274
|
+
["Role", "Calls", "Cost", "Share"],
|
|
275
|
+
roles.map((r) =>
|
|
276
|
+
el("tr", null, [
|
|
277
|
+
el("td", { text: r.role }),
|
|
278
|
+
el("td", { class: "num", text: fmtCount(r.calls) }),
|
|
279
|
+
el("td", { class: "num", text: fmtUsd(usdFromMicros(r.usdMicros)) }),
|
|
280
|
+
el("td", null, shareBar(r.share)),
|
|
281
|
+
]),
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (profiles.length > 0) {
|
|
287
|
+
spendBody.push(
|
|
288
|
+
table(
|
|
289
|
+
["Profile", "Calls", "Cost", "Share"],
|
|
290
|
+
profiles.map((p) =>
|
|
291
|
+
el("tr", null, [
|
|
292
|
+
el("td", { class: "mono", text: p.profile }),
|
|
293
|
+
el("td", { class: "num", text: fmtCount(p.calls) }),
|
|
294
|
+
el("td", { class: "num", text: fmtUsd(usdFromMicros(p.usdMicros)) }),
|
|
295
|
+
el("td", null, shareBar(p.share)),
|
|
296
|
+
]),
|
|
297
|
+
),
|
|
298
|
+
),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (spendBody.length === 0) {
|
|
302
|
+
spendBody.push(
|
|
303
|
+
el("p", { class: "muted", text: "no priced model calls recorded for this harness yet." }),
|
|
304
|
+
);
|
|
305
|
+
} else if (typeof body.spend?.rollups === "number" && body.spend.rollups > 0) {
|
|
306
|
+
spendBody.push(
|
|
307
|
+
el("p", {
|
|
308
|
+
class: "muted",
|
|
309
|
+
text: `${body.spend.rollups} nested-run roll-up line(s) were skipped — a sub-agent's spend is counted from the child's own session log, which is folded here too.`,
|
|
310
|
+
}),
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
root.appendChild(
|
|
314
|
+
card("Spend by role and profile", fmtUsd(usdFromMicros(body.spend?.totalUsdMicros)), spendBody),
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ---- 3. the leaderboard -------------------------------------------------
|
|
318
|
+
root.appendChild(
|
|
319
|
+
card(
|
|
320
|
+
"Leaderboard",
|
|
321
|
+
arms.length === 0 ? "no arms recorded" : `${arms.length} arm(s)`,
|
|
322
|
+
leaderboard.length === 0
|
|
323
|
+
? el("p", {
|
|
324
|
+
class: "muted",
|
|
325
|
+
text: "a pooled run folds one observation per turn into its band's arm; nothing has been observed yet.",
|
|
326
|
+
})
|
|
327
|
+
: table(
|
|
328
|
+
["Band", "Model", "n", "Reward", "Quality", "Latency", "Cost/call"],
|
|
329
|
+
leaderboard.map((row) =>
|
|
330
|
+
el("tr", { class: row?.best === true ? "row-best" : null }, [
|
|
331
|
+
el("td", {
|
|
332
|
+
class: "mono",
|
|
333
|
+
text: `${row?.band ?? "?"}${row?.shadow === true ? " (shadow)" : ""}`,
|
|
334
|
+
}),
|
|
335
|
+
el("td", {
|
|
336
|
+
class: "mono",
|
|
337
|
+
text: `${row?.model ?? "?"}${row?.best === true ? " ★" : ""}`,
|
|
338
|
+
}),
|
|
339
|
+
el("td", { class: "num", text: fmtCount(row?.n) }),
|
|
340
|
+
el("td", { class: "num", text: Number(row?.meanReward ?? 0).toFixed(3) }),
|
|
341
|
+
el("td", {
|
|
342
|
+
class: "num",
|
|
343
|
+
text: row?.qualityCount > 0 ? Number(row.meanQuality).toFixed(3) : "—",
|
|
344
|
+
}),
|
|
345
|
+
el("td", {
|
|
346
|
+
class: "num",
|
|
347
|
+
text: `${Math.round(Number(row?.meanLatencyMs ?? 0))}ms`,
|
|
348
|
+
}),
|
|
349
|
+
el("td", {
|
|
350
|
+
class: "num",
|
|
351
|
+
text: row?.meanCostUsd > 0 ? `$${Number(row.meanCostUsd).toFixed(5)}` : "—",
|
|
352
|
+
}),
|
|
353
|
+
]),
|
|
354
|
+
),
|
|
355
|
+
),
|
|
356
|
+
),
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// ---- 4. the route timeline ---------------------------------------------
|
|
360
|
+
const timelineRoot = el("div");
|
|
361
|
+
root.appendChild(
|
|
362
|
+
card(
|
|
363
|
+
"Route timeline",
|
|
364
|
+
sessions.length === 0
|
|
365
|
+
? "no session recorded a routing decision"
|
|
366
|
+
: `${sessions.length} session(s)`,
|
|
367
|
+
sessions.length === 0
|
|
368
|
+
? el("p", {
|
|
369
|
+
class: "muted",
|
|
370
|
+
text: "only runs whose spec declares a pool, tiers or a hybrid strategy persist routing lines.",
|
|
371
|
+
})
|
|
372
|
+
: [
|
|
373
|
+
el(
|
|
374
|
+
"div",
|
|
375
|
+
{ class: "chips" },
|
|
376
|
+
sessions.slice(0, 12).map((s) => {
|
|
377
|
+
const btn = el("button", {
|
|
378
|
+
class: "chip",
|
|
379
|
+
text: `${s.id} (${s.routingLines})`,
|
|
380
|
+
});
|
|
381
|
+
btn.addEventListener("click", () => {
|
|
382
|
+
void loadTimeline(timelineRoot, ctx.id, String(s.id));
|
|
383
|
+
});
|
|
384
|
+
return btn;
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
387
|
+
timelineRoot,
|
|
388
|
+
],
|
|
389
|
+
),
|
|
390
|
+
);
|
|
391
|
+
const first = sessions[0];
|
|
392
|
+
if (first !== undefined) await loadTimeline(timelineRoot, ctx.id, String(first.id));
|
|
393
|
+
|
|
394
|
+
if (body.freeze) {
|
|
395
|
+
root.appendChild(
|
|
396
|
+
collapsible(
|
|
397
|
+
[el("span", { text: "Routing is FROZEN — the learned policy is pinned" })],
|
|
398
|
+
[jsonPre(body.freeze)],
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Load and render one session's timeline into `host`. */
|
|
405
|
+
async function loadTimeline(host, id, sessionId) {
|
|
406
|
+
clear(host).appendChild(skeleton(3));
|
|
407
|
+
const data = await api.modelRoutes({ id, sess: sessionId });
|
|
408
|
+
const body = data && typeof data.body === "object" && data.body !== null ? data.body : data;
|
|
409
|
+
clear(host);
|
|
410
|
+
const entries = Array.isArray(body?.entries) ? body.entries : [];
|
|
411
|
+
if (entries.length === 0) {
|
|
412
|
+
host.appendChild(
|
|
413
|
+
el("p", { class: "muted", text: String(body?.note ?? "no routing lines in this session.") }),
|
|
414
|
+
);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
host.appendChild(
|
|
418
|
+
table(
|
|
419
|
+
["Turn", "Kind", "Detail"],
|
|
420
|
+
entries.map((entry) => {
|
|
421
|
+
const line = timelineLine(entry);
|
|
422
|
+
return el("tr", null, [
|
|
423
|
+
el("td", { class: "num", text: line.turn === null ? "—" : String(line.turn) }),
|
|
424
|
+
el("td", { class: "mono", text: line.kind }),
|
|
425
|
+
el("td", { class: "muted", text: line.detail }),
|
|
426
|
+
]);
|
|
427
|
+
}),
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -55,6 +55,7 @@ import jobsViewJs from "../assets/js/views/jobs.js" with { type: "text" };
|
|
|
55
55
|
import libraryViewJs from "../assets/js/views/library.js" with { type: "text" };
|
|
56
56
|
import memoryFabricViewJs from "../assets/js/views/memory-fabric.js" with { type: "text" };
|
|
57
57
|
import memoryViewJs from "../assets/js/views/memory.js" with { type: "text" };
|
|
58
|
+
import modelsViewJs from "../assets/js/views/models.js" with { type: "text" };
|
|
58
59
|
import onboardingViewJs from "../assets/js/views/onboarding.js" with { type: "text" };
|
|
59
60
|
import overviewViewJs from "../assets/js/views/overview.js" with { type: "text" };
|
|
60
61
|
import panesViewJs from "../assets/js/views/panes.js" with { type: "text" };
|
|
@@ -129,6 +130,7 @@ export const hangarAssets = {
|
|
|
129
130
|
"/assets/js/views/library.js": asset(libraryViewJs, "library.js"),
|
|
130
131
|
"/assets/js/views/memory-fabric.js": asset(memoryFabricViewJs, "memory-fabric.js"),
|
|
131
132
|
"/assets/js/views/memory.js": asset(memoryViewJs, "memory.js"),
|
|
133
|
+
"/assets/js/views/models.js": asset(modelsViewJs, "models.js"),
|
|
132
134
|
"/assets/js/views/onboarding.js": asset(onboardingViewJs, "onboarding.js"),
|
|
133
135
|
"/assets/js/views/overview.js": asset(overviewViewJs, "overview.js"),
|
|
134
136
|
"/assets/js/views/panes.js": asset(panesViewJs, "panes.js"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/hangar-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The Hangar web console's static UI — zero-build, dependency-free browser ES modules embedded as text assets for the single-binary CLI to hand to the hangar server",
|
|
6
6
|
"main": "dist/index.js",
|