Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293e608ca4 | ||
|
|
df3058e311 | ||
|
|
ec3ca216c5 | ||
|
|
55705f17d3 | ||
|
|
4e06a9682d | ||
|
|
aa8bf11c8b | ||
|
|
aa6e422b78 | ||
|
|
bee165ebc7 | ||
|
|
e2fdaae841 | ||
|
|
1255268f4f | ||
|
|
302738362a | ||
|
|
7ade5f27ea | ||
|
|
09cb705738 | ||
|
|
fdf05c1673 |
29 changed files with 2367 additions and 2065 deletions
|
|
@ -52,7 +52,7 @@ mkdirSync(staticDir(''), { recursive: true });
|
||||||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||||
// to `static/<name>.js` based on the entryPoint basename.
|
// to `static/<name>.js` based on the entryPoint basename.
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js')],
|
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('settings.js'), src('stats.js')],
|
||||||
outdir: staticDir(''),
|
outdir: staticDir(''),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
|
|
@ -91,7 +91,7 @@ await build({
|
||||||
// so a swap replaces only it) + theme.css (the semantic derivation
|
// so a swap replaces only it) + theme.css (the semantic derivation
|
||||||
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
||||||
// panel) plus its own page-specific bundle.
|
// panel) plus its own page-specific bundle.
|
||||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css']) {
|
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'settings.css', 'stats.css']) {
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src(entry)],
|
entryPoints: [src(entry)],
|
||||||
outfile: staticDir(entry),
|
outfile: staticDir(entry),
|
||||||
|
|
@ -101,7 +101,7 @@ for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', '
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html']) {
|
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'settings.html', 'stats.html']) {
|
||||||
copyFileSync(src(html), dist(html));
|
copyFileSync(src(html), dist(html));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,16 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||||
padding: 0.2em 0.5em;
|
padding: 0.2em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── page-content gutter ─────────────────────────────────────────────
|
||||||
|
Inverted-gutter layout: the dashboard + H0M3 keep `<body>` full-bleed
|
||||||
|
and wrap their padded content in a single `.page-content` div that
|
||||||
|
carries the horizontal gutter. Full-width chrome (the tab strip, the
|
||||||
|
server-warnings banner, the footer) then spans edge-to-edge for free,
|
||||||
|
with no negative-margin breakout hacks. Only matches on pages that opt
|
||||||
|
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
|
||||||
|
ST4TS / S3TT1NGS are full-bleed with their own `.<page>-main` padding. */
|
||||||
|
.page-content { padding: 0 1.5em; }
|
||||||
|
|
||||||
/* ─── server warnings banner ──────────────────────────────────────────
|
/* ─── server warnings banner ──────────────────────────────────────────
|
||||||
Generic top-of-page strip, injected at the top of <body> by
|
Generic top-of-page strip, injected at the top of <body> by
|
||||||
common.js (renderServerWarnings) on every page. One row per warning;
|
common.js (renderServerWarnings) on every page. One row per warning;
|
||||||
|
|
@ -530,16 +540,11 @@ body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
/* The bar is prepended as a direct child of <body>. On pages that pad
|
/* The bar is prepended as a direct child of <body> and spans the full
|
||||||
the body gutter (the dashboard + H0M3 use a 1.5em horizontal gutter),
|
width edge-to-edge. The dashboard + H0M3 are full-bleed at the body
|
||||||
break out of that padding so the warning spans the full width
|
level — their 1.5em horizontal gutter lives on an inner `.page-content`
|
||||||
edge-to-edge — the same trick `.dashboard-chrome` uses. FL0W + L0GS
|
wrapper (see below), so the banner is full-width for free with no
|
||||||
are already full-bleed (no body padding), so they need no override. */
|
breakout. FL0W + L0GS are full-bleed too. */
|
||||||
body.dashboard-shell #server-warnings,
|
|
||||||
body.home-shell #server-warnings {
|
|
||||||
margin-left: -1.5em;
|
|
||||||
margin-right: -1.5em;
|
|
||||||
}
|
|
||||||
.server-warnings[hidden] { display: none; }
|
.server-warnings[hidden] { display: none; }
|
||||||
.server-warn {
|
.server-warn {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -558,3 +563,31 @@ body.home-shell #server-warnings {
|
||||||
background: color-mix(in srgb, var(--red) 18%, transparent);
|
background: color-mix(in srgb, var(--red) 18%, transparent);
|
||||||
border-bottom: 1px solid var(--red);
|
border-bottom: 1px solid var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── stats table ─────────────────────────────────────────────────
|
||||||
|
Shared right-aligned numeric table. Used by /stats.html (busiest
|
||||||
|
agents) and the dashboard SYST3M › C0NT41N3R L04D table — moved here
|
||||||
|
from dashboard.css when ST4TS became its own page. */
|
||||||
|
.hive-stats-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.hive-stats-table th,
|
||||||
|
.hive-stats-table td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 5px 8px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.hive-stats-table th:first-child,
|
||||||
|
.hive-stats-table td:first-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.hive-stats-table th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.hive-stats-table td.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,15 @@
|
||||||
routing model. */
|
routing model. */
|
||||||
|
|
||||||
body.dashboard-shell {
|
body.dashboard-shell {
|
||||||
/* Full-width layout — no max-width cap so wide screens don't
|
/* Full-bleed body (no horizontal gutter). The 1.5em side gutter lives
|
||||||
waste real estate on empty side margins. `padding: 0 1.5em
|
on the inner `.page-content` wrapper (see common.css), so the sticky
|
||||||
1.5em` keeps a small gutter on the left/right so cards don't
|
chrome bar, the server-warnings banner, and the footer span the full
|
||||||
kiss the viewport edge; `.dashboard-chrome { margin: 0 -1.5em
|
width edge-to-edge with no negative-margin breakout. `padding-bottom`
|
||||||
... }` still pulls the chrome bar edge-to-edge through that
|
keeps a little breathing room above the viewport edge (overridden to
|
||||||
gutter. */
|
clear the selection bar via `.has-selection` below). No max-width cap
|
||||||
|
— wide screens use the full width rather than empty side margins. */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1.5em 1.5em;
|
padding-bottom: 1.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-chrome {
|
.dashboard-chrome {
|
||||||
|
|
@ -28,7 +29,7 @@ body.dashboard-shell {
|
||||||
backdrop-filter: blur(8px) saturate(120%);
|
backdrop-filter: blur(8px) saturate(120%);
|
||||||
border-bottom: 1px solid var(--purple-dim);
|
border-bottom: 1px solid var(--purple-dim);
|
||||||
padding: 0.4em 0 0;
|
padding: 0.4em 0 0;
|
||||||
margin: 0 -1.5em 1em;
|
margin: 0 0 1em;
|
||||||
}
|
}
|
||||||
/* ← home back-link to the H0M3 hub. Small purple link top-left of the
|
/* ← home back-link to the H0M3 hub. Small purple link top-left of the
|
||||||
chrome, mirroring the .logs-back treatment the sub-pages use so the
|
chrome, mirroring the .logs-back treatment the sub-pages use so the
|
||||||
|
|
@ -1392,97 +1393,14 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── ST4TS tab: hive-wide turn-stats rollup ──────────────────────────────
|
/* ST4TS moved to its own page (`/stats.html`): the
|
||||||
Plain tables + CSS bars (no chart lib in the dashboard bundle). */
|
window selector / summary chips / bars live in stats.css, and the
|
||||||
.hive-stats-windows {
|
shared `.hive-stats-table` moved to common.css (the SYST3M ›
|
||||||
display: flex;
|
C0NT41N3R L04D table below still uses it). */
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 6px;
|
|
||||||
margin: 12px 0;
|
|
||||||
}
|
|
||||||
.hive-stats-windows .btn.active {
|
|
||||||
border-color: var(--amber);
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.hive-stats-chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.hive-stats-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 6px 10px;
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 4px;
|
|
||||||
min-width: 7em;
|
|
||||||
}
|
|
||||||
.hive-stats-chip .k {
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: lowercase;
|
|
||||||
}
|
|
||||||
.hive-stats-chip .v {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.hive-stats-chip.est .v {
|
|
||||||
color: var(--amber);
|
|
||||||
}
|
|
||||||
.hive-stats-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.hive-stats-table th,
|
|
||||||
.hive-stats-table td {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
padding: 5px 8px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.hive-stats-table th:first-child,
|
|
||||||
.hive-stats-table td:first-child {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.hive-stats-table th {
|
|
||||||
color: var(--muted);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.hive-stats-table td.num {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.hive-stats-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 12em 1fr 4em;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin: 3px 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.hive-stats-bar .track {
|
|
||||||
background: var(--bg-elev);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 3px;
|
|
||||||
height: 14px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.hive-stats-bar .fill {
|
|
||||||
display: block;
|
|
||||||
height: 100%;
|
|
||||||
background: var(--purple);
|
|
||||||
}
|
|
||||||
.hive-stats-bar .cnt {
|
|
||||||
text-align: right;
|
|
||||||
color: var(--muted);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── SYST3M › C0NT41N3R L04D: live cgroup cpu/mem ─────────────────────────────
|
/* ─── SYST3M › C0NT41N3R L04D: live cgroup cpu/mem ─────────────────────────────
|
||||||
Reuses the `.hive-stats-table` styling from the ST4TS tab; only the
|
Reuses the shared `.hive-stats-table` styling (now in common.css);
|
||||||
inline meter bar is new. */
|
only the inline meter bar is new. */
|
||||||
.cload-meter {
|
.cload-meter {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6em;
|
width: 6em;
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,9 @@
|
||||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- ST4TS: hive-wide turn-stats rollup (swarm totals, busiest
|
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
|
||||||
agents, model mix, est cost). Fetched on tab activation +
|
the H0M3 hub — the hive-wide rollup is a standalone read-only
|
||||||
window change from GET /api/stats-hive. -->
|
view, not part of the operational tab strip. -->
|
||||||
<a class="tab" id="tab-stats" href="#stats" role="tab"
|
|
||||||
aria-controls="tab-pane-stats"
|
|
||||||
data-tab="stats">
|
|
||||||
<span class="tab-label">◆ ST4TS ◆</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- (Peer hives are no longer a tab — they render as a headline
|
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||||
under SW4RM. See tab-pane-swarm.) -->
|
under SW4RM. See tab-pane-swarm.) -->
|
||||||
|
|
@ -88,6 +83,10 @@
|
||||||
visible by toggling the `hidden` attribute, resolved from the URL
|
visible by toggling the `hidden` attribute, resolved from the URL
|
||||||
hash (default SW4RM). Panes start `hidden` to avoid a flash before
|
hash (default SW4RM). Panes start `hidden` to avoid a flash before
|
||||||
the script runs. -->
|
the script runs. -->
|
||||||
|
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||||
|
(.page-content, common.css) while <body> stays full-bleed so the
|
||||||
|
sticky chrome above + the footer below span the full width. -->
|
||||||
|
<div class="page-content">
|
||||||
<main class="dashboard-main">
|
<main class="dashboard-main">
|
||||||
|
|
||||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||||
|
|
@ -241,35 +240,11 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ST4TS: hive-wide turn-stats aggregate. Plain tables/bars (the
|
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
|
||||||
dashboard bundle has no chart lib); data from GET
|
(`/stats.html`), reached from the H0M3 hub. The markup + the
|
||||||
/api/stats-hive?window=, fetched on tab activation + on
|
hive-stats render JS moved there; when tabs.js boots on the
|
||||||
window change. Per-agent trend charts stay on each agent's
|
dashboard the renderers are simply gone (no stats tab to
|
||||||
own /stats page. -->
|
activate). -->
|
||||||
<section class="tab-pane" id="tab-pane-stats" data-tab-pane="stats" hidden
|
|
||||||
role="tabpanel" aria-labelledby="tab-stats">
|
|
||||||
<h2>◆ ST4TS ◆</h2>
|
|
||||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
||||||
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
|
|
||||||
<div class="hive-stats-windows" id="hive-stats-windows">
|
|
||||||
<button type="button" class="btn" data-w="1h">1h</button>
|
|
||||||
<button type="button" class="btn" data-w="4h">4h</button>
|
|
||||||
<button type="button" class="btn active" data-w="24h">24h</button>
|
|
||||||
<button type="button" class="btn" data-w="3d">3d</button>
|
|
||||||
<button type="button" class="btn" data-w="7d">7d</button>
|
|
||||||
<button type="button" class="btn" data-w="30d">30d</button>
|
|
||||||
</div>
|
|
||||||
<div class="hive-stats-chips" id="hive-stats-summary"></div>
|
|
||||||
<h3>◇ busiest agents</h3>
|
|
||||||
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
|
||||||
<h3>◇ model mix (turns across the swarm)</h3>
|
|
||||||
<div id="hive-stats-models"></div>
|
|
||||||
<!-- "favorite tools": most-run bash commands across the swarm.
|
|
||||||
Header + list hidden until the hive-bash-mcp capture has
|
|
||||||
recorded data, so the section never shows an empty block. -->
|
|
||||||
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
|
|
||||||
<div id="hive-stats-bash" hidden></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||||
message-flow + inbox + compose DOM only exists there — when
|
message-flow + inbox + compose DOM only exists there — when
|
||||||
|
|
@ -283,6 +258,7 @@
|
||||||
localStorage + browser permission the settings page sets). -->
|
localStorage + browser permission the settings page sets). -->
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
</div><!-- /.page-content -->
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
|
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@
|
||||||
styling can fold into it. */
|
styling can fold into it. */
|
||||||
|
|
||||||
body.home-shell {
|
body.home-shell {
|
||||||
|
/* Full-bleed body; the 1.5em side gutter lives on the inner
|
||||||
|
`.page-content` wrapper (common.css) so the server-warnings banner
|
||||||
|
spans full width. `padding-bottom` keeps breathing room at the foot. */
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1.5em 2em;
|
padding-bottom: 2em;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,10 @@
|
||||||
surface. This is the page served at `/` — the landing page — with
|
surface. This is the page served at `/` — the landing page — with
|
||||||
the dashboard relocated to /dashboard.html. No tabbar / SSE —
|
the dashboard relocated to /dashboard.html. No tabbar / SSE —
|
||||||
it's a static portal. -->
|
it's a static portal. -->
|
||||||
|
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||||
|
(.page-content, common.css) while <body> stays full-bleed so the
|
||||||
|
server-warnings banner spans the full width. -->
|
||||||
|
<div class="page-content">
|
||||||
<header class="home-header">
|
<header class="home-header">
|
||||||
<p class="banner-thin" id="hive-identity" hidden></p>
|
<p class="banner-thin" id="hive-identity" hidden></p>
|
||||||
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre>
|
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre>
|
||||||
|
|
@ -26,7 +30,7 @@
|
||||||
|
|
||||||
<a class="home-tile" href="/dashboard.html">
|
<a class="home-tile" href="/dashboard.html">
|
||||||
<span class="home-tile-label">Dashboard</span>
|
<span class="home-tile-label">Dashboard</span>
|
||||||
<span class="home-tile-desc">containers · approvals · system · stats</span>
|
<span class="home-tile-desc">containers · approvals · permissions · schedules · system</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a class="home-tile" href="/flow.html">
|
<a class="home-tile" href="/flow.html">
|
||||||
|
|
@ -39,6 +43,11 @@
|
||||||
<span class="home-tile-desc">build · agent · system logs</span>
|
<span class="home-tile-desc">build · agent · system logs</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="home-tile" href="/stats.html">
|
||||||
|
<span class="home-tile-label">Stats</span>
|
||||||
|
<span class="home-tile-desc">hive-wide turn stats · cost · model mix</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="home-tile" href="/settings.html">
|
<a class="home-tile" href="/settings.html">
|
||||||
<span class="home-tile-label">Settings</span>
|
<span class="home-tile-label">Settings</span>
|
||||||
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
|
<span class="home-tile-desc">operator-local prefs · browser notifications</span>
|
||||||
|
|
@ -54,6 +63,7 @@
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
</main>
|
</main>
|
||||||
|
</div><!-- /.page-content -->
|
||||||
|
|
||||||
<script type="module" src="/static/home.js" defer></script>
|
<script type="module" src="/static/home.js" defer></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
84
frontend/packages/dashboard/src/stats.css
Normal file
84
frontend/packages/dashboard/src/stats.css
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
|
||||||
|
Extracted from the dashboard ST4TS tab. Same minimal-
|
||||||
|
chrome pattern as /flow.html and /logs.html: the back-link header +
|
||||||
|
title (.logs-header / .logs-back / .logs-title) live in common.css.
|
||||||
|
`.hive-stats-table` also lives in common.css — it's shared with the
|
||||||
|
dashboard SYST3M › C0NT41N3R L04D table. Only the ST4TS-specific
|
||||||
|
window selector, summary chips, and bars are page-specific. */
|
||||||
|
|
||||||
|
body.stats-shell {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-main {
|
||||||
|
padding: 1.2em 1.5em 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Window selector. Buttons keep the shared `.btn` pill look; the active
|
||||||
|
one is flagged by createTabStrip via `.hive-tab--active` (was a bespoke
|
||||||
|
`.active` toggle). */
|
||||||
|
.hive-stats-windows {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.hive-stats-windows .btn.hive-tab--active {
|
||||||
|
border-color: var(--amber);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hive-stats-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.hive-stats-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 7em;
|
||||||
|
}
|
||||||
|
.hive-stats-chip .k {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.hive-stats-chip .v {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.hive-stats-chip.est .v {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hive-stats-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12em 1fr 4em;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.hive-stats-bar .track {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
height: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hive-stats-bar .fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--purple);
|
||||||
|
}
|
||||||
|
.hive-stats-bar .cnt {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
53
frontend/packages/dashboard/src/stats.html
Normal file
53
frontend/packages/dashboard/src/stats.html
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>hyperhive // ST4TS</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/static/colors.css">
|
||||||
|
<link rel="stylesheet" href="/static/theme.css">
|
||||||
|
<link rel="stylesheet" href="/static/common.css">
|
||||||
|
<link rel="stylesheet" href="/static/stats.css">
|
||||||
|
</head>
|
||||||
|
<body class="stats-shell">
|
||||||
|
|
||||||
|
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
|
||||||
|
logs.html — no full dashboard tabbar. Back link points to the
|
||||||
|
H0M3 hub (served at /), not the dashboard. -->
|
||||||
|
<header class="logs-header">
|
||||||
|
<a class="logs-back" href="/">← home</a>
|
||||||
|
<span class="logs-title">ST4TS</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="stats-main">
|
||||||
|
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
|
||||||
|
|
||||||
|
<!-- Window selector — a hash-routed createTabStrip (#1h / #24h / …),
|
||||||
|
like the per-agent /stats page. Buttons keep the `.btn` pill
|
||||||
|
look; createTabStrip toggles `.hive-tab--active` and the page
|
||||||
|
re-fetches on change via onShow. -->
|
||||||
|
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
|
||||||
|
<button type="button" class="btn" data-tab="1h">1h</button>
|
||||||
|
<button type="button" class="btn" data-tab="4h">4h</button>
|
||||||
|
<button type="button" class="btn" data-tab="24h">24h</button>
|
||||||
|
<button type="button" class="btn" data-tab="3d">3d</button>
|
||||||
|
<button type="button" class="btn" data-tab="7d">7d</button>
|
||||||
|
<button type="button" class="btn" data-tab="30d">30d</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="hive-stats-chips" id="hive-stats-summary"></div>
|
||||||
|
<h3>◇ busiest agents</h3>
|
||||||
|
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
||||||
|
<h3>◇ model mix (turns across the swarm)</h3>
|
||||||
|
<div id="hive-stats-models"></div>
|
||||||
|
<!-- "favorite tools": most-run bash commands across the swarm.
|
||||||
|
Header + list hidden until the hive-bash-mcp capture has
|
||||||
|
recorded data, so the section never shows an empty block. -->
|
||||||
|
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
|
||||||
|
<div id="hive-stats-bash" hidden></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/static/stats.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
frontend/packages/dashboard/src/stats.js
Normal file
167
frontend/packages/dashboard/src/stats.js
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
// /stats.html — hive-wide turn-stats rollup.
|
||||||
|
//
|
||||||
|
// Extracted from the dashboard ST4TS tab. Pull-only (no SSE): fetched
|
||||||
|
// from /api/stats-hive on load and on window change. Plain tables/bars —
|
||||||
|
// this bundle has no chart lib; per-agent trend charts live on each
|
||||||
|
// agent's own /stats page. The window selector is a hash-routed
|
||||||
|
// createTabStrip (#1h / #24h / …), matching the per-agent stats page.
|
||||||
|
import { $, initServerWarnings } from './common.js';
|
||||||
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
|
|
||||||
|
let hiveStatsWindow = '24h';
|
||||||
|
|
||||||
|
function hsFmtInt(n) {
|
||||||
|
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
|
||||||
|
}
|
||||||
|
function hsFmtTokens(n) {
|
||||||
|
if (!Number.isFinite(n)) return '0';
|
||||||
|
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||||
|
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||||
|
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||||
|
return String(Math.round(n));
|
||||||
|
}
|
||||||
|
function hsFmtUsd(n) {
|
||||||
|
if (!Number.isFinite(n)) return '$0';
|
||||||
|
if (n >= 100) return '$' + n.toFixed(0);
|
||||||
|
if (n >= 1) return '$' + n.toFixed(2);
|
||||||
|
return '$' + n.toFixed(3);
|
||||||
|
}
|
||||||
|
function hsChip(parent, label, value, est) {
|
||||||
|
const c = document.createElement('span');
|
||||||
|
c.className = 'hive-stats-chip' + (est ? ' est' : '');
|
||||||
|
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
|
||||||
|
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
|
||||||
|
c.append(k, v);
|
||||||
|
parent.append(c);
|
||||||
|
}
|
||||||
|
function hsMeta(parent, text) {
|
||||||
|
parent.replaceChildren();
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = 'meta';
|
||||||
|
p.textContent = text;
|
||||||
|
parent.append(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHiveStats(s) {
|
||||||
|
const sum = $('hive-stats-summary');
|
||||||
|
if (sum) {
|
||||||
|
sum.replaceChildren();
|
||||||
|
hsChip(sum, 'window', s.window);
|
||||||
|
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
|
||||||
|
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
|
||||||
|
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
||||||
|
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
|
||||||
|
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
|
||||||
|
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
|
||||||
|
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
|
||||||
|
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
|
||||||
|
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = $('hive-stats-agents');
|
||||||
|
if (at) {
|
||||||
|
const agents = s.agents || [];
|
||||||
|
if (!agents.length) {
|
||||||
|
hsMeta(at, 'no turns in window');
|
||||||
|
} else {
|
||||||
|
at.replaceChildren();
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.className = 'hive-stats-table';
|
||||||
|
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
|
||||||
|
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
|
||||||
|
const tb = document.createElement('tbody');
|
||||||
|
for (const a of agents) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
const cells = [
|
||||||
|
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
|
||||||
|
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
|
||||||
|
hsFmtUsd(a.est_cost_usd),
|
||||||
|
];
|
||||||
|
cells.forEach((txt, i) => {
|
||||||
|
const td = document.createElement('td');
|
||||||
|
if (i > 0) td.className = 'num';
|
||||||
|
td.textContent = txt;
|
||||||
|
tr.append(td);
|
||||||
|
});
|
||||||
|
tb.append(tr);
|
||||||
|
}
|
||||||
|
table.append(tb);
|
||||||
|
at.append(table);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mm = $('hive-stats-models');
|
||||||
|
if (mm) {
|
||||||
|
const mix = s.model_mix || [];
|
||||||
|
if (!mix.length) {
|
||||||
|
hsMeta(mm, 'no turns in window');
|
||||||
|
} else {
|
||||||
|
mm.replaceChildren();
|
||||||
|
const max = mix[0].count || 1;
|
||||||
|
for (const kc of mix) {
|
||||||
|
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
||||||
|
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
||||||
|
const track = document.createElement('span'); track.className = 'track';
|
||||||
|
const fill = document.createElement('span'); fill.className = 'fill';
|
||||||
|
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
||||||
|
track.append(fill);
|
||||||
|
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
||||||
|
row.append(lbl, track, cnt);
|
||||||
|
mm.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "favorite tools": most-run bash commands across the swarm. Hidden
|
||||||
|
// (header + list) until the capture has recorded data, so the
|
||||||
|
// section never shows an empty block while capture is pre-data.
|
||||||
|
const bh = $('hive-stats-bash');
|
||||||
|
const bhH = $('hive-stats-bash-h');
|
||||||
|
if (bh) {
|
||||||
|
const bmix = s.bash_mix || [];
|
||||||
|
if (!bmix.length) {
|
||||||
|
bh.replaceChildren();
|
||||||
|
bh.hidden = true;
|
||||||
|
if (bhH) bhH.hidden = true;
|
||||||
|
} else {
|
||||||
|
bh.hidden = false;
|
||||||
|
if (bhH) bhH.hidden = false;
|
||||||
|
bh.replaceChildren();
|
||||||
|
const max = bmix[0].count || 1;
|
||||||
|
for (const kc of bmix) {
|
||||||
|
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
||||||
|
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
||||||
|
const track = document.createElement('span'); track.className = 'track';
|
||||||
|
const fill = document.createElement('span'); fill.className = 'fill';
|
||||||
|
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
||||||
|
track.append(fill);
|
||||||
|
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
||||||
|
row.append(lbl, track, cnt);
|
||||||
|
bh.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHiveStats() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
|
||||||
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||||
|
renderHiveStats(await resp.json());
|
||||||
|
} catch (e) {
|
||||||
|
const at = $('hive-stats-agents');
|
||||||
|
if (at) hsMeta(at, 'stats fetch failed: ' + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initServerWarnings();
|
||||||
|
|
||||||
|
// Window selector → hash-routed createTabStrip (#1h / #24h / …). No panes
|
||||||
|
// here (the selector swaps data, not DOM); paneFor() returns null for each
|
||||||
|
// tab and the strip's `if (pane)` guard handles it. The initial show()
|
||||||
|
// fires onShow once → sets the window + does the first fetch, so no
|
||||||
|
// separate refreshHiveStats() call is needed.
|
||||||
|
createTabStrip(document.getElementById('hive-stats-windows'), {
|
||||||
|
defaultId: hiveStatsWindow,
|
||||||
|
onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); },
|
||||||
|
});
|
||||||
|
|
@ -4055,11 +4055,11 @@ window.marked = marked;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ─── tab routing ───────────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#swarm` / `#call` / `#system` / `#schedules` /
|
// Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
|
||||||
// `#permissions` / `#stats` activate the matching pane on the
|
// `#schedules` activate the matching pane on the dashboard. Empty
|
||||||
// dashboard. Empty hash defaults to SW4RM. FL0W and S3TT1NGS are NOT
|
// hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs —
|
||||||
// tabs — they're separate pages (`/flow.html`, `/settings.html`)
|
// they're separate pages (`/flow.html`, `/settings.html`,
|
||||||
// reached from the H0M3 hub. Tab routing only
|
// `/stats.html`) reached from the H0M3 hub. Tab routing only
|
||||||
// applies when the tab DOM is present (e.g. not on the flow page
|
// applies when the tab DOM is present (e.g. not on the flow page
|
||||||
// itself, where these elements don't exist and the loop no-ops).
|
// itself, where these elements don't exist and the loop no-ops).
|
||||||
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
||||||
|
|
@ -4085,176 +4085,16 @@ window.marked = marked;
|
||||||
fetchAndRenderCapabilities();
|
fetchAndRenderCapabilities();
|
||||||
fetchAndRenderToolGroups();
|
fetchAndRenderToolGroups();
|
||||||
}
|
}
|
||||||
// ST4TS: hive-wide rollup is a pull (no SSE) — fetch on activation.
|
|
||||||
if (target === 'stats') { refreshHiveStats(); }
|
|
||||||
if (target === 'call') { refreshOperatorInbox(); }
|
if (target === 'call') { refreshOperatorInbox(); }
|
||||||
// SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is
|
// SYST3M › C0NT41N3R L04D: live cgroup poll only while the tab is
|
||||||
// open (cpu needs a short two-sample read each refresh).
|
// open (cpu needs a short two-sample read each refresh).
|
||||||
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
|
// ST4TS (hive-wide turn-stats rollup) moved to its own page,
|
||||||
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
|
// `/stats.html` — the render JS + the window selector live in
|
||||||
// on window change. Plain tables/bars — the dashboard bundle has no chart
|
// stats.js now. The dashboard no longer fetches
|
||||||
// lib, and per-agent trend charts live on each agent's own /stats page.
|
// /api/stats-hive.
|
||||||
let hiveStatsWindow = '24h';
|
|
||||||
|
|
||||||
function hsFmtInt(n) {
|
|
||||||
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
|
|
||||||
}
|
|
||||||
function hsFmtTokens(n) {
|
|
||||||
if (!Number.isFinite(n)) return '0';
|
|
||||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
|
||||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
|
||||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
|
||||||
return String(Math.round(n));
|
|
||||||
}
|
|
||||||
function hsFmtUsd(n) {
|
|
||||||
if (!Number.isFinite(n)) return '$0';
|
|
||||||
if (n >= 100) return '$' + n.toFixed(0);
|
|
||||||
if (n >= 1) return '$' + n.toFixed(2);
|
|
||||||
return '$' + n.toFixed(3);
|
|
||||||
}
|
|
||||||
function hsChip(parent, label, value, est) {
|
|
||||||
const c = document.createElement('span');
|
|
||||||
c.className = 'hive-stats-chip' + (est ? ' est' : '');
|
|
||||||
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
|
|
||||||
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
|
|
||||||
c.append(k, v);
|
|
||||||
parent.append(c);
|
|
||||||
}
|
|
||||||
function hsMeta(parent, text) {
|
|
||||||
parent.replaceChildren();
|
|
||||||
const p = document.createElement('p');
|
|
||||||
p.className = 'meta';
|
|
||||||
p.textContent = text;
|
|
||||||
parent.append(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderHiveStats(s) {
|
|
||||||
const sum = $('hive-stats-summary');
|
|
||||||
if (sum) {
|
|
||||||
sum.replaceChildren();
|
|
||||||
hsChip(sum, 'window', s.window);
|
|
||||||
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
|
|
||||||
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
|
|
||||||
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
|
||||||
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
|
|
||||||
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
|
|
||||||
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
|
|
||||||
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
|
|
||||||
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
|
|
||||||
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const at = $('hive-stats-agents');
|
|
||||||
if (at) {
|
|
||||||
const agents = s.agents || [];
|
|
||||||
if (!agents.length) {
|
|
||||||
hsMeta(at, 'no turns in window');
|
|
||||||
} else {
|
|
||||||
at.replaceChildren();
|
|
||||||
const table = document.createElement('table');
|
|
||||||
table.className = 'hive-stats-table';
|
|
||||||
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
|
|
||||||
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
|
|
||||||
const tb = document.createElement('tbody');
|
|
||||||
for (const a of agents) {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
const cells = [
|
|
||||||
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
|
|
||||||
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
|
|
||||||
hsFmtUsd(a.est_cost_usd),
|
|
||||||
];
|
|
||||||
cells.forEach((txt, i) => {
|
|
||||||
const td = document.createElement('td');
|
|
||||||
if (i > 0) td.className = 'num';
|
|
||||||
td.textContent = txt;
|
|
||||||
tr.append(td);
|
|
||||||
});
|
|
||||||
tb.append(tr);
|
|
||||||
}
|
|
||||||
table.append(tb);
|
|
||||||
at.append(table);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mm = $('hive-stats-models');
|
|
||||||
if (mm) {
|
|
||||||
const mix = s.model_mix || [];
|
|
||||||
if (!mix.length) {
|
|
||||||
hsMeta(mm, 'no turns in window');
|
|
||||||
} else {
|
|
||||||
mm.replaceChildren();
|
|
||||||
const max = mix[0].count || 1;
|
|
||||||
for (const kc of mix) {
|
|
||||||
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
|
||||||
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
|
||||||
const track = document.createElement('span'); track.className = 'track';
|
|
||||||
const fill = document.createElement('span'); fill.className = 'fill';
|
|
||||||
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
|
||||||
track.append(fill);
|
|
||||||
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
|
||||||
row.append(lbl, track, cnt);
|
|
||||||
mm.append(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "favorite tools": most-run bash commands across the swarm. Hidden
|
|
||||||
// (header + list) until the capture has recorded data, so the
|
|
||||||
// section never shows an empty block while capture is pre-data.
|
|
||||||
const bh = $('hive-stats-bash');
|
|
||||||
const bhH = $('hive-stats-bash-h');
|
|
||||||
if (bh) {
|
|
||||||
const bmix = s.bash_mix || [];
|
|
||||||
if (!bmix.length) {
|
|
||||||
bh.replaceChildren();
|
|
||||||
bh.hidden = true;
|
|
||||||
if (bhH) bhH.hidden = true;
|
|
||||||
} else {
|
|
||||||
bh.hidden = false;
|
|
||||||
if (bhH) bhH.hidden = false;
|
|
||||||
bh.replaceChildren();
|
|
||||||
const max = bmix[0].count || 1;
|
|
||||||
for (const kc of bmix) {
|
|
||||||
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
|
||||||
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
|
||||||
const track = document.createElement('span'); track.className = 'track';
|
|
||||||
const fill = document.createElement('span'); fill.className = 'fill';
|
|
||||||
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
|
||||||
track.append(fill);
|
|
||||||
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
|
||||||
row.append(lbl, track, cnt);
|
|
||||||
bh.append(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshHiveStats() {
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
|
|
||||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
||||||
renderHiveStats(await resp.json());
|
|
||||||
} catch (e) {
|
|
||||||
const at = $('hive-stats-agents');
|
|
||||||
if (at) hsMeta(at, 'stats fetch failed: ' + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindHiveStatsWindows() {
|
|
||||||
const tabs = $('hive-stats-windows');
|
|
||||||
if (!tabs) return;
|
|
||||||
tabs.addEventListener('click', (ev) => {
|
|
||||||
const btn = ev.target.closest('button[data-w]');
|
|
||||||
if (!btn) return;
|
|
||||||
hiveStatsWindow = btn.dataset.w;
|
|
||||||
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
|
||||||
refreshHiveStats();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bindHiveStatsWindows();
|
|
||||||
|
|
||||||
// ─── SYST3M › C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
|
// ─── SYST3M › C0NT41N3R L04D: live per-container cgroup cpu/mem ────────────
|
||||||
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a
|
// Pull-only, polled at 5s ONLY while the SYST3M tab is active (cpu is a
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
229
hive-c0re/src/dashboard/approvals.rs
Normal file
229
hive-c0re/src/dashboard/approvals.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//! Approval endpoints + diff machinery for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Approve/deny actions, the orphan-approval GC sweep used by the
|
||||||
|
//! `/api/state` builder, and the unified-diff endpoints (on-demand
|
||||||
|
//! `/api/approval-diff/{id}` against a chosen base, plus the `pub(crate)`
|
||||||
|
//! `approval_diff` the manager-socket handler pre-computes at submit time).
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use hive_sh4re::Approval;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
use crate::actions;
|
||||||
|
use crate::coordinator::Coordinator;
|
||||||
|
use crate::lifecycle;
|
||||||
|
|
||||||
|
pub(super) async fn post_approve(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match actions::approve(state.coord.clone(), id).await {
|
||||||
|
// 200 instead of 303 — `actions::approve` fires
|
||||||
|
// `ApprovalResolved` (success path) or the eventual failure
|
||||||
|
// event, both of which the dashboard's derived store applies
|
||||||
|
// live. The matching form carries `data-no-refresh`.
|
||||||
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||||
|
Err(e) => error_response(&format!("approve {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(super) struct DenyForm {
|
||||||
|
#[serde(default)]
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_deny(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
Form(form): Form<DenyForm>,
|
||||||
|
) -> Response {
|
||||||
|
let note = form
|
||||||
|
.note
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
match actions::deny(&state.coord, id, note).await {
|
||||||
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||||
|
Err(e) => error_response(&format!("deny {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter out approvals whose agent state dir was wiped out from under us
|
||||||
|
/// (e.g. by a test script's cleanup). Marks them failed so they fall out of
|
||||||
|
/// `pending` on next render.
|
||||||
|
pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
||||||
|
approvals
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| {
|
||||||
|
// Spawn and InitConfig approvals are for not-yet-existent agents;
|
||||||
|
// the proposed dir is supposed to be missing.
|
||||||
|
if matches!(
|
||||||
|
a.kind,
|
||||||
|
hive_sh4re::ApprovalKind::Spawn | hive_sh4re::ApprovalKind::InitConfig
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let note = "agent state dir missing";
|
||||||
|
let _ = coord.approvals.mark_failed(a.id, note);
|
||||||
|
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
||||||
|
let sha_short = a
|
||||||
|
.fetched_sha
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| s[..s.len().min(12)].to_owned());
|
||||||
|
coord.emit_approval_resolved(
|
||||||
|
a.id,
|
||||||
|
&a.agent,
|
||||||
|
"apply_commit",
|
||||||
|
sha_short,
|
||||||
|
"failed",
|
||||||
|
Some(note.to_owned()),
|
||||||
|
a.description.clone(),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Multi-file unified diff between the currently-deployed tree and
|
||||||
|
/// the proposal for this approval. Runs against the applied repo
|
||||||
|
/// since the canonical proposal commit lives there (manager-side
|
||||||
|
/// amendments don't move it). Empty output means proposal == main —
|
||||||
|
/// a no-op approval.
|
||||||
|
///
|
||||||
|
/// `pub(crate)` so the manager-socket handler can pre-compute the
|
||||||
|
/// diff once at submission time and embed it in the `ApprovalAdded`
|
||||||
|
/// dashboard event (instead of forcing the dashboard to wait a
|
||||||
|
/// `/api/state` cycle to see the diff for newly-queued approvals).
|
||||||
|
pub(crate) async fn approval_diff(agent: &str, approval_id: i64) -> String {
|
||||||
|
let applied = Coordinator::agent_applied_dir(agent);
|
||||||
|
if !applied.join(".git").exists() {
|
||||||
|
return format!("(no applied git repo at {})", applied.display());
|
||||||
|
}
|
||||||
|
let proposal_ref = format!("refs/tags/proposal/{approval_id}");
|
||||||
|
match git_diff_refs(&applied, "refs/heads/main", &proposal_ref).await {
|
||||||
|
Ok(s) if s.is_empty() => "(proposal matches currently-deployed tree)".to_owned(),
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => format!("(error: {e:#})"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn git_diff_refs(applied_dir: &Path, base_ref: &str, target_ref: &str) -> Result<String> {
|
||||||
|
let out = lifecycle::git_command()
|
||||||
|
.current_dir(applied_dir)
|
||||||
|
.args(["diff", &format!("{base_ref}..{target_ref}")])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("spawn `git diff` in {}", applied_dir.display()))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"git diff {base_ref}..{target_ref} failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numeric ids of `<prefix>/<n>` tags in the applied repo (e.g.
|
||||||
|
/// `proposal/3` → `3`). Unparseable suffixes are skipped. Used to
|
||||||
|
/// resolve the `approved` / `previous` diff bases for an approval.
|
||||||
|
async fn tag_ids(applied_dir: &Path, prefix: &str) -> Vec<i64> {
|
||||||
|
let Ok(out) = lifecycle::git_command()
|
||||||
|
.current_dir(applied_dir)
|
||||||
|
.args(["tag", "-l", &format!("{prefix}/*")])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if !out.status.success() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let strip = format!("{prefix}/");
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| l.trim().strip_prefix(&strip))
|
||||||
|
.filter_map(|s| s.parse::<i64>().ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct DiffBaseQuery {
|
||||||
|
/// `applied` (running tree — default), `approved` (most recent
|
||||||
|
/// earlier approved proposal), or `previous` (the prior queued
|
||||||
|
/// proposal for this agent).
|
||||||
|
base: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// On-demand unified diff for one `ApplyCommit` approval against a
|
||||||
|
/// chosen base. `applied` = `applied/main` (what's running);
|
||||||
|
/// `approved` = the most recent earlier `approved/<n>` tag (the last
|
||||||
|
/// proposal the operator OK'd, even if its build then failed);
|
||||||
|
/// `previous` = the prior queued `proposal/<n>` (the incremental
|
||||||
|
/// delta when the manager chains proposals). Returns the raw diff
|
||||||
|
/// text — the dashboard classifies lines client-side.
|
||||||
|
pub(super) async fn get_approval_diff(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<DiffBaseQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let base = q.base.as_deref().unwrap_or("applied");
|
||||||
|
let approval = match state.coord.approvals.get(id) {
|
||||||
|
Ok(Some(a)) => a,
|
||||||
|
Ok(None) => return error_response(&format!("approval {id} not found")),
|
||||||
|
Err(e) => return error_response(&format!("approval {id}: {e:#}")),
|
||||||
|
};
|
||||||
|
if !matches!(approval.kind, hive_sh4re::ApprovalKind::ApplyCommit) {
|
||||||
|
return error_response("spawn approvals carry no commit to diff");
|
||||||
|
}
|
||||||
|
let applied = Coordinator::agent_applied_dir(&approval.agent);
|
||||||
|
if !applied.join(".git").exists() {
|
||||||
|
return plain_text(format!("(no applied git repo at {})", applied.display()));
|
||||||
|
}
|
||||||
|
let target = format!("refs/tags/proposal/{id}");
|
||||||
|
let base_ref = match base {
|
||||||
|
"applied" => Some("refs/heads/main".to_owned()),
|
||||||
|
"approved" => {
|
||||||
|
let ids = tag_ids(&applied, "approved").await;
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|&n| n != id)
|
||||||
|
.max()
|
||||||
|
.map(|n| format!("refs/tags/approved/{n}"))
|
||||||
|
}
|
||||||
|
"previous" => {
|
||||||
|
let ids = tag_ids(&applied, "proposal").await;
|
||||||
|
ids.into_iter()
|
||||||
|
.filter(|&n| n < id)
|
||||||
|
.max()
|
||||||
|
.map(|n| format!("refs/tags/proposal/{n}"))
|
||||||
|
}
|
||||||
|
other => return error_response(&format!("unknown diff base {other:?}")),
|
||||||
|
};
|
||||||
|
let Some(base_ref) = base_ref else {
|
||||||
|
return plain_text(match base {
|
||||||
|
"approved" => "(no earlier approved proposal to diff against)".to_owned(),
|
||||||
|
_ => "(no previous proposal to diff against)".to_owned(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
match git_diff_refs(&applied, &base_ref, &target).await {
|
||||||
|
Ok(s) if s.is_empty() => plain_text("(identical — no changes vs this base)".to_owned()),
|
||||||
|
Ok(s) => plain_text(s),
|
||||||
|
Err(e) => error_response(&format!("git diff: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plain_text(body: String) -> Response {
|
||||||
|
(StatusCode::OK, body).into_response()
|
||||||
|
}
|
||||||
229
hive-c0re/src/dashboard/build_logs.rs
Normal file
229
hive-c0re/src/dashboard/build_logs.rs
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
//! Build-log endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Header lists (all-agents + per-agent), the full row by id, a `text/plain`
|
||||||
|
//! download, and an SSE stream that delivers incremental stdout/stderr while
|
||||||
|
//! a build runs (closing once it finishes or the row is vacuum-reaped).
|
||||||
|
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{
|
||||||
|
IntoResponse, Response,
|
||||||
|
sse::{Event, KeepAlive, Sse},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio_stream::Stream;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
use super::{AppState, error_response, validate_agent_name};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct BuildLogsAllQuery {
|
||||||
|
/// Max rows to return. Capped at 100. Default 30.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/build-logs?limit=N` — most-recent build log headers across
|
||||||
|
/// all agents, newest first. Same JSON shape as the per-agent endpoint.
|
||||||
|
pub(super) async fn get_build_logs_all(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<BuildLogsAllQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let limit = q.limit.unwrap_or(30);
|
||||||
|
match state.coord.build_logs.list_recent_all(limit) {
|
||||||
|
Ok(rows) => axum::Json(rows).into_response(),
|
||||||
|
Err(e) => error_response(&format!("build-logs all: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct BuildLogsQuery {
|
||||||
|
/// Maximum number of rows to return. Capped server-side at 50
|
||||||
|
/// (see `build_logs::list_recent_for_agent`). Default 10.
|
||||||
|
#[serde(default)]
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/build-logs/{agent}?limit=N` — most-recent build log
|
||||||
|
/// headers for one agent, newest first. Returns
|
||||||
|
/// `Vec<BuildLogHeader>` (JSON). Limit defaults to 10, server-side
|
||||||
|
/// cap at 50. Backs the per-agent log chip in the agent card and
|
||||||
|
/// the side-panel header list.
|
||||||
|
pub(super) async fn get_build_logs_agent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<BuildLogsQuery>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(reason) = validate_agent_name(&name) {
|
||||||
|
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
||||||
|
}
|
||||||
|
let limit = q.limit.unwrap_or(10);
|
||||||
|
match state.coord.build_logs.list_recent_for_agent(&name, limit) {
|
||||||
|
Ok(rows) => axum::Json(rows).into_response(),
|
||||||
|
Err(e) => error_response(&format!("build-logs {name}: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/build-logs/id/{id}` — full build log row (stdout +
|
||||||
|
/// stderr concatenated) by id. Returns `BuildLogFull` (JSON), or
|
||||||
|
/// HTTP 404 when the id doesn't exist (vacuum-reaped, or the
|
||||||
|
/// operator passed a stale id from a refresh race).
|
||||||
|
pub(super) async fn get_build_log_full(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match state.coord.build_logs.get_full(id) {
|
||||||
|
Ok(Some(log)) => axum::Json(log).into_response(),
|
||||||
|
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
||||||
|
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel.
|
||||||
|
/// `stdout_append` / `stderr_append` carry only the new bytes since the
|
||||||
|
/// last frame; `done = true` means the build finished and the stream
|
||||||
|
/// will close after this frame.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct BuildLogFrame {
|
||||||
|
stdout_append: String,
|
||||||
|
stderr_append: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
status: Option<String>,
|
||||||
|
done: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers
|
||||||
|
/// incremental stdout/stderr as a build runs. The client connects when
|
||||||
|
/// it opens a running-build panel; the stream closes automatically once
|
||||||
|
/// the build finishes (or the row disappears due to a vacuum).
|
||||||
|
///
|
||||||
|
/// Each frame is a JSON-serialised `BuildLogFrame`. The first frame
|
||||||
|
/// always carries the full accumulated log so far (cursors start at 0);
|
||||||
|
/// subsequent frames carry only new bytes. `done: true` on the final
|
||||||
|
/// frame signals the browser to close the `EventSource`.
|
||||||
|
pub(super) async fn get_build_log_stream(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
|
||||||
|
let logs = state.coord.build_logs.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut notify_rx = logs.subscribe_notifications();
|
||||||
|
let mut stdout_cursor = 0usize;
|
||||||
|
let mut stderr_cursor = 0usize;
|
||||||
|
|
||||||
|
// ── initial snapshot ──────────────────────────────────────────
|
||||||
|
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
||||||
|
Ok(Some(prog)) => {
|
||||||
|
stdout_cursor += prog.stdout_append.len();
|
||||||
|
stderr_cursor += prog.stderr_append.len();
|
||||||
|
let done = prog.finished_at.is_some();
|
||||||
|
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
||||||
|
stdout_append: prog.stdout_append,
|
||||||
|
stderr_append: prog.stderr_append,
|
||||||
|
status: prog.status,
|
||||||
|
done,
|
||||||
|
}) {
|
||||||
|
let _ = tx.send(Ok(Event::default().data(json))).await;
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
// Row missing — send a single error event and exit.
|
||||||
|
let _ = tx
|
||||||
|
.send(Ok(Event::default()
|
||||||
|
.event("error")
|
||||||
|
.data(format!("build log #{id} not found"))))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx
|
||||||
|
.send(Ok(Event::default()
|
||||||
|
.event("error")
|
||||||
|
.data(format!("build log #{id}: {e:#}"))))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── live delta loop ───────────────────────────────────────────
|
||||||
|
loop {
|
||||||
|
match notify_rx.recv().await {
|
||||||
|
// Notification for a different build — ignore and wait
|
||||||
|
// for the next one.
|
||||||
|
Ok(notif_id) if notif_id != id => {}
|
||||||
|
Ok(_) => {
|
||||||
|
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
|
||||||
|
Ok(Some(prog)) => {
|
||||||
|
stdout_cursor += prog.stdout_append.len();
|
||||||
|
stderr_cursor += prog.stderr_append.len();
|
||||||
|
let done = prog.finished_at.is_some();
|
||||||
|
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
|
||||||
|
stdout_append: prog.stdout_append,
|
||||||
|
stderr_append: prog.stderr_append,
|
||||||
|
status: prog.status,
|
||||||
|
done,
|
||||||
|
}) && tx.send(Ok(Event::default().data(json))).await.is_err()
|
||||||
|
{
|
||||||
|
return; // browser disconnected
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) | Err(_) => return, // vacuum reaped row / channel closed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for
|
||||||
|
/// download. Stdout and stderr are concatenated with a `--- stderr ---`
|
||||||
|
/// separator (same layout the JS side-panel renders). The
|
||||||
|
/// `Content-Disposition` header triggers a browser download with a
|
||||||
|
/// descriptive filename so the operator can save and share the log.
|
||||||
|
pub(super) async fn get_build_log_raw(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match state.coord.build_logs.get_full(id) {
|
||||||
|
Ok(Some(log)) => {
|
||||||
|
let mut text = log.stdout;
|
||||||
|
if !log.stderr.is_empty() {
|
||||||
|
text.push_str("\n--- stderr ---\n");
|
||||||
|
text.push_str(&log.stderr);
|
||||||
|
}
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[
|
||||||
|
("content-type", "text/plain; charset=utf-8".to_string()),
|
||||||
|
(
|
||||||
|
"content-disposition",
|
||||||
|
format!(
|
||||||
|
"attachment; filename=\"build-log-{}-{}.txt\"",
|
||||||
|
log.header.agent, id
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => (StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response(),
|
||||||
|
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
139
hive-c0re/src/dashboard/journal.rs
Normal file
139
hive-c0re/src/dashboard/journal.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
//! Journal-read endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! `GET /api/journal/{name}` reads a managed container's journal via the
|
||||||
|
//! root helper (`journalctl -M`, delegated to hive-priv since hive-c0re is
|
||||||
|
//! unprivileged). `GET /api/journal-host` reads host-side journald, both
|
||||||
|
//! gated by an allow-list of known units so arbitrary unit names can't be
|
||||||
|
//! probed. Operator-only by virtue of the dashboard binding host-only.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Path as AxumPath,
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{error_response, strip_container_prefix, validate_agent_name};
|
||||||
|
use crate::lifecycle;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct JournalQuery {
|
||||||
|
/// Optional systemd unit filter — e.g. `hive-ag3nt.service`. When
|
||||||
|
/// omitted, returns the full machine journal.
|
||||||
|
#[serde(default)]
|
||||||
|
unit: Option<String>,
|
||||||
|
/// Number of trailing lines to return. Capped at 5000.
|
||||||
|
#[serde(default)]
|
||||||
|
lines: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `journalctl -M <container> -b` and return its text output.
|
||||||
|
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re
|
||||||
|
/// runs unprivileged (privsep), so the `-M` read — which enters the
|
||||||
|
/// container namespace and needs root — is delegated to hive-priv.
|
||||||
|
pub(super) async fn get_journal(
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
||||||
|
) -> Response {
|
||||||
|
// Defense-in-depth format check so weird chars never reach the
|
||||||
|
// shellout below — the `lifecycle::list()` existence check would
|
||||||
|
// catch them anyway, but rejecting at the boundary keeps the
|
||||||
|
// failure mode crisp.
|
||||||
|
if let Some(reason) = validate_agent_name(&name) {
|
||||||
|
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
||||||
|
}
|
||||||
|
// Validate the container name against the list of managed
|
||||||
|
// containers so we don't shell out with arbitrary input.
|
||||||
|
let container = strip_container_prefix(&name);
|
||||||
|
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
|
||||||
|
let live = lifecycle::list().await.unwrap_or_default();
|
||||||
|
if !live.iter().any(|c| c == &prefixed) {
|
||||||
|
return error_response(&format!("journal: no managed container {prefixed:?}"));
|
||||||
|
}
|
||||||
|
let lines = q.lines.unwrap_or(500).min(5000);
|
||||||
|
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
Some(u) => {
|
||||||
|
// accept hive-ag3nt[.service] — anything else refused.
|
||||||
|
let allowed = ["hive-ag3nt.service"];
|
||||||
|
let unit = if u.ends_with(".service") {
|
||||||
|
u.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{u}.service")
|
||||||
|
};
|
||||||
|
if !allowed.contains(&unit.as_str()) {
|
||||||
|
return error_response(&format!("journal: unknown unit {unit:?}"));
|
||||||
|
}
|
||||||
|
Some(unit)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
match crate::priv_client::read_container_journal(
|
||||||
|
&prefixed,
|
||||||
|
hive_sh4re::priv_proto::JournalQuery {
|
||||||
|
lines,
|
||||||
|
boot: true,
|
||||||
|
output: hive_sh4re::priv_proto::JournalOutput::ShortIso,
|
||||||
|
unit,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((stdout, stderr)) => {
|
||||||
|
// Combine stdout + stderr — journalctl emits to both on errors.
|
||||||
|
let mut body = stdout;
|
||||||
|
if !stderr.is_empty() {
|
||||||
|
body.push_str("\n--- stderr ---\n");
|
||||||
|
body.push_str(&stderr);
|
||||||
|
}
|
||||||
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("journal read: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct JournalHostQuery {
|
||||||
|
/// Service unit name to filter to. If omitted, returns all logs.
|
||||||
|
#[serde(default)]
|
||||||
|
unit: Option<String>,
|
||||||
|
/// Number of trailing lines. Capped at 5000. Default 500.
|
||||||
|
#[serde(default)]
|
||||||
|
lines: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/journal-host?unit=<unit>&lines=N` — host-side journald (no
|
||||||
|
/// `-M` container flag). Restricted to an allow-list of known host services
|
||||||
|
/// so arbitrary unit names can't be probed. Operator-only by virtue of the
|
||||||
|
/// dashboard binding to a host-only port.
|
||||||
|
pub(super) async fn get_journal_host(
|
||||||
|
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
||||||
|
) -> Response {
|
||||||
|
let lines = q.lines.unwrap_or(500).min(5000);
|
||||||
|
let allowed = ["hive-c0re.service"];
|
||||||
|
let mut cmd = tokio::process::Command::new("journalctl");
|
||||||
|
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
||||||
|
.arg(lines.to_string());
|
||||||
|
if let Some(u) = q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||||
|
let unit = if u.ends_with(".service") {
|
||||||
|
u.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{u}.service")
|
||||||
|
};
|
||||||
|
if !allowed.contains(&unit.as_str()) {
|
||||||
|
return error_response(&format!("journal-host: unknown unit {unit:?}"));
|
||||||
|
}
|
||||||
|
cmd.args(["-u", &unit]);
|
||||||
|
}
|
||||||
|
match cmd.output().await {
|
||||||
|
Ok(out) => {
|
||||||
|
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||||
|
if !out.status.success() {
|
||||||
|
body.push_str("\n--- stderr ---\n");
|
||||||
|
body.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
}
|
||||||
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("journalctl spawn: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
191
hive-c0re/src/dashboard/lifecycle_ops.rs
Normal file
191
hive-c0re/src/dashboard/lifecycle_ops.rs
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
//! Container lifecycle endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Rebuild / restart / update-all enqueue onto the rebuild queue; kill /
|
||||||
|
//! start run the lifecycle op directly through `lifecycle_action` (which
|
||||||
|
//! marks the container transient for the duration so the dashboard can
|
||||||
|
//! spinner); destroy delegates to `actions::destroy` (optionally purging).
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{AppState, error_response, guard_agent_name, strip_container_prefix};
|
||||||
|
use crate::{actions, lifecycle};
|
||||||
|
|
||||||
|
pub(super) async fn post_rebuild(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
crate::rebuild_queue::QueueKind::Rebuild,
|
||||||
|
logical,
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"manual via dashboard ↻ R3BU1LD button".to_owned(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common shape for the simple lifecycle action handlers (start /
|
||||||
|
/// stop / restart / rebuild): strip the container prefix, mark
|
||||||
|
/// transient for the duration so the dashboard can spinner, run the
|
||||||
|
/// lifecycle op, clear transient, redirect on success or surface the
|
||||||
|
/// error. `verb` only appears in the error message; `extra` runs on
|
||||||
|
/// success after `clear_transient` for handlers that need follow-up
|
||||||
|
/// (e.g. `kill` also unregisters the agent + fires `HelperEvent`).
|
||||||
|
async fn lifecycle_action<F, Fut>(
|
||||||
|
state: &AppState,
|
||||||
|
name: &str,
|
||||||
|
kind: crate::coordinator::TransientKind,
|
||||||
|
verb: &str,
|
||||||
|
body: F,
|
||||||
|
extra: impl FnOnce(&AppState, &str),
|
||||||
|
) -> Response
|
||||||
|
where
|
||||||
|
F: FnOnce(String) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = anyhow::Result<()>>,
|
||||||
|
{
|
||||||
|
let logical = strip_container_prefix(name);
|
||||||
|
let guard = state.coord.transient_guard(&logical, kind);
|
||||||
|
let result = body(logical.clone()).await;
|
||||||
|
drop(guard);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
extra(state, &logical);
|
||||||
|
// Rescan so the running/needs_login/needs_update flip on
|
||||||
|
// the affected row lands on every dashboard's SSE channel
|
||||||
|
// without waiting for a snapshot poll. 200 + matching
|
||||||
|
// `data-no-refresh` on the form skip the post-submit
|
||||||
|
// /api/state refetch.
|
||||||
|
state.coord.rescan_containers_and_emit().await;
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("{verb} {logical} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_kill(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
// Manager is stoppable from the dashboard like any other
|
||||||
|
// agent. The host's dashboard server keeps running (it's
|
||||||
|
// hive-c0re, not the manager container), per-agent approvals
|
||||||
|
// submitted by other sub-agents still process through the
|
||||||
|
// host-side approval queue without the manager up, and
|
||||||
|
// operator-driven meta-input updates work from the dashboard
|
||||||
|
// either way. The MCP-surface self-kill guard in
|
||||||
|
// `manager_server.rs::ManagerRequest::Kill` stays in place: a
|
||||||
|
// manager calling Kill on its own container is self-suicide
|
||||||
|
// mid-call, not a legitimate operator action.
|
||||||
|
lifecycle_action(
|
||||||
|
&state,
|
||||||
|
&name,
|
||||||
|
crate::coordinator::TransientKind::Stopping,
|
||||||
|
"kill",
|
||||||
|
|n| async move { lifecycle::kill(&n).await },
|
||||||
|
|s, n| {
|
||||||
|
s.coord.unregister_agent(n);
|
||||||
|
s.coord.notify_manager(&hive_sh4re::HelperEvent::Killed {
|
||||||
|
agent: n.to_owned(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_restart(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
crate::rebuild_queue::QueueKind::Restart,
|
||||||
|
logical,
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"manual via dashboard ↺ R3START button".to_owned(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_start(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
lifecycle_action(
|
||||||
|
&state,
|
||||||
|
&name,
|
||||||
|
crate::coordinator::TransientKind::Starting,
|
||||||
|
"start",
|
||||||
|
|n| async move { lifecycle::start(&n).await },
|
||||||
|
|s, n| s.coord.kick_agent(n, "container started"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_update_all(State(state): State<AppState>) -> Response {
|
||||||
|
let containers = lifecycle::list().await.unwrap_or_default();
|
||||||
|
for container in containers {
|
||||||
|
let Some(logical) = container
|
||||||
|
.strip_prefix(lifecycle::AGENT_PREFIX)
|
||||||
|
.map(str::to_owned)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
state.coord.rebuild_queue.enqueue(
|
||||||
|
crate::rebuild_queue::QueueKind::Rebuild,
|
||||||
|
logical,
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"manual via dashboard 🌀 UPDATE ALL".to_owned(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
pub(super) struct DestroyForm {
|
||||||
|
#[serde(default)]
|
||||||
|
purge: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_destroy(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
Form(form): Form<DestroyForm>,
|
||||||
|
) -> Response {
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &name).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
// Checkbox semantics: any non-empty value (axum sends "on") = purge.
|
||||||
|
let purge = form.purge.as_deref().is_some_and(|v| !v.is_empty());
|
||||||
|
// `actions::destroy` rescans the container list on success, so the
|
||||||
|
// `ContainerRemoved` event lands before we return 200. The matching
|
||||||
|
// form carries `data-no-refresh`.
|
||||||
|
match actions::destroy(&state.coord, &name, purge).await {
|
||||||
|
Ok(()) => (StatusCode::OK, "ok").into_response(),
|
||||||
|
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
148
hive-c0re/src/dashboard/permissions.rs
Normal file
148
hive-c0re/src/dashboard/permissions.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
//! Tool-group + capability permission endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Read endpoints return the full set of known groups/capabilities plus
|
||||||
|
//! descriptions and the per-agent assignment map (the UI never hard-codes
|
||||||
|
//! the lists). Write endpoints validate, then enqueue a `PermChange` so the
|
||||||
|
//! JSON file write is serialised through the FIFO rebuild worker.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::{AppState, error_response, guard_agent_name, strip_container_prefix};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct ToolGroupsSnapshot {
|
||||||
|
/// Ordered list of all known tool-group names. Drives the column
|
||||||
|
/// headers in the capabilities table — the UI does not hard-code them.
|
||||||
|
groups: Vec<&'static str>,
|
||||||
|
/// Short description for each group name. Keys match `groups` entries.
|
||||||
|
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||||
|
/// Per-agent assignment map. Absent agents use the role default
|
||||||
|
/// (agents: messaging+meta+inbox+execution; manager: all groups).
|
||||||
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn get_tool_groups(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
) -> axum::Json<ToolGroupsSnapshot> {
|
||||||
|
let groups = hive_sh4re::ToolGroup::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|g| g.as_str())
|
||||||
|
.collect();
|
||||||
|
let descriptions = hive_sh4re::ToolGroup::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|g| (g.as_str(), g.description()))
|
||||||
|
.collect();
|
||||||
|
let assignments = crate::tool_groups::read();
|
||||||
|
axum::Json(ToolGroupsSnapshot {
|
||||||
|
groups,
|
||||||
|
descriptions,
|
||||||
|
assignments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetToolGroupsBody {
|
||||||
|
groups: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_tool_groups(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
// Validate group names before queuing — fail fast so the operator
|
||||||
|
// sees the error immediately rather than waiting for the worker.
|
||||||
|
if let Err(e) = crate::tool_groups::validate_groups(&body.groups) {
|
||||||
|
return error_response(&format!("invalid tool-groups for {logical}: {e}"));
|
||||||
|
}
|
||||||
|
// Enqueue a PermChange so the JSON file write is serialised through
|
||||||
|
// the FIFO worker. Prevents concurrent batch-apply actions for
|
||||||
|
// different agents from racing on the shared tool-groups.json.
|
||||||
|
state.coord.rebuild_queue.enqueue_with_perm(
|
||||||
|
logical.clone(),
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"tool-group change via permissions UI".to_owned(),
|
||||||
|
crate::rebuild_queue::PermPayload::ToolGroups {
|
||||||
|
groups: body.groups.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub(super) struct CapabilitiesSnapshot {
|
||||||
|
/// Ordered list of all known capability names. Drives the column
|
||||||
|
/// headers in the capabilities table — the UI does not hard-code them.
|
||||||
|
caps: Vec<&'static str>,
|
||||||
|
/// Short description for each capability name. Keys match `caps` entries.
|
||||||
|
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||||
|
/// Per-agent capability grant map. Absent agents have no extra caps.
|
||||||
|
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn get_capabilities(
|
||||||
|
State(_state): State<AppState>,
|
||||||
|
) -> axum::Json<CapabilitiesSnapshot> {
|
||||||
|
use hive_sh4re::Capability;
|
||||||
|
let caps = Capability::ALL.iter().map(|c| c.as_str()).collect();
|
||||||
|
let descriptions = Capability::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.as_str(), c.description()))
|
||||||
|
.collect();
|
||||||
|
let assignments = crate::capabilities::read();
|
||||||
|
axum::Json(CapabilitiesSnapshot {
|
||||||
|
caps,
|
||||||
|
descriptions,
|
||||||
|
assignments,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetCapabilitiesBody {
|
||||||
|
caps: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_capabilities(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(name): AxumPath<String>,
|
||||||
|
axum::Json(body): axum::Json<SetCapabilitiesBody>,
|
||||||
|
) -> Response {
|
||||||
|
let logical = strip_container_prefix(&name);
|
||||||
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
return reject;
|
||||||
|
}
|
||||||
|
let known: Vec<&str> = hive_sh4re::Capability::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.as_str())
|
||||||
|
.collect();
|
||||||
|
for cap in &body.caps {
|
||||||
|
if !known.contains(&cap.as_str()) {
|
||||||
|
return error_response(&format!("unknown capability: {cap}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enqueue a PermChange so the JSON file write is serialised through
|
||||||
|
// the FIFO worker. Prevents concurrent batch-apply actions for
|
||||||
|
// different agents from racing on the shared capabilities.json.
|
||||||
|
state.coord.rebuild_queue.enqueue_with_perm(
|
||||||
|
logical.clone(),
|
||||||
|
crate::rebuild_queue::QueueSource::Manual,
|
||||||
|
"capability change via dashboard".to_owned(),
|
||||||
|
crate::rebuild_queue::PermPayload::Capabilities {
|
||||||
|
caps: body.caps.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
tracing::info!(agent = %logical, caps = ?body.caps, "operator: set capabilities via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
115
hive-c0re/src/dashboard/questions.rs
Normal file
115
hive-c0re/src/dashboard/questions.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
//! Operator question answer/cancel endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! `POST /answer-question/{id}` records the operator's answer and fires a
|
||||||
|
//! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}`
|
||||||
|
//! resolves a pending question with a `[cancelled]` sentinel. Both carry a
|
||||||
|
//! permissive CORS header so the per-agent web UI (different origin) can
|
||||||
|
//! POST here until the unifying gateway makes it same-origin.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct AnswerForm {
|
||||||
|
answer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a permissive CORS header so the per-agent web UI — served on
|
||||||
|
/// a different port — can POST an operator answer here and read the
|
||||||
|
/// result. The dashboard has no auth, so `*` exposes nothing a plain
|
||||||
|
/// cross-origin form-POST couldn't already reach. This shim disappears
|
||||||
|
/// once the unifying gateway makes the agent page same-origin; see
|
||||||
|
/// `docs/boundary.md`.
|
||||||
|
fn with_cors(mut resp: Response) -> Response {
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
|
axum::http::HeaderValue::from_static("*"),
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_answer_question(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
Form(form): Form<AnswerForm>,
|
||||||
|
) -> Response {
|
||||||
|
let answer = form.answer.trim();
|
||||||
|
if answer.is_empty() {
|
||||||
|
return with_cors(error_response("answer: required"));
|
||||||
|
}
|
||||||
|
let resp = match state
|
||||||
|
.coord
|
||||||
|
.questions
|
||||||
|
.answer(id, answer, hive_sh4re::OPERATOR_RECIPIENT)
|
||||||
|
{
|
||||||
|
Ok((question, asker, target)) => {
|
||||||
|
tracing::info!(%id, %asker, "operator answered question");
|
||||||
|
state.coord.notify_agent(
|
||||||
|
&asker,
|
||||||
|
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||||
|
id,
|
||||||
|
question,
|
||||||
|
answer: answer.to_owned(),
|
||||||
|
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
state.coord.emit_question_resolved(
|
||||||
|
id,
|
||||||
|
answer,
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT,
|
||||||
|
false,
|
||||||
|
target.as_deref(),
|
||||||
|
);
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
||||||
|
};
|
||||||
|
with_cors(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a pending operator question with a sentinel answer when
|
||||||
|
/// the operator decides not to / can't answer. The asker harness
|
||||||
|
/// receives a `QuestionAnswered` event with `answer = "[cancelled]"`
|
||||||
|
/// so it can fall back on whatever default it had. Same code path as
|
||||||
|
/// a real answer — just lets the operator close the loop instead of
|
||||||
|
/// letting the question dangle forever.
|
||||||
|
pub(super) async fn post_cancel_question(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
const SENTINEL: &str = "[cancelled]";
|
||||||
|
match state
|
||||||
|
.coord
|
||||||
|
.questions
|
||||||
|
.answer(id, SENTINEL, hive_sh4re::OPERATOR_RECIPIENT)
|
||||||
|
{
|
||||||
|
Ok((question, asker, target)) => {
|
||||||
|
tracing::info!(%id, %asker, "operator cancelled question");
|
||||||
|
state.coord.emit_question_resolved(
|
||||||
|
id,
|
||||||
|
SENTINEL,
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT,
|
||||||
|
true,
|
||||||
|
target.as_deref(),
|
||||||
|
);
|
||||||
|
state.coord.notify_agent_from(
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT,
|
||||||
|
&asker,
|
||||||
|
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||||
|
id,
|
||||||
|
question,
|
||||||
|
answer: SENTINEL.to_owned(),
|
||||||
|
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
55
hive-c0re/src/dashboard/reminders.rs
Normal file
55
hive-c0re/src/dashboard/reminders.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
//! Reminder endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Lists pending reminders for the reminders tab, and lets the operator
|
||||||
|
//! cancel a pending reminder or reset its failure state so the scheduler
|
||||||
|
//! retries it on the next tick.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
|
pub(super) async fn api_reminders(State(state): State<AppState>) -> Response {
|
||||||
|
match state.coord.broker.list_pending_reminders() {
|
||||||
|
Ok(rows) => axum::Json(rows).into_response(),
|
||||||
|
Err(e) => error_response(&format!("reminders: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn post_cancel_reminder(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match state.coord.broker.cancel_reminder(id) {
|
||||||
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(%id, "operator cancelled reminder");
|
||||||
|
state.coord.emit_reminders_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("cancel reminder {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset a pending reminder's failure state so the scheduler
|
||||||
|
/// retries it on the next tick. Useful when the failure was
|
||||||
|
/// transient (sqlite lock contention, disk full → freed up) and
|
||||||
|
/// the operator wants delivery to resume immediately instead of
|
||||||
|
/// the row sitting in attempt-count-capped purgatory.
|
||||||
|
pub(super) async fn post_retry_reminder(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match state.coord.broker.reset_reminder_failure(id) {
|
||||||
|
Ok(0) => error_response(&format!("reminder {id} not pending (already delivered?)")),
|
||||||
|
Ok(_) => {
|
||||||
|
tracing::info!(%id, "operator reset reminder failure for retry");
|
||||||
|
state.coord.emit_reminders_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("retry reminder {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
227
hive-c0re/src/dashboard/schedules.rs
Normal file
227
hive-c0re/src/dashboard/schedules.rs
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
//! Scheduled-prompt + rebuild-queue endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Operator-direct schedule CRUD (`/api/schedules` GET/POST, `{id}` PATCH,
|
||||||
|
//! `{id}/cancel` + `{id}/fire-now`) — the operator click *is* the approval,
|
||||||
|
//! so these skip the manager approval gate. Also the co-located
|
||||||
|
//! `/api/rebuild-queue/{id}/cancel` endpoint.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Path as AxumPath, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
|
/// `GET /api/schedules` — snapshot of every schedule for the
|
||||||
|
/// scheduled-prompts tab. Returns the wire shape directly
|
||||||
|
/// so the frontend can render without an extra translation layer.
|
||||||
|
pub(super) async fn api_schedules(State(state): State<AppState>) -> Response {
|
||||||
|
match state.coord.scheduled_prompts.list() {
|
||||||
|
Ok(rows) => axum::Json(
|
||||||
|
rows.into_iter()
|
||||||
|
.map(crate::manager_server::schedule_to_wire_public)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => error_response(&format!("scheduled_prompts list: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/schedules` — operator-direct schedule creation
|
||||||
|
/// (mara: "user can add them manually"). Accepts the same
|
||||||
|
/// `SchedulePromptPayload` shape as the manager request flow but
|
||||||
|
/// skips the approval gate — the operator click *is* the
|
||||||
|
/// approval. The schedule lands directly with
|
||||||
|
/// `source = Operator` and the worker picks it up at fire time.
|
||||||
|
pub(super) async fn post_schedule_new(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::Json(payload): axum::Json<hive_sh4re::SchedulePromptPayload>,
|
||||||
|
) -> Response {
|
||||||
|
if payload.targets.is_empty() {
|
||||||
|
return error_response("schedule must have at least one target");
|
||||||
|
}
|
||||||
|
if payload.body.trim().is_empty() {
|
||||||
|
return error_response("schedule body must be non-empty");
|
||||||
|
}
|
||||||
|
if let Some(0) = payload.interval_seconds {
|
||||||
|
return error_response("interval_seconds must be > 0 (use None for one-shot)");
|
||||||
|
}
|
||||||
|
let new = crate::scheduled_prompts::NewSchedule {
|
||||||
|
owner: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||||
|
targets: payload.targets,
|
||||||
|
body: payload.body,
|
||||||
|
first_fire_at_unix: payload.first_fire_at_unix,
|
||||||
|
interval_seconds: payload.interval_seconds,
|
||||||
|
description: payload.description,
|
||||||
|
source: crate::scheduled_prompts::ScheduleSource::Operator,
|
||||||
|
};
|
||||||
|
match state.coord.scheduled_prompts.submit(&new) {
|
||||||
|
Ok(id) => {
|
||||||
|
state.coord.emit_schedules_snapshot();
|
||||||
|
axum::Json(serde_json::json!({"id": id})).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("schedule submit: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/schedules/{id}/fire-now` — operator-initiated
|
||||||
|
/// out-of-band fire of a scheduled prompt. Runs the
|
||||||
|
/// per-target fan-out once immediately and reports per-target
|
||||||
|
/// outcome counts. Does NOT touch `next_fire_at_unix` on
|
||||||
|
/// recurring schedules (their cadence stays intact); one-shot
|
||||||
|
/// schedules are consumed (cancelled) by a manual fire — the
|
||||||
|
/// operator's intent is "send this now, the scheduled time was
|
||||||
|
/// wrong."
|
||||||
|
pub(super) async fn post_schedule_fire_now(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
match crate::scheduled_prompts_worker::fire_now(&state.coord, id).await {
|
||||||
|
Ok(report) => {
|
||||||
|
state.coord.emit_schedules_snapshot();
|
||||||
|
axum::Json(report).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("fire schedule {id} now: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry
|
||||||
|
/// from the rebuild queue. Refuses `Running` / terminal
|
||||||
|
/// entries: an in-flight rebuild owns the agent's nix store +
|
||||||
|
/// nixos-container update lock and can't be safely interrupted
|
||||||
|
/// from the queue side. Always returns 200; the body is
|
||||||
|
/// `{"cancelled": true}` on a successful flip from Queued →
|
||||||
|
/// Cancelled, `{"cancelled": false}` when the row was Running /
|
||||||
|
/// terminal / gone. On success a fresh `RebuildQueueChanged`
|
||||||
|
/// snapshot fires so the row's state flip surfaces live.
|
||||||
|
pub(super) async fn post_rebuild_queue_cancel(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<u64>,
|
||||||
|
) -> Response {
|
||||||
|
let cancelled = state.coord.rebuild_queue.cancel(id);
|
||||||
|
if cancelled {
|
||||||
|
state.coord.emit_rebuild_queue_snapshot();
|
||||||
|
axum::Json(serde_json::json!({"cancelled": true})).into_response()
|
||||||
|
} else {
|
||||||
|
axum::Json(serde_json::json!({"cancelled": false})).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
pub(super) struct CancelScheduleForm {
|
||||||
|
/// `None` / absent / empty array → cancel whole schedule.
|
||||||
|
#[serde(default)]
|
||||||
|
targets: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Default)]
|
||||||
|
#[allow(
|
||||||
|
clippy::option_option,
|
||||||
|
reason = "double-Option carries three-state PATCH semantics on the wire \
|
||||||
|
(missing key = leave alone, JSON null = clear, value = set); \
|
||||||
|
collapsing to a single Option would lose the 'clear' state"
|
||||||
|
)]
|
||||||
|
pub(super) struct EditScheduleForm {
|
||||||
|
#[serde(default)]
|
||||||
|
body: Option<String>,
|
||||||
|
/// Double-`Option` semantics on the wire: missing key = leave
|
||||||
|
/// alone, explicit `null` = clear, value = set. serde's
|
||||||
|
/// `deserialize_with` trick to distinguish missing from null:
|
||||||
|
/// we wrap each editable field in its own helper. Simpler
|
||||||
|
/// here — keep them plain `Option<Option<_>>` and document
|
||||||
|
/// that the dashboard caller passes JSON `null` to clear.
|
||||||
|
#[serde(default, deserialize_with = "deserialize_some")]
|
||||||
|
description: Option<Option<String>>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_some")]
|
||||||
|
interval_seconds: Option<Option<u64>>,
|
||||||
|
#[serde(default)]
|
||||||
|
next_fire_at_unix: Option<i64>,
|
||||||
|
/// New targets to add. Replace-on-conflict: re-adding a
|
||||||
|
/// previously-cancelled target drops the tombstone and the
|
||||||
|
/// target starts fresh (operator intent on re-add = "this
|
||||||
|
/// target is active again, fresh start").
|
||||||
|
#[serde(default)]
|
||||||
|
targets_add: Option<Vec<String>>,
|
||||||
|
/// Targets to cancel. Same path as `cancel_targets`:
|
||||||
|
/// tombstones preserve audit and the parent schedule
|
||||||
|
/// auto-cancels when no active targets remain.
|
||||||
|
#[serde(default)]
|
||||||
|
targets_remove: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// serde adaptor: turns missing-key into `None`, explicit-null
|
||||||
|
/// into `Some(None)`, value into `Some(Some(v))`. Standard trick
|
||||||
|
/// for distinguishing "field absent" from "field set to null" in
|
||||||
|
/// JSON PATCH bodies.
|
||||||
|
fn deserialize_some<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
T::deserialize(deserializer).map(Some)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `PATCH /api/schedules/{id}` — partial update of an existing
|
||||||
|
/// schedule. Mutable fields: `body`, `description`,
|
||||||
|
/// `interval_seconds`, `next_fire_at_unix`, plus the target set
|
||||||
|
/// via `targets_add` / `targets_remove`. Both target lists
|
||||||
|
/// are applied in the same transaction as the scalar fields with
|
||||||
|
/// removes-before-adds; re-adding a previously-removed target
|
||||||
|
/// resets per-target history (fresh start); draining all targets
|
||||||
|
/// auto-cancels the parent schedule. JSON body uses missing-key
|
||||||
|
/// = "leave alone", explicit null = "clear" for `description` +
|
||||||
|
/// `interval_seconds`. Cancelled schedules are refused — submit
|
||||||
|
/// a new one instead. Returns the updated `WireSchedule` so the
|
||||||
|
/// caller's post-edit refresh has the new state inline.
|
||||||
|
pub(super) async fn patch_schedule(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
axum::Json(form): axum::Json<EditScheduleForm>,
|
||||||
|
) -> Response {
|
||||||
|
let patch = crate::scheduled_prompts::UpdateSchedule {
|
||||||
|
body: form.body,
|
||||||
|
description: form.description,
|
||||||
|
interval_seconds: form.interval_seconds,
|
||||||
|
next_fire_at_unix: form.next_fire_at_unix,
|
||||||
|
targets_add: form.targets_add,
|
||||||
|
targets_remove: form.targets_remove,
|
||||||
|
};
|
||||||
|
if let Err(e) = state.coord.scheduled_prompts.update(id, patch) {
|
||||||
|
return error_response(&format!("edit schedule {id}: {e:#}"));
|
||||||
|
}
|
||||||
|
match state.coord.scheduled_prompts.get(id) {
|
||||||
|
Ok(Some(s)) => {
|
||||||
|
let wire = crate::manager_server::schedule_to_wire_public(s);
|
||||||
|
state.coord.emit_schedules_snapshot();
|
||||||
|
axum::Json(wire).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => error_response(&format!("edit schedule {id}: row vanished post-update")),
|
||||||
|
Err(e) => error_response(&format!("re-read schedule {id}: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/schedules/{id}/cancel` — operator-side cancel
|
||||||
|
/// (whole schedule when no `targets` field, partial when one is
|
||||||
|
/// provided). Operator bypasses the topology check; the manager
|
||||||
|
/// surface enforces it for agent callers.
|
||||||
|
pub(super) async fn post_schedule_cancel(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
body: Option<axum::Json<CancelScheduleForm>>,
|
||||||
|
) -> Response {
|
||||||
|
let targets = body
|
||||||
|
.and_then(|axum::Json(b)| b.targets)
|
||||||
|
.filter(|t| !t.is_empty());
|
||||||
|
let result = match targets.as_deref() {
|
||||||
|
Some(list) => state.coord.scheduled_prompts.cancel_targets(id, list),
|
||||||
|
None => state.coord.scheduled_prompts.cancel_all(id),
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
state.coord.emit_schedules_snapshot();
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("cancel schedule {id}: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
308
hive-c0re/src/dashboard/state_files.rs
Normal file
308
hive-c0re/src/dashboard/state_files.rs
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
//! State-file proxy + path-validation for the dashboard.
|
||||||
|
//!
|
||||||
|
//! `GET /api/state-file?path=…` serves an allow-listed file (per-agent
|
||||||
|
//! `state/` or `shared/`) with defense-in-depth symlink + traversal checks
|
||||||
|
//! (see `docs/security.md::State-file endpoint`); raster images are served
|
||||||
|
//! with their real content-type, everything else as truncated text.
|
||||||
|
//! `scan_validated_paths` runs the same allow-list at broker-message ingest
|
||||||
|
//! so dashboard events carry a pre-verified file-ref set.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::error_response;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct StateFileQuery {
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a caller-supplied path against the allow-listed roots
|
||||||
|
/// (`agents/<n>/state/` and `shared/`). Applies defense-in-depth
|
||||||
|
/// symlink + traversal checks before serving. Security model and
|
||||||
|
/// all five layers: `docs/security.md::State-file endpoint`.
|
||||||
|
fn resolve_state_path(
|
||||||
|
raw: &str,
|
||||||
|
) -> std::result::Result<(std::path::PathBuf, std::fs::Metadata), String> {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
const AGENTS_ROOT: &str = "/var/lib/hyperhive/agents";
|
||||||
|
const SHARED_ROOT: &str = "/var/lib/hyperhive/shared";
|
||||||
|
let raw = raw.trim();
|
||||||
|
let (mapped, root): (std::path::PathBuf, &str) =
|
||||||
|
if let Some(rest) = raw.strip_prefix("/agents/") {
|
||||||
|
(
|
||||||
|
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
|
||||||
|
AGENTS_ROOT,
|
||||||
|
)
|
||||||
|
} else if let Some(rest) = raw.strip_prefix("/shared/") {
|
||||||
|
(
|
||||||
|
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
|
||||||
|
SHARED_ROOT,
|
||||||
|
)
|
||||||
|
} else if let Some(rest) = raw.strip_prefix(&format!("{AGENTS_ROOT}/")) {
|
||||||
|
(
|
||||||
|
std::path::PathBuf::from(format!("{AGENTS_ROOT}/{rest}")),
|
||||||
|
AGENTS_ROOT,
|
||||||
|
)
|
||||||
|
} else if let Some(rest) = raw.strip_prefix(&format!("{SHARED_ROOT}/")) {
|
||||||
|
(
|
||||||
|
std::path::PathBuf::from(format!("{SHARED_ROOT}/{rest}")),
|
||||||
|
SHARED_ROOT,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Err(format!("path not in allow-list: {raw}"));
|
||||||
|
};
|
||||||
|
reject_symlinks_below(std::path::Path::new(root), &mapped)?;
|
||||||
|
let canonical =
|
||||||
|
std::fs::canonicalize(&mapped).map_err(|e| format!("{}: {e}", mapped.display()))?;
|
||||||
|
if !(canonical.starts_with(AGENTS_ROOT) || canonical.starts_with(SHARED_ROOT)) {
|
||||||
|
return Err(format!(
|
||||||
|
"resolved path escapes allow-list: {}",
|
||||||
|
canonical.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Ok(rel) = canonical.strip_prefix(AGENTS_ROOT) {
|
||||||
|
let mut components = rel.components();
|
||||||
|
let _agent = components.next();
|
||||||
|
let dir = components.next().and_then(|c| c.as_os_str().to_str());
|
||||||
|
if dir != Some("state") {
|
||||||
|
return Err(format!(
|
||||||
|
"only per-agent state/ is readable here ({} dir not allowed)",
|
||||||
|
dir.unwrap_or("(root)")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let meta =
|
||||||
|
std::fs::metadata(&canonical).map_err(|e| format!("stat {}: {e}", canonical.display()))?;
|
||||||
|
if meta.is_file() {
|
||||||
|
let mode = meta.permissions().mode();
|
||||||
|
if mode & 0o004 == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{} not world-readable (mode 0{:o}); refusing to proxy non-public file",
|
||||||
|
canonical.display(),
|
||||||
|
mode & 0o777,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((canonical, meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk every path component under `root` and refuse if any of
|
||||||
|
/// them is a symlink. The roots themselves (`AGENTS_ROOT`,
|
||||||
|
/// `SHARED_ROOT`) are hive-c0re-owned and assumed trusted; only
|
||||||
|
/// the parts the agent / operator can plant matter. Components
|
||||||
|
/// that don't exist yet are skipped — `canonicalize` reports
|
||||||
|
/// non-existence separately, and missing-component checks would
|
||||||
|
/// just race the filesystem.
|
||||||
|
fn reject_symlinks_below(
|
||||||
|
root: &std::path::Path,
|
||||||
|
mapped: &std::path::Path,
|
||||||
|
) -> std::result::Result<(), String> {
|
||||||
|
let Ok(rel) = mapped.strip_prefix(root) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut cumulative = root.to_path_buf();
|
||||||
|
for component in rel.components() {
|
||||||
|
match component {
|
||||||
|
std::path::Component::Normal(name) => {
|
||||||
|
cumulative.push(name);
|
||||||
|
match std::fs::symlink_metadata(&cumulative) {
|
||||||
|
Ok(m) if m.file_type().is_symlink() => {
|
||||||
|
return Err(format!(
|
||||||
|
"symlink at {} not allowed (canonicalize would resolve it past the \
|
||||||
|
allow-list check; refuse outright)",
|
||||||
|
cumulative.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::path::Component::ParentDir => {
|
||||||
|
return Err(format!(
|
||||||
|
"path contains `..` traversal below {}; refuse outright",
|
||||||
|
root.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan `body` for path-shaped tokens and return those that pass the
|
||||||
|
/// allow-list + `is_file` check via `resolve_state_path`. Called at
|
||||||
|
/// broker-message ingest so the dashboard event already carries the
|
||||||
|
/// verified set; security rules stay in sync with the read endpoint.
|
||||||
|
pub fn scan_validated_paths(body: &str) -> Vec<String> {
|
||||||
|
const PREFIXES: [&str; 4] = [
|
||||||
|
"/agents/",
|
||||||
|
"/shared/",
|
||||||
|
"/var/lib/hyperhive/agents/",
|
||||||
|
"/var/lib/hyperhive/shared/",
|
||||||
|
];
|
||||||
|
let mut out = Vec::<String>::new();
|
||||||
|
for raw in body.split(|c: char| c.is_whitespace()) {
|
||||||
|
// Trim trailing natural-language punctuation that wouldn't
|
||||||
|
// be part of any real path. Inline rather than via a regex
|
||||||
|
// dep — the set is small and the call is hot.
|
||||||
|
let token = raw.trim_end_matches([',', ';', ':', ')', ']', '}', '.', '\'', '"']);
|
||||||
|
if token.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !PREFIXES.iter().any(|p| token.starts_with(p)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Cheap dedupe — typical message has 0-3 refs.
|
||||||
|
if out.iter().any(|s| s == token) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok((_canonical, meta)) = resolve_state_path(token)
|
||||||
|
&& meta.is_file()
|
||||||
|
{
|
||||||
|
out.push(token.to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn get_state_file(
|
||||||
|
axum::extract::Query(q): axum::extract::Query<StateFileQuery>,
|
||||||
|
) -> Response {
|
||||||
|
const MAX_BYTES: usize = 1 << 20; // 1 MiB
|
||||||
|
let (canonical, meta) = match resolve_state_path(&q.path) {
|
||||||
|
Ok(pair) => pair,
|
||||||
|
Err(e) => return error_response(&format!("state-file: {e}")),
|
||||||
|
};
|
||||||
|
if !meta.is_file() {
|
||||||
|
return error_response(&format!(
|
||||||
|
"state-file: {} is not a regular file",
|
||||||
|
canonical.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let size = meta.len();
|
||||||
|
let bytes = match std::fs::read(&canonical) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => return error_response(&format!("state-file: read {}: {e}", canonical.display())),
|
||||||
|
};
|
||||||
|
// Raster images: serve the raw bytes with their real content-type
|
||||||
|
// so the dashboard can render them in an <img>. Not truncated —
|
||||||
|
// a clipped binary is corrupt, so over-cap images are rejected
|
||||||
|
// instead. (SVG stays on the text path: it's text, and the client
|
||||||
|
// renders it via a data: URI.)
|
||||||
|
if let Some(ct) = image_content_type(&canonical) {
|
||||||
|
if bytes.len() > MAX_BYTES {
|
||||||
|
return error_response(&format!(
|
||||||
|
"state-file: image {} is {size} bytes, over the {MAX_BYTES}-byte preview cap",
|
||||||
|
canonical.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return ([("content-type", ct)], bytes).into_response();
|
||||||
|
}
|
||||||
|
let truncated = bytes.len() > MAX_BYTES;
|
||||||
|
let body_bytes = if truncated {
|
||||||
|
&bytes[..MAX_BYTES]
|
||||||
|
} else {
|
||||||
|
&bytes[..]
|
||||||
|
};
|
||||||
|
let mut body = String::from_utf8_lossy(body_bytes).into_owned();
|
||||||
|
if truncated {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(
|
||||||
|
body,
|
||||||
|
"\n\n--- truncated at {MAX_BYTES} of {size} bytes ---\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
([("content-type", "text/plain; charset=utf-8")], body).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Content-type for a raster image the dashboard can preview in an
|
||||||
|
/// `<img>`, keyed off the file extension. `None` for non-image, SVG,
|
||||||
|
/// and text files (SVG is served on the text path and rendered
|
||||||
|
/// client-side via a `data:` URI).
|
||||||
|
fn image_content_type(path: &Path) -> Option<&'static str> {
|
||||||
|
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||||
|
Some(match ext.as_str() {
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"webp" => "image/webp",
|
||||||
|
"bmp" => "image/bmp",
|
||||||
|
"ico" => "image/x-icon",
|
||||||
|
"avif" => "image/avif",
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
/// Make a unique tmp subdir for the calling test. Caller is responsible
|
||||||
|
/// for cleanup (we leak on panic, fine for ephemeral CI runs).
|
||||||
|
fn tmproot(tag: &str) -> std::path::PathBuf {
|
||||||
|
let ts = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map_or(0, |d| d.as_nanos());
|
||||||
|
let p = std::env::temp_dir().join(format!("hyperhive-test-{tag}-{ts}"));
|
||||||
|
std::fs::create_dir_all(&p).unwrap();
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_symlinks_below_accepts_plain_dirs_and_files() {
|
||||||
|
let root = tmproot("symlink-ok");
|
||||||
|
std::fs::create_dir_all(root.join("alice/state")).unwrap();
|
||||||
|
std::fs::write(root.join("alice/state/notes.md"), b"hi").unwrap();
|
||||||
|
assert!(reject_symlinks_below(&root, &root.join("alice/state/notes.md")).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_symlinks_below_rejects_leaf_symlink() {
|
||||||
|
let root = tmproot("symlink-leaf");
|
||||||
|
std::fs::create_dir_all(root.join("alice/state")).unwrap();
|
||||||
|
// Plant a symlink that points anywhere; resolve_state_path's
|
||||||
|
// canonicalize would happily resolve it past the allow-list
|
||||||
|
// check, so we have to refuse at the un-canonical layer.
|
||||||
|
symlink("/etc/shadow", root.join("alice/state/peek")).unwrap();
|
||||||
|
let err = reject_symlinks_below(&root, &root.join("alice/state/peek")).unwrap_err();
|
||||||
|
assert!(err.contains("symlink at"), "msg = {err}");
|
||||||
|
assert!(err.contains("peek"), "msg = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_symlinks_below_rejects_directory_symlink_in_middle() {
|
||||||
|
let root = tmproot("symlink-mid");
|
||||||
|
std::fs::create_dir_all(root.join("real/state")).unwrap();
|
||||||
|
std::fs::write(root.join("real/state/secret.md"), b"hi").unwrap();
|
||||||
|
// alice's "state" dir is actually a symlink to real/state — a
|
||||||
|
// sub-agent shouldn't be able to plant this and proxy real's
|
||||||
|
// private files via the dashboard.
|
||||||
|
std::fs::create_dir_all(root.join("alice")).unwrap();
|
||||||
|
symlink(root.join("real/state"), root.join("alice/state")).unwrap();
|
||||||
|
let err = reject_symlinks_below(&root, &root.join("alice/state/secret.md")).unwrap_err();
|
||||||
|
assert!(err.contains("symlink at"), "msg = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_symlinks_below_rejects_parent_dir_traversal() {
|
||||||
|
let root = tmproot("symlink-dotdot");
|
||||||
|
// `..` doesn't survive canonicalize anyway, but we want a
|
||||||
|
// friendlier error than "path escapes allow-list" — refusing
|
||||||
|
// upfront also avoids walking ancestors with `symlink_metadata`.
|
||||||
|
let p = root.join("alice/state/../escape");
|
||||||
|
let err = reject_symlinks_below(&root, &p).unwrap_err();
|
||||||
|
assert!(err.contains("`..`"), "msg = {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reject_symlinks_below_passes_through_when_path_not_under_root() {
|
||||||
|
// resolve_state_path's earlier allow-list check would reject
|
||||||
|
// this; reject_symlinks_below stays a no-op so the caller
|
||||||
|
// surfaces the better-fit error.
|
||||||
|
let root = std::path::Path::new("/var/lib/hyperhive/agents");
|
||||||
|
assert!(reject_symlinks_below(root, std::path::Path::new("/etc/shadow")).is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
116
hive-c0re/src/dashboard/topology.rs
Normal file
116
hive-c0re/src/dashboard/topology.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
//! Topology (set-parent) endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Operator-driven agent reparenting — single (`/api/topology/set-parent`,
|
||||||
|
//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array →
|
||||||
|
//! one git commit). Both go through `Coordinator::reparent*_with_notify`,
|
||||||
|
//! which wraps `topology::set_parent` with the move-notification messages
|
||||||
|
//! and the `ContainerView` rescan.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent` body. `child` is required.
|
||||||
|
/// `new_parent` may be:
|
||||||
|
/// - absent or empty / whitespace-only → promote to root,
|
||||||
|
/// - non-empty → new parent's logical name.
|
||||||
|
///
|
||||||
|
/// (The CLI surface gates "no parent specified" behind an explicit
|
||||||
|
/// `--root` flag for safety; the HTTP surface is permissive
|
||||||
|
/// because the dashboard form encodes "no value" as the empty
|
||||||
|
/// string for the optional radio-group input.)
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetParentForm {
|
||||||
|
child: String,
|
||||||
|
new_parent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
||||||
|
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetParentBulkEntry {
|
||||||
|
child: String,
|
||||||
|
#[serde(default)]
|
||||||
|
new_parent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent` — operator-driven parent move.
|
||||||
|
/// Form fields: `child` (required, agent name), `new_parent`
|
||||||
|
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
||||||
|
/// cycles and unknown agents. The manager is reparentable like any
|
||||||
|
/// other agent — its privileges come from the privileged MCP socket,
|
||||||
|
/// not its tree position. On success
|
||||||
|
/// re-emits container snapshots so the dashboard tree repaints
|
||||||
|
/// without a refresh.
|
||||||
|
pub(super) async fn post_set_parent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(form): Form<SetParentForm>,
|
||||||
|
) -> Response {
|
||||||
|
let child = form.child.trim().to_owned();
|
||||||
|
if child.is_empty() {
|
||||||
|
return error_response("set-parent: `child` required");
|
||||||
|
}
|
||||||
|
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
||||||
|
// forms submit the empty string for a "no value" radio button,
|
||||||
|
// so this is the ergonomic encoding.
|
||||||
|
let new_parent = form
|
||||||
|
.new_parent
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
// `reparent_with_notify` wraps `topology::set_parent` with the
|
||||||
|
// three notification messages + the ContainerView rescan.
|
||||||
|
// Idempotent same-parent calls skip both the messages and the
|
||||||
|
// disk write per the topology fast-path.
|
||||||
|
match state
|
||||||
|
.coord
|
||||||
|
.reparent_with_notify(&child, new_parent.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
child = %child,
|
||||||
|
new_parent = ?new_parent,
|
||||||
|
"operator: set-parent via dashboard"
|
||||||
|
);
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single
|
||||||
|
/// request, producing **one** git commit. JSON body: `[{"child":"name",
|
||||||
|
/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK).
|
||||||
|
/// First validation error aborts the whole batch.
|
||||||
|
pub(super) async fn post_set_parent_bulk(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
||||||
|
) -> Response {
|
||||||
|
if body.is_empty() {
|
||||||
|
return (StatusCode::OK, "ok").into_response();
|
||||||
|
}
|
||||||
|
// Collect borrows for the coordinator call.
|
||||||
|
let moves: Vec<(&str, Option<&str>)> = body
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let child: &str = &e.child;
|
||||||
|
let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty());
|
||||||
|
(child, parent)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match state.coord.reparent_bulk_with_notify(&moves).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
||||||
|
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("set-parent-bulk failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
64
hive-c0re/src/dashboard/webhook.rs
Normal file
64
hive-c0re/src/dashboard/webhook.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
//! Forgejo push-webhook endpoint for the `internal/knowledge` repo.
|
||||||
|
//!
|
||||||
|
//! Loopback-only; on a push to `main` of the knowledge repo it triggers a
|
||||||
|
//! read-only `git pull` on the local clone so agents see up-to-date
|
||||||
|
//! documents on their next turn.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// Minimal Forgejo push-webhook payload — only the fields we care about.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct PushWebhookPayload {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
git_ref: Option<String>,
|
||||||
|
repository: Option<PushWebhookRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct PushWebhookRepo {
|
||||||
|
full_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST `/webhook/knowledge` — Forgejo push webhook for
|
||||||
|
/// `internal/knowledge`. Runs `git pull` on the local clone so
|
||||||
|
/// agents see up-to-date documents on their next turn.
|
||||||
|
///
|
||||||
|
/// Expected Forgejo webhook configuration:
|
||||||
|
/// - URL: `http://127.0.0.1:<dashboard_port>/webhook/knowledge`
|
||||||
|
/// - Event: "Push" (fires on merge commits to main as well)
|
||||||
|
///
|
||||||
|
/// No signature verification for now; the endpoint is loopback-only
|
||||||
|
/// and only triggers a read-only `git pull` on an operator-curated repo.
|
||||||
|
pub(super) async fn post_webhook_knowledge(
|
||||||
|
axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>,
|
||||||
|
) -> Response {
|
||||||
|
let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO);
|
||||||
|
let full_name = payload
|
||||||
|
.repository
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.full_name.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
if full_name != expected_repo {
|
||||||
|
tracing::debug!(
|
||||||
|
full_name,
|
||||||
|
"webhook/knowledge: ignoring push from unexpected repo"
|
||||||
|
);
|
||||||
|
return (StatusCode::OK, "ignored").into_response();
|
||||||
|
}
|
||||||
|
let git_ref = payload.git_ref.as_deref().unwrap_or("");
|
||||||
|
if git_ref != "refs/heads/main" {
|
||||||
|
tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push");
|
||||||
|
return (StatusCode::OK, "ignored").into_response();
|
||||||
|
}
|
||||||
|
tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}");
|
||||||
|
tokio::spawn(async {
|
||||||
|
if let Err(e) = crate::knowledge::pull().await {
|
||||||
|
tracing::warn!(error = ?e, "webhook/knowledge: pull failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
@ -31,7 +31,6 @@ let
|
||||||
services.hyperhive.enable = lib.mkForce false;
|
services.hyperhive.enable = lib.mkForce false;
|
||||||
services.hyperhive.forge.enable = lib.mkForce false;
|
services.hyperhive.forge.enable = lib.mkForce false;
|
||||||
services.hyperhive.matrix.enable = lib.mkForce false;
|
services.hyperhive.matrix.enable = lib.mkForce false;
|
||||||
services.hyperhive.gateway.enable = lib.mkForce false;
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@
|
||||||
# frontend/packages/dashboard/build.mjs):
|
# frontend/packages/dashboard/build.mjs):
|
||||||
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA,
|
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA,
|
||||||
# served at /dashboard.html) flow.html logs.html settings.html
|
# served at /dashboard.html) flow.html logs.html settings.html
|
||||||
# favicon.svg
|
# stats.html favicon.svg
|
||||||
# static/{home,tabs,flow,logs,settings,stream-worker}.js{,.map}
|
# static/{home,tabs,flow,logs,settings,stats,stream-worker}.js{,.map}
|
||||||
# static/{colors,theme,common,home,dashboard,flow,logs,settings}.css
|
# static/{colors,theme,common,home,dashboard,flow,logs,settings,stats}.css
|
||||||
# $out/agent/ the per-agent default UI (layered with
|
# $out/agent/ the per-agent default UI (layered with
|
||||||
# hyperhive.frontend.extraFiles at activation time)
|
# hyperhive.frontend.extraFiles at activation time)
|
||||||
# index.html stats.html screen.html
|
# index.html stats.html screen.html
|
||||||
|
|
|
||||||
|
|
@ -634,15 +634,9 @@ in
|
||||||
};
|
};
|
||||||
users.groups.hive-core = { };
|
users.groups.hive-core = { };
|
||||||
|
|
||||||
# Open the per-agent web-port range when the gateway is *off* —
|
# The gateway nginx is always the sole external entry point (it runs
|
||||||
# otherwise the gateway nginx is the sole external entry point.
|
# alongside hyperhive), so the per-agent web-port range stays closed on
|
||||||
# See `docs/gateway.md::Firewall posture (host-level)`.
|
# the host firewall. See `docs/gateway.md::Firewall posture (host-level)`.
|
||||||
networking.firewall.allowedTCPPortRanges = lib.mkIf (!config.services.hyperhive.gateway.enable) [
|
|
||||||
{
|
|
||||||
from = 8100;
|
|
||||||
to = 8999;
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# WireGuard inter-hive mesh. Enabled when
|
# WireGuard inter-hive mesh. Enabled when
|
||||||
# `services.hyperhive.swarm.wireguard.enable = true`. Brings up a
|
# `services.hyperhive.swarm.wireguard.enable = true`. Brings up a
|
||||||
|
|
@ -777,9 +771,11 @@ in
|
||||||
# docs/gateway.md::Vhost map.
|
# docs/gateway.md::Vhost map.
|
||||||
HIVE_MATRIX_GUI_ENABLED = "1";
|
HIVE_MATRIX_GUI_ENABLED = "1";
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs config.services.hyperhive.gateway.enable {
|
// {
|
||||||
# When true the dashboard builds same-origin `/agent/<name>/`
|
# The gateway always runs, so the dashboard always builds
|
||||||
# links; when false it falls back to direct `<host>:<port>` TCP.
|
# same-origin `/agent/<name>/` links (never the direct
|
||||||
|
# `<host>:<port>` TCP fallback). Kept as an env flag so the
|
||||||
|
# dashboard doesn't need to learn the gateway is unconditional.
|
||||||
HIVE_GATEWAY_ENABLED = "1";
|
HIVE_GATEWAY_ENABLED = "1";
|
||||||
}
|
}
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,8 @@ in
|
||||||
|
|
||||||
behindGateway = lib.mkOption {
|
behindGateway = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = gatewayCfg.enable or false;
|
default = config.services.hyperhive.enable;
|
||||||
defaultText = lib.literalExpression "config.services.hyperhive.gateway.enable";
|
defaultText = lib.literalExpression "config.services.hyperhive.enable";
|
||||||
description = ''
|
description = ''
|
||||||
Serve forgejo through the hive-gateway nginx as a sub-domain
|
Serve forgejo through the hive-gateway nginx as a sub-domain
|
||||||
vhost (`server_name = cfg.domain`) instead of directly on
|
vhost (`server_name = cfg.domain`) instead of directly on
|
||||||
|
|
@ -127,9 +127,9 @@ in
|
||||||
- `gateway.localHostsEntry = true` extends `/etc/hosts` to
|
- `gateway.localHostsEntry = true` extends `/etc/hosts` to
|
||||||
include `cfg.domain → 127.0.0.1` for local dev.
|
include `cfg.domain → 127.0.0.1` for local dev.
|
||||||
|
|
||||||
Defaults to `services.hyperhive.gateway.enable` — flipping
|
Defaults to `services.hyperhive.enable` (the gateway always runs
|
||||||
the gateway on/off auto-routes forge through it. Set `false`
|
alongside hyperhive, so forge auto-routes through it). Set `false`
|
||||||
explicitly to keep forge on the direct port even when the
|
explicitly to keep forge on the direct port even though the
|
||||||
gateway is running (e.g. an external git client that doesn't
|
gateway is running (e.g. an external git client that doesn't
|
||||||
traverse the gateway).
|
traverse the gateway).
|
||||||
|
|
||||||
|
|
@ -211,21 +211,6 @@ in
|
||||||
or "git.internal".
|
or "git.internal".
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
{
|
|
||||||
# behindGateway requires the gateway module to actually be on.
|
|
||||||
# Otherwise the configured `ROOT_URL` flips to a sub-domain
|
|
||||||
# shape that has no nginx vhost backing it → broken on the
|
|
||||||
# rebuild.
|
|
||||||
assertion = !cfg.behindGateway || (gatewayCfg.enable or false);
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.forge.behindGateway = true requires
|
|
||||||
services.hyperhive.gateway.enable = true (the gateway vhost
|
|
||||||
serving forge needs the gateway container to actually be
|
|
||||||
running). Either turn the gateway on, or set
|
|
||||||
services.hyperhive.forge.behindGateway = false to keep forge
|
|
||||||
on its direct port.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
containers.hive-forge = {
|
containers.hive-forge = {
|
||||||
|
|
|
||||||
|
|
@ -69,22 +69,11 @@ in
|
||||||
# `docs/gateway.md`.
|
# `docs/gateway.md`.
|
||||||
|
|
||||||
options.services.hyperhive.gateway = {
|
options.services.hyperhive.gateway = {
|
||||||
enable = lib.mkOption {
|
# The gateway is always run alongside hyperhive (it's the single nginx
|
||||||
type = lib.types.bool;
|
# in front of every surface and the only thing exposed to the outside);
|
||||||
default = true;
|
# there is no enable flag. An operator who wants their own reverse proxy
|
||||||
description = ''
|
# in front points it at the gateway's `port`. The gateway config below
|
||||||
Run hive-gateway — a single nginx in front of every hyperhive
|
# is gated on the top-level `services.hyperhive.enable`.
|
||||||
surface. On by default: the gateway hosts the matrix GUI static
|
|
||||||
dist (when `services.hyperhive.matrix.gui.enable` is true) and
|
|
||||||
proxies everything else to hive-c0re's dashboard upstream. Set
|
|
||||||
`services.hyperhive.gateway.enable = false` to bypass nginx
|
|
||||||
entirely and reach hive-c0re directly on its dashboard port
|
|
||||||
(7000 by default).
|
|
||||||
|
|
||||||
v0 is HTTP-only; TLS / public-domain shape is tracked
|
|
||||||
separately.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
port = lib.mkOption {
|
port = lib.mkOption {
|
||||||
type = lib.types.port;
|
type = lib.types.port;
|
||||||
|
|
@ -375,7 +364,7 @@ in
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf config.services.hyperhive.enable {
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
|
assertion = !cfg.localHostsEntry || hyperhiveDomain != null;
|
||||||
|
|
|
||||||
|
|
@ -248,8 +248,8 @@ in
|
||||||
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
|
defaultText = lib.literalExpression "config.services.hyperhive.matrix.enable";
|
||||||
description = ''
|
description = ''
|
||||||
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
|
Serve a matrix web client at `matrix.''${services.hyperhive.domain}/`.
|
||||||
Requires `gateway.enable` + `matrix.gatewayHost != null`
|
Requires `matrix.gatewayHost != null` (default `matrix.<hive>`
|
||||||
(default true / `matrix.<hive>` when hive-domain set). When
|
when hive-domain set); the gateway itself always runs. When
|
||||||
off, the dashboard's `M4TR1X →` tab is hidden. See
|
off, the dashboard's `M4TR1X →` tab is hidden. See
|
||||||
`docs/gateway.md` for the discovery flow that lets clients
|
`docs/gateway.md` for the discovery flow that lets clients
|
||||||
auto-find the sub-domain.
|
auto-find the sub-domain.
|
||||||
|
|
@ -435,9 +435,9 @@ in
|
||||||
# boot failure this module fixes was an *empty* resolv.conf, a parse
|
# boot failure this module fixes was an *empty* resolv.conf, a parse
|
||||||
# error, not a connectivity one) — so this is robustness, not a boot
|
# error, not a connectivity one) — so this is robustness, not a boot
|
||||||
# requirement. Soft `after` ordering (not `requires`) keeps the matrix
|
# requirement. Soft `after` ordering (not `requires`) keeps the matrix
|
||||||
# container's lifecycle decoupled from the gateway's. `network.enable`
|
# container's lifecycle decoupled from the gateway's. The gateway
|
||||||
# asserts `gateway.enable`, so the gateway container unit always exists
|
# always runs alongside hyperhive, so the gateway container unit always
|
||||||
# here. (Declarative `containers.<n>` → `container@<n>.service` — the
|
# exists here. (Declarative `containers.<n>` → `container@<n>.service` — the
|
||||||
# nspawn template NixOS generates, confirmed from the live
|
# nspawn template NixOS generates, confirmed from the live
|
||||||
# `container@hive-matrix.service` host unit.)
|
# `container@hive-matrix.service` host unit.)
|
||||||
systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [
|
systemd.services."container@hive-matrix".after = lib.mkIf networkCfg.enable [
|
||||||
|
|
|
||||||
|
|
@ -154,16 +154,6 @@ in
|
||||||
`services.hyperhive.network.enable = false` explicitly.
|
`services.hyperhive.network.enable = false` explicitly.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
{
|
|
||||||
assertion = config.services.hyperhive.gateway.enable;
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.network.enable = true requires
|
|
||||||
services.hyperhive.gateway.enable = true — the dnsmasq
|
|
||||||
resolver runs inside the hive-gateway container (single
|
|
||||||
front-door for both DNS and HTTP). Enable the gateway or
|
|
||||||
set `services.hyperhive.network.enable = false` explicitly.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# Virtual bridge — veth pairs attach when isolateContainers flips on.
|
# Virtual bridge — veth pairs attach when isolateContainers flips on.
|
||||||
|
|
@ -196,17 +186,6 @@ in
|
||||||
resolver must be running before isolation is flipped on).
|
resolver must be running before isolation is flipped on).
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
{
|
|
||||||
assertion = !config.services.hyperhive.forge.enable || config.services.hyperhive.gateway.enable;
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.network.isolateContainers = true with
|
|
||||||
services.hyperhive.forge.enable = true requires
|
|
||||||
services.hyperhive.gateway.enable = true — isolated agents
|
|
||||||
reach the forge via `http://forge.<domain>` which nginx (in
|
|
||||||
the gateway container) proxies to forgejo. Without the gateway
|
|
||||||
there is nothing listening on port 80 to serve that hostname.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue