Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: remove the flow page and the endpoints only it used

The flow page showed the raw broker message stream and had a compose box.
This removes the page and its bundle, its link in the pages menu, and the
styles only it used.

The history endpoint that backfilled it and the operator-send endpoint
behind the compose box had no other caller, so they go too, along with
the broker query behind the history endpoint. The stream endpoint stays:
the dashboard tabs still subscribe to it.

The docs lose the FL0W section and the references to the two endpoints.
This commit is contained in:
iris 2026-10-21 22:19:13 +02:00 • committed by mara
commit fa131b46a6
24 changed files with 62 additions and 1121 deletions

View file

@ -12,7 +12,7 @@ what you actually do here.
Everything starts at the **dashboard**, served at `/`. A pages menu
(the `☰` button at the left of the tab strip) links to the other
surfaces — Flow, Stats, the API docs and the swarm UI — and each of
surfaces — Stats, the API docs and the swarm UI — and each of
those pages links back to the dashboard.
The dashboard is where you'll spend most of your time. It's a single
@ -26,9 +26,8 @@ page with five tabs:
- **K3PT ST4T3** — kept state from agents with no container
(tombstones), with a purge button per entry.
Two things live on their own page instead, reached from the pages
menu: **Flow** (`/flow.html`, the raw live message stream across the
whole swarm) and **Stats** (`/stats.html`, swarm-wide usage stats).
Usage stats live on their own page, reached from the pages menu:
**Stats** (`/stats.html`, swarm-wide usage stats).
Each agent also has its own page — a full terminal view of that
agent's session, reachable by selecting its name anywhere in the

View file

@ -12,7 +12,7 @@ swap touches only the first:
what the app references and it **never changes on a swap**.
Both files live in `frontend/packages/shared/src/`. Per-page stylesheets
(`common.css`, `dashboard.css`, `flow.css`, `agent.css`)
(`common.css`, `dashboard.css`, `agent.css`)
reference the **semantic** names and must **not** redeclare them or reach
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
`body` typography — it references the palette but no longer defines it.)

View file

@ -12,21 +12,21 @@ hive-c0re serves the dashboard at `/`.
It has a fixed chrome header at the top and a `<main>` that shows exactly
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
`#buildlogs`, `#kept`) drives which pane is
active; hash changes don't reload the page. FL0W and ST4TS
are standalone pages reached from the pages menu, not from the
active; hash changes don't reload the page. ST4TS
is a standalone page reached from the pages menu, not from the
dashboard tab strip.
**Chrome header** (fixed, overlays the active tab pane):
- **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
opening a popover with links to **flow** (`/flow.html`), **stats**
opening a popover with links to **stats**
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
UI at the swarm apex domain, opened in a new tab). The swarm entry
comes from `state.swarm_public_url`, which hive-c0re reads from
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
with no domain set there is no swarm entry. The standalone pages link back to `/`.
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
`◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — FL0W / ST4TS
live on their own pages, reached from the pages menu. Count pills on
`◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — ST4TS
lives on its own page, reached from the pages menu. Count pills on
4G3NTS (stale containers), R3BU1LD QU3U3 (rebuilds in flight) and
K3PT ST4T3 (kept-state entries).
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
@ -46,10 +46,6 @@ dashboard tab strip.
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot.
The FL0W page uses a slim header (a `← dashboard` back-link + the page
title) rather than the dashboard tab strip — it's a standalone surface,
not a tab pane.
## 4G3NTS tab
**C0NTAINERS** — live containers as a flat list, one row per
@ -212,7 +208,7 @@ entry with that id pre-expanded.
Hive-wide turn statistics, aggregated across every agent's
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
tab — a standalone page reached from the dashboard's pages menu,
same minimal chrome as `/flow.html`. Distinct from
minimal chrome (a back-link and a title). Distinct from
each agent's own `/stats` page (which carries the per-agent trend
charts): ST4TS is the swarm-level rollup.
@ -249,42 +245,6 @@ the model id, longest match wins) mapping to
`{ input, output, cache_read, cache_write }` USD-per-million-token
prices. Models not covered fall back to hive-c0re's built-in estimate.
## FL0W page (`/flow.html`)
A dedicated full-page terminal (not a tab pane — a separate HTML
page). Slim chrome: a `← dashboard` back-link, the FL0W
title, and the agent-filter select (see below). No dashboard tab
strip — FL0W is a standalone surface reached from the pages menu.
**MESS4GE FL0W** — live broker tail wrapped in a `.terminal-wrap`.
Cold load backfills the last ~200 messages from `/api/dashboard/history`;
live frames arrive on `/api/dashboard/stream`. Each row is one broker
event — `sent` or `delivered` — with `from → to: body`. When a `sent`
and `delivered` event for the same message arrive within 3 seconds
(immediate delivery to a live recipient), the page upgrades the row in
place (arrow becomes green ✓, title reads "sent + delivered") instead of
rendering two near-identical lines — genuine delivery latency (recipient
was busy) still appears as a second row. Each row carries `data-from` /
`data-to` attributes; an **agent filter select in the FL0W header** narrows
the timeline to messages involving the chosen agent (matched on `from` OR
`to`), with non-matching rows hidden (`.flow-hidden` class). The selection
persists in localStorage across reloads; new rows pick up the active filter
at render time. The dropdown populates from the live container list and
stays current on add/remove; a saved selection survives even if that agent
isn't currently listed.
The row is a `flex-wrap: wrap` container holding ts / arrow / from / sep
/ to chips inline; the **body wraps to its own full-width line below** the
chips (`flex: 1 1 100%`) so long timestamps and agent names never push the
body into a narrow trailing column. `min-width: 0` keeps
`word-break: break-word` effective so the body doesn't force the row wider
than its container. Sticky-bottom autoscroll + "↓ N new" pill. Below the
stream sits a terminal-style compose box: `@name` picks the recipient
(sticky via localStorage; autocomplete from the live container list,
Tab/Enter to confirm; `@*` broadcasts). `POST /api/op-send` drops
`{from:"operator", to, body}` into the broker; the resulting SSE frame
re-renders the terminal row. The operator addresses the root agent as `@root`.
## Container row
A full-height **square agent icon** (5em, capped) on the left. The
@ -577,11 +537,6 @@ below — some endpoints aren't in it yet.
not yet delivered and sets `acked_at = now`. Returns
`{ "marked": N }`. Agent name validated against
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
- `POST /api/op-send` (`to=<name>`, `body=<text>`) — drop an
operator-authored message into `<name>`'s inbox. `to=*` fans
out to every registered agent. Returns 200; the broker
`Sent` event re-renders the message-flow terminal without a
snapshot refetch. Used by the compose textbox under MESS4GE FL0W.
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
headers for one agent, newest first. Returns
`Vec<BuildLogHeader>` (JSON): `id`, `agent`, `kind`, `cmdline`,
@ -621,8 +576,7 @@ below — some endpoints aren't in it yet.
The client trusts that list and linkifies only those tokens,
so directories, missing files, and forbidden subtrees never
become anchors. No probe endpoint, no client-side regex
heuristics. Historical messages get the same treatment on
`/api/dashboard/history` backfill.
heuristics.
- `GET /api/reminders` — list pending reminders for the
dashboard's queued-reminders panel.
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide
@ -639,9 +593,6 @@ below — some endpoints aren't in it yet.
- `GET /api/dashboard/stream` — unified live event channel:
broker `sent` / `delivered`, plus the mutation events listed
below. Each frame carries `seq`.
- `GET /api/dashboard/history` — last ~200 broker messages
(wrapped as `{ seq, events }`) for the message-flow
terminal's backfill on page load.
<!-- vale write-good.Passive = YES -->
## Dashboard event channel
@ -652,9 +603,7 @@ payload):
- `sent` / `delivered` — broker traffic, mirrored from the
intra-process channel by a forwarder task. Both carry `id: i64`
(the broker row id) and `in_reply_to: Option<i64>` for thread
rendering. The dashboard message-flow terminal renders reply
rows with a `↳ reply` tag that scroll-highlights the parent
row on selection. Used by the message-flow terminal renderer.
rendering.
- `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix`
@ -696,7 +645,7 @@ payload):
forms that mutate non-event-derived state** (PURG3 +
meta-update, since tombstones + meta_inputs aren't event-
shaped yet). Every other section — transients,
containers, message flow —
containers —
derives from `/api/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on
top.

View file

@ -19,15 +19,15 @@
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
loaded by every page, plus a page-specific bundle —
`dashboard.css` / `flow.css` /
`dashboard.css` /
`stats.css`);
`common.css` inlines `@hive/shared`'s
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
`pill.css` via esbuild's `@import` resolution.
`terminal.js` exports `{ create, linkify }` as ES module
members; a back-compat shim in the IIFE bodies still exposes
a `window.HiveTerminal` global for callers that need it. The dashboard's
`#msgflow` and the per-agent `#live` log are both backed by
a `window.HiveTerminal` global for callers that need it. The
per-agent `#live` log is backed by
this terminal — sticky-bottom autoscroll, "↓ N new" pill,
history backfill, SSE plumbing all live there. Each page
registers a kind→renderer map; unknown kinds fall through to
@ -51,10 +51,8 @@
dashboard stream carries broker `Sent` / `Delivered` (mirrored
by a forwarder task from the broker's intra-process channel)
plus mutation events (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
matching backfill endpoint is `GET /api/dashboard/history` (last
~200 broker messages wrapped in `{ seq, events }`) on the
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
also wrapped in `{ seq, events }`) on the agent.
agent's matching backfill endpoint is `GET /events/history` (last 2000
`LiveEvent`s wrapped in `{ seq, events }`).
**One unified channel**: browsers cap concurrent SSE
connections per origin (~6 in Chrome). Using one channel per
domain would exhaust this budget on a live hive; dispatching
@ -87,9 +85,8 @@
## Shared terminal pane
The shared terminal factory in `@hive/shared/terminal.js` backs both
surfaces' scrollable log streams (`#msgflow` on the dashboard, `#live`
on the per-agent page). The factory wires up
The shared terminal factory in `@hive/shared/terminal.js` backs the
per-agent page's scrollable log stream (`#live`). The factory wires up
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
SSE replay. Pages register a `kind → renderer` map; unknown kinds
fall through to a JSON-dump note row. The factory ships three row

View file

@ -69,7 +69,3 @@ read that when you need the specifics, this doc doesn't duplicate it.
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
any `TermMsg` with `body_format: "markdown"`.
## Dashboard side (not covered here)
The main dashboard's message-flow pane is a different shape: broker
messages render as `.msgrow` grid lines, not agent-terminal rows.

View file

@ -1,5 +1,5 @@
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
// dashboard at `/`, flow/stats at their own
// dashboard at `/`, stats at its own
// `.html`), each named after its own entry point below — see the
// `entryPoints`/`for` lists for the exact map, not repeated here to
// avoid this comment drifting out of sync with the real build steps.
@ -29,7 +29,7 @@ mkdirSync(staticDir(""), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src("tabs.js"), src("flow.js"), src("stats.js")],
entryPoints: [src("tabs.js"), src("stats.js")],
outdir: staticDir(""),
bundle: true,
format: "esm",
@ -58,7 +58,7 @@ await build({
// Stream-worker entry. Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no
// `window`) so they can't be inlined into tabs.js / flow.js. Output is
// `window`) so they can't be inlined into tabs.js / stats.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
// matches the classic-script load (`new SharedWorker(url, name)` with
@ -89,7 +89,6 @@ for (const entry of [
"theme.css",
"common.css",
"dashboard.css",
"flow.css",
"stats.css",
]) {
await build({
@ -101,7 +100,7 @@ for (const entry of [
});
}
for (const html of ["index.html", "flow.html", "stats.html"]) {
for (const html of ["index.html", "stats.html"]) {
copyFileSync(src(html), dist(html));
}

View file

@ -588,7 +588,7 @@ function buildContainerLi(c, opts) {
export function renderContainers(s) {
const root = $("containers-section");
// #containers-section only exists on the dashboard. tabs.js is the
// bundle for that page only (flow.html loads flow.js instead).
// bundle for that page only.
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers

View file

@ -337,148 +337,9 @@ code {
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
at the top of this file. */
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
msgrow / msg-* rules below power the /flow.html full-page terminal. */
.live .msgrow {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em;
padding: 0.1em 0;
text-indent: 0;
}
.live .msgrow .msg-body {
flex: 1 1 100%;
min-width: 0;
}
.live .msgrow.sent .msg-arrow {
color: var(--cyan);
}
.live .msgrow.delivered .msg-arrow {
color: var(--green);
}
/* Reply-thread rendering: indented border-left + muted reply tag. */
.live .msgrow.msg-reply {
padding-left: 1.2em;
border-left: 2px solid var(--border);
margin-left: 0.6em;
}
.msg-reply-tag {
color: var(--muted);
font-size: 0.8em;
white-space: nowrap;
order: -1;
}
.msg-reply-tag a {
color: var(--muted);
text-shadow: none;
font-weight: normal;
}
.msg-reply-tag a:hover {
color: var(--fg);
}
@keyframes msg-highlight-fade {
from {
background: color-mix(in srgb, var(--purple) 18%, transparent);
}
to {
background: transparent;
}
}
.msg-highlight {
animation: msg-highlight-fade 1.5s ease-out forwards;
}
.msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.msg-arrow {
font-weight: bold;
}
.msg-from {
color: var(--amber);
}
.msg-sep {
color: var(--muted);
}
.msg-to {
color: var(--pink);
}
.msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
/* ─── operator compose box ──────────────────────────────────────────
Sits inside `.terminal-wrap` on both /flow.html and the dashboard
CALL tab. Dashed top separator mirrors the agent terminal prompt. */
.op-compose {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.6em;
padding: 0.55em 0.8em;
border-top: 1px dashed var(--purple-dim);
}
.op-compose-prompt {
color: var(--purple);
text-shadow: 0 0 4px currentColor;
font-weight: bold;
white-space: nowrap;
user-select: none;
padding-top: 0.15em;
}
.op-compose-input {
flex: 1;
background: transparent;
color: var(--fg);
border: none;
font-family: inherit;
font-size: 1em;
line-height: 1.4;
resize: none;
overflow: hidden;
max-height: 8em;
padding: 0;
}
.op-compose-input:focus {
outline: none;
}
.op-compose-input::placeholder {
color: var(--muted);
}
.op-compose-suggest {
position: absolute;
left: 3em;
bottom: 100%;
z-index: 50;
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
list-style: none;
margin: 0;
padding: 0.3em 0;
min-width: 12em;
max-height: 14em;
overflow-y: auto;
}
.op-compose-suggest .item {
padding: 0.35em 0.9em;
cursor: pointer;
font-size: 0.9em;
color: var(--muted);
}
.op-compose-suggest .item.active,
.op-compose-suggest .item:hover {
background: var(--border);
color: var(--purple);
}
/* ─── side panel ─────────────────────────────────────────────────
Long content (file previews, diffs, applied config) opens in
a drawer that swipes in from the right — used by both /flow.html and
a drawer that swipes in from the right on
the dashboard. The drawer chrome itself (backdrop, drawer, resize
handle, header, title, close button) is now the shared
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left
@ -566,8 +427,8 @@ hive-side-panel .md td {
carries the horizontal gutter. Full-width chrome (the tab strip, 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 (index.html); FL0W / ST4TS
are full-bleed with their own `.<page>-main` padding. */
in by adding the wrapper (index.html); ST4TS
is full-bleed with its own `.<page>-main` padding. */
.page-content {
padding: 0 1.5em;
}

View file

@ -1,6 +1,6 @@
// Shared dashboard helpers used by both index.html (./tabs.js) and
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
// and the path-link / file-preview infrastructure for the side panel.
// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
// the side-panel singleton, and the path-link / file-preview infrastructure
// for the side panel.
import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js";

View file

@ -180,8 +180,6 @@ body.dashboard-shell {
display: none;
}
/* FL0W is a separate page (`/flow.html`) — its full-viewport
styling lives in flow.css. */
.banner {
text-align: center;
margin: 0 0 1em 0;

View file

@ -1,111 +0,0 @@
/* ─── /flow.html — full-page chat ─────────────────────────────────
See docs/web-ui/dashboard.md::FL0W page for the page-vs-pane rationale.
Shape mirrors the per-agent live page (frosted-glass header +
composer, full-viewport terminal). */
:root {
/* Approximate height of the flow chrome (the slim back-link header).
The slug banner lives at the page footer on the dashboard, omitted
on /flow.html since the full-viewport terminal has no normal-flow
footer position. */
--flow-header-h: 3.6em;
--flow-composer-h: 3.6em;
--flow-frost-bg: color-mix(in srgb, var(--bg) 74%, transparent);
--flow-frost-blur: blur(12px) saturate(140%);
}
body.flow-shell {
/* Override the dashboard's centred-max-width layout — flow owns
the whole viewport. */
max-width: none;
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
background:
radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg);
}
.flow-main {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
/* flow.html now has a slim header (back link only) instead of the full
tabbar — reduce the top padding to match the smaller header height. */
body.flow-shell .flow-main-slim {
padding-top: calc(var(--flow-header-h) + 0.4em);
}
.flow-main .terminal-wrap {
position: absolute;
inset: 0;
border: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
margin: 0;
padding: 0;
}
.flow-main .live {
position: absolute;
inset: 0;
height: auto;
max-height: none;
padding-top: calc(var(--flow-header-h) + 0.8em);
padding-bottom: calc(var(--flow-composer-h) + 0.8em);
scroll-padding-top: calc(var(--flow-header-h) + 0.8em);
scroll-padding-bottom: calc(var(--flow-composer-h) + 0.8em);
overflow: auto;
}
/* Tail pill (↓ N new): bottom offset clears the floating composer.
Pill is anchored on .flow-main (not .terminal-wrap) so the
backdrop-filter stacking context doesn't trap its z-index — see
docs/web-ui/agent.md::Per-agent page (Terminal-wrap) for the same
gotcha on the agent page. */
.flow-main .tail-pill {
bottom: calc(var(--flow-composer-h) + 0.6em);
z-index: 35;
}
.flow-composer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
min-height: var(--flow-composer-h);
background: var(--flow-frost-bg);
-webkit-backdrop-filter: var(--flow-frost-blur);
backdrop-filter: var(--flow-frost-blur);
border-top: 1px solid var(--purple-dim);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.35);
padding: 0.4em 1em;
}
/* Agent filter: a select in the FL0W header narrows the
timeline to messages involving one agent. Non-matching rows get
`.flow-hidden`. */
.flow-filter {
font-family: inherit;
font-size: 0.85em;
background: var(--bg-elev);
color: var(--amber);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.25em 0.5em;
margin-left: auto;
}
.flow-filter:focus {
outline: 1px solid var(--purple);
}
.msgrow.flow-hidden {
display: none;
}

View file

@ -1,63 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // FL0W</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/flow.css" />
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the dashboard (served at /).
No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back is the only chrome needed. -->
<header class="page-header">
<a class="page-back" href="/">← dashboard</a>
<span class="page-title">FL0W</span>
<select
id="flow-agent-filter"
class="flow-filter"
title="filter timeline by agent"
aria-label="filter timeline by agent"
>
<option value="">all agents</option>
</select>
</header>
<!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay
reachable. -->
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal">
<div class="meta">connecting…</div>
</div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge
between them. -->
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea
id="op-compose-input"
class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1"
autocomplete="off"
></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Flow-specific bundle. Contains the broker terminal init + the
@-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -1,479 +0,0 @@
// /flow.html entry point. Owns the full-page broker terminal and the
// @-mention compose box. Pulls shared infrastructure (DOM helpers,
// side panel, path linkification) from `./common.js`. FL0W is the pure
// event firehose.
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
// the dashboard. The flow page runs purely on the broker stream + an
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from "@hive/shared/terminal.js";
import { $, appendLinkified, openStream, initStickyTop } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => {
initStickyTop();
// ─── local containers cache (for compose autocomplete) ──────────────────
// The compose box's @-mention completion suggests known agent names.
// the dashboard (tabs.js) maintains the canonical `containersState`
// from /api/state + SSE; here we keep a small local mirror updated
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
const flowContainers = new Map();
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
})
.catch(() => {
/* graceful: compose just shows `*` and nothing else */
});
// ─── agent filter ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving
// one agent (matched on `from` OR `to`). Each rendered row carries
// `data-from` / `data-to`; non-matching rows get `.flow-hidden`. New rows
// pick up the active filter at render time (see renderMsg); changing the
// filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter;
}
function applyAgentFilter() {
const flow = $("msgflow");
if (!flow) return;
for (const row of flow.children) {
const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
}
}
function populateAgentFilter() {
const sel = $("flow-agent-filter");
if (!sel) return;
const names = [...flowContainers.keys()].sort();
sel.replaceChildren();
sel.append(el("option", { value: "" }, "all agents"));
for (const n of names) sel.append(el("option", { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) {
sel.append(el("option", { value: agentFilter }, agentFilter));
}
sel.value = agentFilter;
}
{
const sel = $("flow-agent-filter");
if (sel) {
sel.addEventListener("change", () => {
agentFilter = sel.value;
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
else localStorage.removeItem("flow-agent-filter");
applyAgentFilter();
});
}
}
// ─── message flow: shared terminal pane ────────────────────────────────
// Scroll, pill, backfill + SSE plumbing live in @hive/shared/terminal.
// What stays here is the broker-message renderer + the page-local
// side effect (banner pulse).
(() => {
const flow = $("msgflow");
if (!flow) return;
flow.replaceChildren();
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
// Pulse the page banner whenever a broker event lands. The
// `.banner` element lives in the dashboard's <footer> rather than
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector(".banner");
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
// Bounded by the history window (~200 msgs from /dashboard/history),
// well within normal memory.
const msgRowMap = new Map();
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, "");
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement("span");
ts.className = "msg-ts";
ts.textContent = tsFmt(ev.at);
const arrow = document.createElement("span");
arrow.className = "msg-arrow";
arrow.textContent = glyph;
const from = document.createElement("span");
from.className = "msg-from";
from.textContent = ev.from;
const sep = document.createElement("span");
sep.className = "msg-sep";
sep.textContent = "→";
const to = document.createElement("span");
to.className = "msg-to";
to.textContent = ev.to;
const body = document.createElement("span");
body.className = "msg-body";
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement("span");
replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement("a");
link.href = "#";
link.textContent = "↳ reply";
link.title = "scroll to parent message";
link.addEventListener("click", (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = "↳ reply";
}
row.prepend(replyTag);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else {
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
}
// Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
}
// Sent→Delivered collapse. When a message directly wakes its
// recipient the broker emits `sent` then `delivered` for the same row
// id back-to-back, rendering two near-identical lines. We track each
// recent `sent` row and, when its `delivered` lands within
// COLLAPSE_SECS, upgrade that row in place (✓) instead of adding a
// second line. A delivery that arrives *later* (recipient was busy)
// stays a separate row so the latency remains visible.
const COLLAPSE_SECS = 3;
const recentSent = new Map(); // broker row id → { row, at }
function rememberSent(ev, row) {
if (ev.id == null || ev.id <= 0) return;
recentSent.set(ev.id, { row, at: epochSec(ev.at) });
// Bound the map — drop the oldest entries once it grows past a
// page of un-collapsed sends (insertion order = oldest first).
while (recentSent.size > 256) {
recentSent.delete(recentSent.keys().next().value);
}
}
// Returns true if the delivered event was folded into its sent row.
function collapseDelivered(ev) {
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove("sent");
s.row.classList.add("delivered");
s.row.title = "sent + delivered";
recentSent.delete(ev.id);
return true;
}
// Anchor the `↓ N new` pill in `.flow-main` rather than the
// default `.terminal-wrap` parent — see docs/web-ui/agent.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector(".flow-main");
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
// Backend pre-parses the allow-list at subscribe time so the
// per-frame hot path is one HashSet::contains and the
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui/shape.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
// dashboard's tab renderers aren't on this page, so we don't
// need to dispatch to applyContainerStateChanged etc. — just
// keep the autocomplete list current.
container_state_changed: (ev) => {
if (ev.container && ev.container.name) {
flowContainers.set(ev.container.name, ev.container);
populateAgentFilter(); // keep the filter dropdown current
}
},
container_removed: (ev) => {
flowContainers.delete(ev.name);
populateAgentFilter();
},
// Drop every other mutation kind silently — without this
// they'd fall through to the terminal module's default
// renderer and clutter the log with JSON dumps. The dashboard
// tabs handle these on the dashboard via tabs.js.
_default: () => {},
},
// Re-sync the local containers cache on every SSE (re)connect.
// Live mutation events that fired during a disconnect window
// are never replayed, so without this the compose autocomplete
// could drift stale.
onStreamOpen: () => {
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
},
onLiveEvent: () => pulseBanner(),
});
})();
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $("op-compose-input");
const prompt = $("op-compose-prompt");
const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
const names = Array.from(flowContainers.values()).map((c) => c.name);
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift("*");
return names;
}
function autosize() {
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
/// with a known @-mention, otherwise null.
function parseAddressed(raw) {
const m = raw.match(/^@([\w*-]+)\s+([\s\S]+)$/);
if (!m) return null;
return { to: m[1], body: m[2] };
}
function hideSuggest() {
suggest.hidden = true;
suggest.replaceChildren();
suggestActive = -1;
}
function renderSuggest(matches) {
suggest.replaceChildren();
if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement("div");
item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = "@" + matches[i];
item.addEventListener("mousedown", (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
suggest.append(item);
}
suggest.hidden = false;
}
function applySuggestion(name) {
// Replace the partial @-token at the start with the full name.
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else {
input.value = `@${name} ` + v;
}
hideSuggest();
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autosize();
}
function updateSuggest() {
const v = input.value;
// Only suggest while the cursor is still inside the @-token at the
// very start of the input — switching recipient is always "redirect
// this whole line." Once whitespace follows the token the recipient
// is chosen: the list must close, or it swallows the Enter that
// should send. Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)$/);
if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) =>
n.toLowerCase().startsWith(partial),
);
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
const raw = input.value.trim();
if (!raw) return;
let to;
let body;
const addressed = parseAddressed(raw);
if (addressed) {
to = addressed.to;
body = addressed.body.trim();
} else if (stickyTo) {
to = stickyTo;
body = raw;
} else {
flashError("no recipient — start with @name to address a message");
return;
}
if (!body) return;
const fd = new FormData();
fd.append("to", to);
fd.append("body", body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch("/api/op-send", {
method: "POST",
body: new URLSearchParams(fd),
});
if (!resp.ok) {
flashError(`send failed: http ${resp.status}`);
return;
}
} catch (err) {
flashError(`send failed: ${err}`);
return;
} finally {
input.disabled = false;
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = "";
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $("msgflow");
if (!flow) return;
const row = document.createElement("div");
row.className = "msgrow meta";
row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view.
flow.append(row);
// Scroll the terminal wrap so the error is in view.
const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
input.addEventListener("input", () => {
autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) {
if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector(".item.active");
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === "Escape") {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});
renderPrompt();
autosize();
})();
})();

View file

@ -17,7 +17,7 @@
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Pages menu: the standalone pages (flow, stats, API docs) and the
<!-- Pages menu: the standalone pages (stats, API docs) and the
swarm UI. Filled by links-menu.js from /api/state. The pages link
back to `/`, which is this dashboard. -->
<div class="links-menu" id="links-menu"></div>
@ -82,7 +82,7 @@
<!-- (Peer hives are no longer a tab — they render as a headline
under 4G3NTS. See tab-pane-agents.) -->
<!-- FL0W and ST4TS are standalone pages reached from the pages
<!-- ST4TS is a standalone page reached from the pages
menu at the left of this strip, not tabs — the strip holds only
in-page tabs. -->
</nav>
@ -216,8 +216,8 @@
</div>
</section>
<!-- ST4TS and FL0W live on their own pages (`/stats.html`,
`/flow.html`). Their DOM only exists there — when tabs.js boots
<!-- ST4TS lives on its own page (`/stats.html`).
Its DOM only exists there — when tabs.js boots
on this page the corresponding renderers no-op silently (each
guard is `if (!el) return`). -->
</main>
@ -271,7 +271,7 @@
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>

View file

@ -1,5 +1,5 @@
// Pages menu in the dashboard chrome: one button that opens a popover
// linking the standalone pages (flow, stats, API docs) and the swarm UI.
// linking the standalone pages (stats, API docs) and the swarm UI.
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
// when `/api/state` carries `swarm_public_url`, so a hive without a
// configured swarm domain shows no dead link.
@ -10,7 +10,6 @@ import { el } from "@hive/shared/dom.js";
/** Menu entries for a state snapshot. `external` opens in a new tab. */
export function menuLinks(state) {
const links = [
{ icon: "📡", label: "flow", url: "/flow.html" },
{ icon: "📊", label: "stats", url: "/stats.html" },
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
];

View file

@ -1,6 +1,6 @@
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
Extracted from the dashboard ST4TS tab. Same minimal-
chrome pattern as /flow.html: the back-link header +
Extracted from the dashboard ST4TS tab. Minimal chrome:
the back-link header +
title (.page-header / .page-back / .page-title) live in
@hive/shared/chrome.css.
`.hive-stats-table` lives in common.css. Only the ST4TS-specific

View file

@ -11,7 +11,7 @@
<link rel="stylesheet" href="/static/stats.css" />
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html —
<!-- Minimal chrome: back link + title —
no full dashboard tabbar. Back link points to the
dashboard (served at /). -->
<header class="page-header">

View file

@ -5,9 +5,7 @@
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
//
// Pure helpers (DOM, side panel, path linkification)
// live in `./common.js`; the flow-page surface (broker
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
// `index.html` each load their own bundle.
// live in `./common.js`.
// 4G3NTS (containers) domain lives in `./agents.js`.
import { marked } from "marked";
@ -46,7 +44,7 @@ import { renderLinksMenu } from "./links-menu.js";
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
// previews work; flow.js does the same for the flow-page entry.
// previews work.
window.marked = marked;
(() => {
@ -206,12 +204,10 @@ window.marked = marked;
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
// `sent` / `delivered`, anything new the backend adds) silently
// no-op. On (re)connect we kick a refreshState() to recover events
// lost during the disconnect window (same pattern as flow.js's
// onStreamOpen).
// lost during the disconnect window.
//
// Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
// and filter client-side — the dashboard ignores broker traffic
// and FL0W ignores mutation events.
// The dashboard subscribes to `/api/dashboard/stream` and ignores broker
// traffic client-side.
const MUTATION_HANDLERS = {
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
@ -276,11 +272,9 @@ window.marked = marked;
// ─── tab routing ───────────────────────────────────────────────────────
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
// activate the matching pane on the dashboard. Empty
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
// they're separate pages (`/flow.html`, `/stats.html`) reached from
// the pages menu (links-menu.js). Tab routing only
// 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).
// hash defaults to 4G3NTS. ST4TS is NOT a tab — it is a separate page
// (`/stats.html`) reached from the pages menu (links-menu.js). Tab routing
// only applies when the tab DOM is present.
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
// per-tab side-effects via the strip's onShow. Constructed below (after
@ -337,7 +331,4 @@ window.marked = marked;
// — no per-renderer hookup needed.
refreshTabCounts();
setInterval(refreshTabCounts, 1000);
// Flow-specific code (broker terminal init + the @-mention composer)
// lives in ./flow.js — loaded only by /flow.html.
})();

View file

@ -1,6 +1,6 @@
/* ─── page chrome: back-link header ────────────────────────────────
The minimal sticky "frosted bar" header used by every standalone page
reached from the dashboard's pages menu — /flow, /stats. A back
reached from the dashboard's pages menu — /stats. A back
link to the dashboard on the left, then a page title and, on /stats, a
sub-tab nav. The operator dashboard uses its own richer
sticky chrome (`.dashboard-chrome`); these classes cover the simple

View file

@ -1,8 +1,8 @@
//! Remaining single-endpoint dashboard handlers: mark-all-read, operator
//! compose (`op-send`), spawn-request and hive-wide turn stats.
//! Remaining single-endpoint dashboard handlers: mark-all-read,
//! spawn-request and hive-wide turn stats.
use axum::{
extract::{Form, Path as AxumPath, State},
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
@ -82,67 +82,3 @@ pub(super) async fn post_mark_all_read(
Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")),
}
}
/// Operator-side compose form on the dashboard terminal. Drops a
/// message into the broker as `{from: "operator", to, body}`. Same
/// shape that per-agent web UIs use via `OperatorMsg`, but here the
/// operator picks the recipient explicitly with `@name`. No
/// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize, ToSchema)]
pub(super) struct OpSendForm {
to: String,
body: String,
}
/// Operator compose: drop a message into the
/// broker addressed to `to` (or `*` to broadcast).
#[utoipa::path(
post,
path = "/api/op-send",
request_body(content = OpSendForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "message sent", body = String),
(status = 500, description = "missing to/body, or the broker send failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_op_send(
State(state): State<AppState>,
Form(form): Form<OpSendForm>,
) -> Response {
let to = form.to.trim().to_owned();
let body = form.body.trim().to_owned();
if to.is_empty() {
return error_response("op-send: `to` required");
}
if body.is_empty() {
return error_response("op-send: `body` required");
}
if to == "*" {
let errors = state
.coord
.broadcast_send(hive_sh4re::manager::OPERATOR_RECIPIENT, &body);
if !errors.is_empty() {
return error_response(&format!(
"op-send broadcast partial fail: {}",
errors.join("; ")
));
}
} else if let Err(e) = state.coord.broker.send(&hive_sh4re::inbox::Message {
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::OPERATOR_RECIPIENT),
to: to.clone(),
body,
in_reply_to: None,
}) {
return error_response(&format!("op-send to {to} failed: {e:#}"));
}
// 200 instead of 303 → the client doesn't refetch /api/state. The
// broker `send` already emitted a `MessageEvent` which the
// dashboard channel forwarder mirrors as `DashboardEvent::Sent`,
// and the page's terminal derives from that stream — so the
// operator's send shows up the same way an agent's send does, with
// no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response()
}

View file

@ -117,7 +117,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
@ -137,7 +136,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup))
.split_for_parts();
@ -371,7 +369,6 @@ mod router_build_probe {
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
@ -391,7 +388,6 @@ mod router_build_probe {
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup));
}

View file

@ -1,17 +1,14 @@
//! `/api/state` cold-load snapshot plus the dashboard's live read side:
//! the `StateSnapshot` shape and its view builders, the
//! `/api/dashboard/stream` SSE channel, and the `/api/dashboard/history`
//! backfill. SPA shape + SSE channels: docs/web-ui/shape.md.
//! `/api/dashboard/stream` SSE channel. SPA shape + SSE channels:
//! docs/web-ui/shape.md.
use std::convert::Infallible;
use axum::{
extract::State,
http::HeaderMap,
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
response::sse::{Event, KeepAlive, Sse},
};
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
@ -20,9 +17,9 @@ use utoipa::IntoParams;
use crate::container_view::ContainerView;
use super::AppState;
use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
@ -351,105 +348,14 @@ pub(super) async fn jobq_rollup(
axum::Json(state.coord.job_queue.state_rollup())
}
/// Response body for `/api/dashboard/history`. No `ToSchema` — its
/// `events` field wraps [`crate::dashboard_events::DashboardEvent`],
/// which doesn't derive `ToSchema` either (a large enum with many
/// variants; see that type's doc comment for why annotating it is
/// out of scope here). The `responses(...)` doc below spells out the
/// shape in prose instead of a `body = ...` reference.
#[derive(Serialize)]
struct DashboardHistoryBody {
seq: u64,
events: Vec<crate::dashboard_events::DashboardEvent>,
}
#[utoipa::path(
get,
path = "/api/dashboard/history",
responses(
(status = 200, description = "`{ seq, events }` — up to the last 200 \
broker messages as `DashboardEvent::Sent`/`Delivered` JSON, plus \
`seq`: the dashboard channel's high-water mark at fetch time \
(used by clients to dedupe against buffered live SSE frames)"),
),
tag = "state_snapshot"
)]
pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response {
// Backfill source for the dashboard terminal. Returns up to ~200
// historical broker messages (no other event kinds are persisted)
// converted to `DashboardEvent::Sent` JSON so the client can replay
// through the same dispatch path as live frames. Wrapped in
// `{ seq, events }`: the seq is the dashboard channel's high-water
// mark at fetch time. Clients use it to dedupe their buffered live
// SSE traffic (drop anything with `seq <= history_seq`) so a frame
// that lands between SSE-subscribe and history-fetch isn't shown
// twice and isn't lost. Historical rows carry `seq = 0`; the
// boundary seq is what closes the dedupe window.
const HISTORY_LIMIT: u64 = 200;
let seq = state.coord.current_seq();
match state.coord.broker.recent_all(HISTORY_LIMIT) {
Ok(mut messages) => {
messages.reverse();
let events: Vec<crate::dashboard_events::DashboardEvent> = messages
.into_iter()
.map(|m| match m {
crate::broker::MessageEvent::Sent {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Sent {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
crate::broker::MessageEvent::Delivered {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Delivered {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
})
.collect();
axum::Json(DashboardHistoryBody { seq, events }).into_response()
}
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
}
}
/// `/dashboard/stream` query string. Today's only field is `kinds`:
/// a comma-separated allow-list of event-`kind` strings.
/// Empty / absent ⇒ no filter (current behaviour, all variants
/// forwarded). Set ⇒ only the named kinds reach the subscriber,
/// non-matches are skipped before the JSON serialise cost.
///
/// Useful for narrow pages (e.g. `flow.js` only cares about `sent`
/// / `delivered` / `container_state_changed` / `container_removed`)
/// that want to drop the dispatch overhead on every unrelated mutation.
/// Useful for narrow pages that only care about a few kinds and want to
/// drop the dispatch overhead on every unrelated mutation.
#[derive(Deserialize, Default, IntoParams)]
pub(super) struct DashboardStreamQuery {
/// Comma-separated event kinds to forward. Each token is
@ -464,8 +370,7 @@ pub(super) struct DashboardStreamQuery {
params(DashboardStreamQuery),
responses(
(status = 200, description = "server-sent event stream; each event's \
`data` is a JSON-serialised `DashboardEvent` (seq-tagged; pair \
with `/api/dashboard/history` to backfill + dedupe on connect)",
`data` is a JSON-serialised `DashboardEvent` (seq-tagged)",
body = String, content_type = "text/event-stream"),
),
tag = "state_snapshot"

View file

@ -269,38 +269,6 @@ impl Broker {
.map_err(Into::into)
}
/// Latest `limit` messages across every recipient, newest-first.
/// Backs the dashboard's message-flow backfill so a reload doesn't
/// blank the operator's view of recent traffic. Returns each row as
/// a [`MessageEvent::Sent`] so the dashboard's live renderer (which
/// already speaks `MessageEvent`) can replay history through the
/// same code path. We don't synthesise `Delivered` events here —
/// the recv-side acks live in a different table column and would
/// double-render on backfill; the live stream picks them up
/// immediately on the first new `recv`.
pub fn recent_all(&self, limit: u64) -> Result<Vec<MessageEvent>> {
let conn = self.conn.lock().unwrap();
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, body, sent_at, in_reply_to
FROM messages
ORDER BY id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit_i], |row| {
Ok(MessageEvent::Sent {
id: row.get(0)?,
from: row.get(1)?,
to: row.get(2)?,
body: row.get(3)?,
at: row.get(4)?,
in_reply_to: row.get(5)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Number of undelivered messages addressed to `recipient`. Non-mutating
/// — used by the harness to surface "N unread" in tool-result status
/// lines without popping the queue.

View file

@ -15,10 +15,10 @@
#
# $out/dashboard/ the hive-c0re dashboard assets (full layout in
# frontend/packages/dashboard/build.mjs):
# index.html (operator SPA, served at /) flow.html
# index.html (operator SPA, served at /)
# stats.html favicon.svg
# static/{tabs,flow,stats,stream-worker}.js{,.map}
# static/{colors,theme,common,dashboard,flow,stats}.css
# static/{tabs,stats,stream-worker}.js{,.map}
# static/{colors,theme,common,dashboard,stats}.css
# $out/agent/ the per-agent default UI (layered with
# services.hyperhive.agent.frontend.extraFiles at activation time)
# index.html stats.html screen.html