Skip to content

Commit b657bdd

Browse files
committed
feat(admin): a Venues view, and the venue on hosts and runs (#712)
Part 2 of #712. - Each host's registry row gains `backends`, written as names and fixed words only: blessed=<venues>;floor=<property:word,...>;os=<engine>:<gateway>. The first blessed venue is the default, as PI_BACKENDS is read. The gateway word (local, remote or unknown) is the boot read, never an address. hostBackendsField and parseHostBackends are shared by the worker and admin, and the parser checks every element again. - Admin hostRowOf keeps backends and caps. venuesView builds one model from the hosts' published facts plus the static BACKENDS table, and checks nothing live. - The console's Fleet page has a Venues panel, and the /dispatch panel a VENUES view (`v` from HOSTS). Each venue shows: - the hosts that bless it, the default, and any floor; - every declared property in words, naming the observation it depends on; - what it serves, its credential custody, and the openshell engine and gateway as of the worker's start. Sentence tables (DECLARATION_TEXT, PROPERTY_LABEL, GATEWAY_TEXT) are bolted by tests. - Venue columns on the hosts tables and the runs table (a record with no venue is local), and a `backend` runs filter that accepts any venue-shaped name, so a newer worker's venue is filterable. - The fleet console fixtures and docs/images/dispatch-hosts are regenerated. Specs: INT-HOST-REGISTRY-CONTRACT (the backends field, its encoding and content rule), DES-OPERATOR-CONSOLE (the Venues view) and REQ-OPERATOR-CONSOLE-SCREENS (b, f) are amended. Deleting host:* still changes nothing. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent f204208 commit b657bdd

36 files changed

Lines changed: 873 additions & 49 deletions

‎admin/src/console/pages.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
fleetDoctor,
4141
fleetQueues,
4242
fleetSchedulers,
43+
fleetVenues,
4344
INSIGHTS_DEFAULT_RANGE,
4445
INSIGHTS_RANGES,
4546
insightsPanel,
@@ -99,7 +100,7 @@ const PAGE_PANELS = Object.freeze({
99100
run: [["p-progress", runProgress], ["p-chat", runChat], ["p-inbox", runInbox], ["p-run", runSummary], ["p-cost", runCostPanel], ["p-resources", runResources], ["p-session", runSession], ["p-chain", runChain]],
100101
parked: [["p-parked", parkedInbox]],
101102
insights: [["p-range", insightsRange], ["p-whatif", insightsWhatIf], ["p-insights", insightsPanel]],
102-
fleet: [["p-queues", fleetQueues], ["p-active", fleetActive], ["p-held", fleetHeld], ["p-hosts", fleetHosts], ["p-doctor", fleetDoctor], ["p-schedulers", fleetSchedulers], ["p-failed", fleetFailed]],
103+
fleet: [["p-queues", fleetQueues], ["p-active", fleetActive], ["p-held", fleetHeld], ["p-hosts", fleetHosts], ["p-doctor", fleetDoctor], ["p-venues", fleetVenues], ["p-schedulers", fleetSchedulers], ["p-failed", fleetFailed]],
103104
config: [["p-triggers", configTriggers], ["p-settings", configSettings], ["p-pause-windows", configPauseWindows], ["p-scoped-limits", configScopedLimits], ["p-projects", configProjects], ["p-mcp", configMcp], ["p-packages", configPackages], ["p-secret-profiles", configSecretProfiles], ["p-secret-bundles", configSecretBundles]],
104105
audit: [["p-audit", auditLog]],
105106
});

‎admin/src/console/panels.mjs‎

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { fmtCost } from "../panel.mjs";
5656
import { renderWhatIf } from "../render.mjs";
5757
import { recordedProgress } from "@edgehero/pi-dispatch/run-history";
5858
import { UNATTRIBUTED_BACKEND } from "@edgehero/pi-dispatch/backends";
59-
import { BUNDLE_SKIPPED_TEXT, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, VENUE_MAY_NOT_STREAM, liveAbsentCause, progressAbsentText, recordSkips, sandboxShellRefusal, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf } from "../read-model.mjs";
59+
import { BUNDLE_SKIPPED_TEXT, GATEWAY_TEXT, OPENSHELL_UNPUBLISHED_TEXT, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, VENUE_MAY_NOT_STREAM, venuesView, liveAbsentCause, progressAbsentText, recordSkips, sandboxShellRefusal, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf } from "../read-model.mjs";
6060
import { RUN_FILTER_FIELDS } from "../run-filter.mjs";
6161
import { sessionKeyOfRecord } from "@edgehero/pi-dispatch/session-key";
6262
import { parkedVerdictOf } from "@edgehero/pi-dispatch/chat-mode";
@@ -281,12 +281,23 @@ function beatCell(staleMs) {
281281
return staleMs > MIN ? html`<td class="warn">${ageText(staleMs)} ago</td>` : html`<td>${ageText(staleMs)} ago</td>`;
282282
}
283283

284+
/**
285+
* A host's blessed venues, compact (issue #712): `local (default), openshell`, the first the default as PI_BACKENDS reads
286+
* it; "not published" for a worker from before the registry's `backends` field, never a guessed `local`.
287+
*/
288+
function blessedText(h) {
289+
const blessed = Array.isArray(h?.backends?.blessed) ? h.backends.blessed : null;
290+
if (blessed === null) return html`<span class="note">not published</span>`;
291+
if (blessed.length === 0) return "none";
292+
return blessed.map((n, i) => (i === 0 ? `${n} (default)` : n)).join(", ");
293+
}
294+
284295
export const overviewHosts = fromSnapshot("Hosts online", (s) => {
285296
const hosts = Array.isArray(s.hostBudgets) ? s.hostBudgets : [];
286297
return table(
287298
"Hosts online",
288-
["Host", { num: "Slots" }, { num: "Running" }, "Last beat"],
289-
hosts.map((h) => html`<tr><td>${cell(h.name)}</td><td class="num">${cell(h.concurrency)}</td><td class="num">${Array.isArray(h.jobs) ? h.jobs.filter((j) => !j.o).length : "unknown"}</td>${beatCell(h.staleMs)}</tr>`),
299+
["Host", { num: "Slots" }, { num: "Running" }, "Venues", "Last beat"],
300+
hosts.map((h) => html`<tr><td>${cell(h.name)}</td><td class="num">${cell(h.concurrency)}</td><td class="num">${Array.isArray(h.jobs) ? h.jobs.filter((j) => !j.o).length : "unknown"}</td><td>${blessedText(h)}</td>${beatCell(h.staleMs)}</tr>`),
290301
"No host is registered. A worker registers when it starts.",
291302
);
292303
});
@@ -385,7 +396,7 @@ export const overviewActive = fromSnapshot("Active jobs", (s, view) => activeTab
385396
// ---- runs ------------------------------------------------------------------------------------------------------------
386397

387398
/** What each filter field is called on the form. */
388-
const FILTER_LABELS = Object.freeze({ repo: "Repository", kind: "Forge", flow: "Flow", trigger: "Trigger", project: "Project", host: "Host", outcome: "Outcome", reason: "Reason", model: "Model", q: "Target contains" });
399+
const FILTER_LABELS = Object.freeze({ repo: "Repository", kind: "Forge", flow: "Flow", trigger: "Trigger", project: "Project", host: "Host", backend: "Venue", outcome: "Outcome", reason: "Reason", model: "Model", q: "Target contains" });
389400

390401
/** The runs filter, a plain GET form: it works without the script, and its values are the query's, escaped. */
391402
export function runsFilter(view) {
@@ -406,12 +417,20 @@ function runsHref(filter, extra = {}) {
406417
return qs === "" ? "/runs" : `/runs?${qs}`;
407418
}
408419

420+
/**
421+
* The venue a run used (issue #712): its record's `backend`, or `local` for a record that names none, which was written
422+
* before venues were recorded (`UNATTRIBUTED_BACKEND`, the worker's own reading, and the runs filter's).
423+
*/
424+
function runVenue(r) {
425+
return typeof r?.backend === "string" && r.backend !== "" ? r.backend : UNATTRIBUTED_BACKEND;
426+
}
427+
409428
/** One run as a table row. */
410429
function runRow(r, env, subscriptions) {
411430
const at = msOf(r.endedAt ?? r.startedAt);
412431
const outcome = [r.outcome, r.reason].filter((x) => typeof x === "string" && x !== "").join(" / ");
413432
const failed = r.outcome === "failed";
414-
return html`<tr><td>${utcText(at)}</td><td>${runLink(r.jobId)}</td><td>${targetLink(r, env)}</td><td>${cell(r.flow)}</td><td>${cell(r.host)}</td><td${failed ? html` class="bad"` : null}>${cell(outcome)}</td><td>${cell(r.model)}</td><td class="num">${Number.isFinite(r.tokens?.total) ? count(r.tokens.total) : "-"}</td><td class="num">${costText(runCost(r, subscriptions))}</td><td class="num">${usdText(capMicrosOf(r))}</td></tr>`;
433+
return html`<tr><td>${utcText(at)}</td><td>${runLink(r.jobId)}</td><td>${targetLink(r, env)}</td><td>${cell(r.flow)}</td><td>${cell(r.host)}</td><td>${runVenue(r)}</td><td${failed ? html` class="bad"` : null}>${cell(outcome)}</td><td>${cell(r.model)}</td><td class="num">${Number.isFinite(r.tokens?.total) ? count(r.tokens.total) : "-"}</td><td class="num">${costText(runCost(r, subscriptions))}</td><td class="num">${usdText(capMicrosOf(r))}</td></tr>`;
415434
}
416435

417436
export function runsTable(view) {
@@ -429,7 +448,7 @@ export function runsTable(view) {
429448
const pager = html`<div class="pager">${view.query?.before !== undefined && view.query?.before !== null ? html`<a href="${runsHref(filter)}">Newest runs</a>` : null}${page.next !== null && page.next !== undefined ? html`<a href="${runsHref(filter, { before: page.next })}">Older runs</a>` : null}</div>`;
430449
return titled(
431450
"Runs",
432-
html`${notes}${table("Runs", ["When", "Job", "Target", "Flow", "Host", "Outcome", "Model", { num: "Tokens" }, { num: "Cost" }, { num: "Job cap" }], runs.map((r) => runRow(r, view.env, subs)), "No run matches.")}${pager}`,
451+
html`${notes}${table("Runs", ["When", "Job", "Target", "Flow", "Host", "Venue", "Outcome", "Model", { num: "Tokens" }, { num: "Cost" }, { num: "Job cap" }], runs.map((r) => runRow(r, view.env, subs)), "No run matches.")}${pager}`,
433452
);
434453
}
435454

@@ -1161,11 +1180,11 @@ export const fleetHosts = fromSnapshot("Hosts", (s, view) => {
11611180
const hosts = Array.isArray(s.hostBudgets) ? s.hostBudgets : [];
11621181
return table(
11631182
"Hosts",
1164-
["Host", { num: "Slots" }, "Memory", "CPU (centi)", { num: "Waiting" }, "Running jobs", "Routes", "Last beat"],
1183+
["Host", { num: "Slots" }, "Memory", "CPU (centi)", { num: "Waiting" }, "Running jobs", "Venues", "Routes", "Last beat"],
11651184
hosts.map((h) => {
11661185
const jobs = Array.isArray(h.jobs) ? h.jobs : null;
11671186
const list = jobs === null ? (h.jobsUnreadable ? "unreadable" : "-") : jobs.length === 0 ? "none" : html`${jobs.map((j, i) => html`${i > 0 ? ", " : ""}${runLink(j.id)}${j.o ? " (orphaned hold)" : ""}${j.at ? html` <span class="note">${ageText(view.now - j.at)}</span>` : null}`)}${h.jobsMore > 0 ? ` and ${h.jobsMore} more` : ""}`;
1168-
return html`<tr><td>${cell(h.name)}</td><td class="num">${cell(h.concurrency)}</td><td>${budgetText(h.usedMemMiB, h.budgetMemMiB, "MiB")}</td><td>${budgetText(h.usedCpuCenti, h.budgetCpuCenti, null)}</td><td class="num">${cell(h.waiters)}</td><td>${list}</td><td>${h.routes === false || h.routes === "false" ? "no" : "yes"}</td>${beatCell(h.staleMs)}</tr>`;
1187+
return html`<tr><td>${cell(h.name)}</td><td class="num">${cell(h.concurrency)}</td><td>${budgetText(h.usedMemMiB, h.budgetMemMiB, "MiB")}</td><td>${budgetText(h.usedCpuCenti, h.budgetCpuCenti, null)}</td><td class="num">${cell(h.waiters)}</td><td>${list}</td><td>${blessedText(h)}</td><td>${h.routes === false || h.routes === "false" ? "no" : "yes"}</td>${beatCell(h.staleMs)}</tr>`;
11691188
}),
11701189
"No host is registered.",
11711190
);
@@ -1207,6 +1226,42 @@ export const fleetDoctor = fromSnapshot("Doctor", (s, view) => {
12071226
return html`${rows.length === 0 ? html`<p class="note">No host has published doctor results yet. Each worker runs doctor a minute after it starts and then every PI_DOCTOR_INTERVAL_HOURS (24 by default), and pi-dispatch doctor publishes too.</p>` : rows.map(block)}${silent.length > 0 ? html`<p class="note">No doctor result published by: ${silent.join(", ")}.</p>` : null}${d.more > 0 ? html`<p class="note">${count(d.more)} more hosts published results; not every one is read.</p>` : null}`;
12081227
});
12091228

1229+
/**
1230+
* The Venues panel (issue #712, `DES-OPERATOR-CONSOLE`): every venue this build's table holds, with which live hosts
1231+
* bless it and default to it (their registry rows' `backends` field), each declared property in words (its question,
1232+
* the word and what the word means, the switch or the observation it holds under, and which hosts' floors ask for it),
1233+
* what it serves and who holds its credentials, and per host the OpenShell engine, modes and gateway word. The hosts'
1234+
* published facts plus the static table (`venuesView`); nothing is resolved live, so a word is the DECLARED one.
1235+
*/
1236+
export const fleetVenues = fromSnapshot("Venues", (s) => {
1237+
const v = venuesView(Array.isArray(s.hostBudgets) ? s.hostBudgets : []);
1238+
const names = (list) => (list.length === 0 ? "no live host" : list.join(", "));
1239+
const floorText = (floor) => Object.entries(floor ?? {}).map(([p, w]) => `${p}=${w}`).join(", ");
1240+
const means = (p) => html`${p.wordText}${p.armedBy ? html`<br><span class="note">Only while ${p.armedBy} is on.</span>` : null}${p.observedText ? html`<br><span class="note">Holds on a host where ${p.observedText}; elsewhere it counts as asserted.</span>` : null}`;
1241+
const floored = (p) => (p.flooredOn.length === 0 ? "-" : p.flooredOn.map((f) => `${f.want} on ${f.host}`).join(", "));
1242+
const venue = (x) => {
1243+
const summary = `${x.name}: ${x.blessedOn.length === 0 ? "blessed on no live host" : `blessed on ${x.blessedOn.join(", ")}`}${x.defaultOn.length > 0 ? `, the default on ${x.defaultOn.join(", ")}` : ""}`;
1244+
const openshell = x.openshell && x.openshell.length > 0
1245+
? table(`${x.name} OpenShell setup`, ["Host", "Engine", "Modes", "Gateway"], x.openshell.map((o) => (o.unpublished ? html`<tr><td>${cell(o.host)}</td><td colspan="3">${OPENSHELL_UNPUBLISHED_TEXT}</td></tr>` : html`<tr><td>${cell(o.host)}</td><td>${cell(o.engine)}</td><td>${o.modes.length > 0 ? o.modes.join(", ") : "-"}</td><td>${GATEWAY_TEXT[o.gateway] ?? GATEWAY_TEXT.unknown}</td></tr>`)), "")
1246+
: null;
1247+
return html`<details><summary>${summary}</summary><p>Runs in ${x.describe}.</p>${facts([
1248+
["Blessed on", names(x.blessedOn)],
1249+
["Default on", names(x.defaultOn)],
1250+
["Credentials", x.custody === "layer" ? html`held by the venue's egress layer: ${x.custodyText}` : html`in the job's container: ${x.custodyText}`],
1251+
])}<ul class="plain">${x.serves.map((sv) => html`<li>It ${sv.text ?? `${sv.key}: ${sv.word}`}.</li>`)}</ul>${openshell}${table(
1252+
`${x.name} properties`,
1253+
["Property", "Word", "What the word means", "Asked for by a floor"],
1254+
x.properties.map((p) => html`<tr><td>${p.question}<br><span class="note mono">${p.property}</span></td><td>${p.word}</td><td>${means(p)}</td><td>${floored(p)}</td></tr>`),
1255+
"",
1256+
)}</details>`;
1257+
};
1258+
return html`<p class="note">What each venue declares is this build's table; which hosts bless it, their floors and their OpenShell setup are what each host's worker published when it started. Nothing here is read live from a host.</p>${
1259+
v.unpublished.length > 0 ? html`<p class="note">Not published by ${v.unpublished.join(", ")} (a worker older than this console).</p>` : null
1260+
}${v.floors.length > 0 ? table("Floors", ["Host", "PI_BACKEND_FLOOR"], v.floors.map((f) => html`<tr><td>${cell(f.host)}</td><td class="mono">${floorText(f.floor)}</td></tr>`), "") : html`<p class="note">No live host published a floor (PI_BACKEND_FLOOR).</p>`}${v.venues.map(venue)}${
1261+
v.others.length > 0 ? html`<p class="warn">Blessed by a host and not in this console's table: ${v.others.map((o) => `${o.name} (${o.blessedOn.join(", ")})`).join("; ")}.</p>` : null
1262+
}`;
1263+
});
1264+
12101265
export const fleetSchedulers = fromSnapshot("Schedules", (s, view) => {
12111266
const list = Array.isArray(s.schedulers) ? s.schedulers : [];
12121267
const stalls = s.schedulerStalls ?? {};

0 commit comments

Comments
 (0)