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:
parent
acf82beba3
commit
fa131b46a6
24 changed files with 62 additions and 1121 deletions
|
|
@ -12,7 +12,7 @@ what you actually do here.
|
||||||
|
|
||||||
Everything starts at the **dashboard**, served at `/`. A pages menu
|
Everything starts at the **dashboard**, served at `/`. A pages menu
|
||||||
(the `☰` button at the left of the tab strip) links to the other
|
(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.
|
those pages links back to the dashboard.
|
||||||
|
|
||||||
The dashboard is where you'll spend most of your time. It's a single
|
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
|
- **K3PT ST4T3** — kept state from agents with no container
|
||||||
(tombstones), with a purge button per entry.
|
(tombstones), with a purge button per entry.
|
||||||
|
|
||||||
Two things live on their own page instead, reached from the pages
|
Usage stats live on their own page, reached from the pages menu:
|
||||||
menu: **Flow** (`/flow.html`, the raw live message stream across the
|
**Stats** (`/stats.html`, swarm-wide usage stats).
|
||||||
whole swarm) and **Stats** (`/stats.html`, swarm-wide usage stats).
|
|
||||||
|
|
||||||
Each agent also has its own page — a full terminal view of that
|
Each agent also has its own page — a full terminal view of that
|
||||||
agent's session, reachable by selecting its name anywhere in the
|
agent's session, reachable by selecting its name anywhere in the
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ swap touches only the first:
|
||||||
what the app references and it **never changes on a swap**.
|
what the app references and it **never changes on a swap**.
|
||||||
|
|
||||||
Both files live in `frontend/packages/shared/src/`. Per-page stylesheets
|
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
|
reference the **semantic** names and must **not** redeclare them or reach
|
||||||
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
|
for the raw `--baseNN` slots directly. (`base.css` holds only the shared
|
||||||
`body` typography — it references the palette but no longer defines it.)
|
`body` typography — it references the palette but no longer defines it.)
|
||||||
|
|
|
||||||
|
|
@ -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
|
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`,
|
one tab pane at a time. The URL hash (`#agents`, `#rebuild`, `#meta`,
|
||||||
`#buildlogs`, `#kept`) drives which pane is
|
`#buildlogs`, `#kept`) drives which pane is
|
||||||
active; hash changes don't reload the page. FL0W and ST4TS
|
active; hash changes don't reload the page. ST4TS
|
||||||
are standalone pages reached from the pages menu, not from the
|
is a standalone page reached from the pages menu, not from the
|
||||||
dashboard tab strip.
|
dashboard tab strip.
|
||||||
|
|
||||||
**Chrome header** (fixed, overlays the active tab pane):
|
**Chrome header** (fixed, overlays the active tab pane):
|
||||||
- **Pages menu**: a `☰` button at the left of the chrome (`links-menu.js`)
|
- **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
|
(`/stats.html`), **api docs** (`/api/docs/`) and **swarm** (the swarm
|
||||||
UI at the swarm apex domain, opened in a new tab). The swarm entry
|
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
|
comes from `state.swarm_public_url`, which hive-c0re reads from
|
||||||
`HIVE_SWARM_PUBLIC_URL` (set from `services.hyperhive.swarm.domain`);
|
`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 `/`.
|
with no domain set there is no swarm entry. The standalone pages link back to `/`.
|
||||||
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
|
- **Tab strip**: `◆ 4G3NTS ◆`, `◆ R3BU1LD QU3U3 ◆`, `◆ M3T4 1NPUTS ◆`,
|
||||||
`◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — FL0W / ST4TS
|
`◆ BUILD L0GS ◆`, `◆ K3PT ST4T3 ◆`. In-page tabs only — ST4TS
|
||||||
live on their own pages, reached from the pages menu. Count pills on
|
lives on its own page, reached from the pages menu. Count pills on
|
||||||
4G3NTS (stale containers), R3BU1LD QU3U3 (rebuilds in flight) and
|
4G3NTS (stale containers), R3BU1LD QU3U3 (rebuilds in flight) and
|
||||||
K3PT ST4T3 (kept-state entries).
|
K3PT ST4T3 (kept-state entries).
|
||||||
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
- **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`
|
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
|
||||||
arrive in the state snapshot.
|
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
|
## 4G3NTS tab
|
||||||
|
|
||||||
**C0NTAINERS** — live containers as a flat list, one row per
|
**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
|
Hive-wide turn statistics, aggregated across every agent's
|
||||||
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
|
`hyperhive-turn-stats.sqlite` for the selected window. Not a dashboard
|
||||||
tab — a standalone page reached from the dashboard's pages menu,
|
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
|
each agent's own `/stats` page (which carries the per-agent trend
|
||||||
charts): ST4TS is the swarm-level rollup.
|
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
|
`{ input, output, cache_read, cache_write }` USD-per-million-token
|
||||||
prices. Models not covered fall back to hive-c0re's built-in estimate.
|
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
|
## Container row
|
||||||
|
|
||||||
A full-height **square agent icon** (5em, capped) on the left. The
|
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
|
not yet delivered and sets `acked_at = now`. Returns
|
||||||
`{ "marked": N }`. Agent name validated against
|
`{ "marked": N }`. Agent name validated against
|
||||||
`[a-z0-9_-]`, 1-63 chars; 400 on bad input.
|
`[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
|
- `GET /api/build-logs/{agent}?limit=N` — most-recent build log
|
||||||
headers for one agent, newest first. Returns
|
headers for one agent, newest first. Returns
|
||||||
`Vec<BuildLogHeader>` (JSON): `id`, `agent`, `kind`, `cmdline`,
|
`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,
|
The client trusts that list and linkifies only those tokens,
|
||||||
so directories, missing files, and forbidden subtrees never
|
so directories, missing files, and forbidden subtrees never
|
||||||
become anchors. No probe endpoint, no client-side regex
|
become anchors. No probe endpoint, no client-side regex
|
||||||
heuristics. Historical messages get the same treatment on
|
heuristics.
|
||||||
`/api/dashboard/history` backfill.
|
|
||||||
- `GET /api/reminders` — list pending reminders for the
|
- `GET /api/reminders` — list pending reminders for the
|
||||||
dashboard's queued-reminders panel.
|
dashboard's queued-reminders panel.
|
||||||
- `GET /api/stats-hive?window=<1h|4h|24h|3d|7d|30d|all>` — hive-wide
|
- `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:
|
- `GET /api/dashboard/stream` — unified live event channel:
|
||||||
broker `sent` / `delivered`, plus the mutation events listed
|
broker `sent` / `delivered`, plus the mutation events listed
|
||||||
below. Each frame carries `seq`.
|
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 -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
## Dashboard event channel
|
## Dashboard event channel
|
||||||
|
|
@ -652,9 +603,7 @@ payload):
|
||||||
- `sent` / `delivered` — broker traffic, mirrored from the
|
- `sent` / `delivered` — broker traffic, mirrored from the
|
||||||
intra-process channel by a forwarder task. Both carry `id: i64`
|
intra-process channel by a forwarder task. Both carry `id: i64`
|
||||||
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
(the broker row id) and `in_reply_to: Option<i64>` for thread
|
||||||
rendering. The dashboard message-flow terminal renders reply
|
rendering.
|
||||||
rows with a `↳ reply` tag that scroll-highlights the parent
|
|
||||||
row on selection. Used by the message-flow terminal renderer.
|
|
||||||
- `transient_set` (name, transient_kind, since_unix) /
|
- `transient_set` (name, transient_kind, since_unix) /
|
||||||
`transient_cleared` (name) — lifecycle action spinners. The
|
`transient_cleared` (name) — lifecycle action spinners. The
|
||||||
client ticks the elapsed-seconds badge off `since_unix`
|
client ticks the elapsed-seconds badge off `since_unix`
|
||||||
|
|
@ -696,7 +645,7 @@ payload):
|
||||||
forms that mutate non-event-derived state** (PURG3 +
|
forms that mutate non-event-derived state** (PURG3 +
|
||||||
meta-update, since tombstones + meta_inputs aren't event-
|
meta-update, since tombstones + meta_inputs aren't event-
|
||||||
shaped yet). Every other section — transients,
|
shaped yet). Every other section — transients,
|
||||||
containers, message flow —
|
containers —
|
||||||
derives from `/api/dashboard/stream` after the initial snapshot,
|
derives from `/api/dashboard/stream` after the initial snapshot,
|
||||||
maintaining its own client-side store and applying events on
|
maintaining its own client-side store and applying events on
|
||||||
top.
|
top.
|
||||||
|
|
|
||||||
|
|
@ -19,15 +19,15 @@
|
||||||
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
|
from `@hive/shared` (was `hive-fr0nt`); the dashboard ships one
|
||||||
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
|
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
|
||||||
loaded by every page, plus a page-specific bundle —
|
loaded by every page, plus a page-specific bundle —
|
||||||
`dashboard.css` / `flow.css` /
|
`dashboard.css` /
|
||||||
`stats.css`);
|
`stats.css`);
|
||||||
`common.css` inlines `@hive/shared`'s
|
`common.css` inlines `@hive/shared`'s
|
||||||
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
||||||
`pill.css` via esbuild's `@import` resolution.
|
`pill.css` via esbuild's `@import` resolution.
|
||||||
`terminal.js` exports `{ create, linkify }` as ES module
|
`terminal.js` exports `{ create, linkify }` as ES module
|
||||||
members; a back-compat shim in the IIFE bodies still exposes
|
members; a back-compat shim in the IIFE bodies still exposes
|
||||||
a `window.HiveTerminal` global for callers that need it. The dashboard's
|
a `window.HiveTerminal` global for callers that need it. The
|
||||||
`#msgflow` and the per-agent `#live` log are both backed by
|
per-agent `#live` log is backed by
|
||||||
this terminal — sticky-bottom autoscroll, "↓ N new" pill,
|
this terminal — sticky-bottom autoscroll, "↓ N new" pill,
|
||||||
history backfill, SSE plumbing all live there. Each page
|
history backfill, SSE plumbing all live there. Each page
|
||||||
registers a kind→renderer map; unknown kinds fall through to
|
registers a kind→renderer map; unknown kinds fall through to
|
||||||
|
|
@ -51,10 +51,8 @@
|
||||||
dashboard stream carries broker `Sent` / `Delivered` (mirrored
|
dashboard stream carries broker `Sent` / `Delivered` (mirrored
|
||||||
by a forwarder task from the broker's intra-process channel)
|
by a forwarder task from the broker's intra-process channel)
|
||||||
plus mutation events (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
|
plus mutation events (`transient_set` / `transient_cleared`). Each frame carries a `seq`. The
|
||||||
matching backfill endpoint is `GET /api/dashboard/history` (last
|
agent's matching backfill endpoint is `GET /events/history` (last 2000
|
||||||
~200 broker messages wrapped in `{ seq, events }`) on the
|
`LiveEvent`s wrapped in `{ seq, events }`).
|
||||||
dashboard and `GET /events/history` (last 2000 `LiveEvent`s
|
|
||||||
also wrapped in `{ seq, events }`) on the agent.
|
|
||||||
**One unified channel**: browsers cap concurrent SSE
|
**One unified channel**: browsers cap concurrent SSE
|
||||||
connections per origin (~6 in Chrome). Using one channel per
|
connections per origin (~6 in Chrome). Using one channel per
|
||||||
domain would exhaust this budget on a live hive; dispatching
|
domain would exhaust this budget on a live hive; dispatching
|
||||||
|
|
@ -87,9 +85,8 @@
|
||||||
|
|
||||||
## Shared terminal pane
|
## Shared terminal pane
|
||||||
|
|
||||||
The shared terminal factory in `@hive/shared/terminal.js` backs both
|
The shared terminal factory in `@hive/shared/terminal.js` backs the
|
||||||
surfaces' scrollable log streams (`#msgflow` on the dashboard, `#live`
|
per-agent page's scrollable log stream (`#live`). The factory wires up
|
||||||
on the per-agent page). The factory wires up
|
|
||||||
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
|
sticky-bottom autoscroll, a "↓ N new" pill, history backfill, and
|
||||||
SSE replay. Pages register a `kind → renderer` map; unknown kinds
|
SSE replay. Pages register a `kind → renderer` map; unknown kinds
|
||||||
fall through to a JSON-dump note row. The factory ships three row
|
fall through to a JSON-dump note row. The factory ships three row
|
||||||
|
|
|
||||||
|
|
@ -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
|
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
|
||||||
any `TermMsg` with `body_format: "markdown"`.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (the
|
// 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
|
// `.html`), each named after its own entry point below — see the
|
||||||
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
||||||
// avoid this comment drifting out of sync with the real build steps.
|
// 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
|
// 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("stats.js")],
|
entryPoints: [src("tabs.js"), src("stats.js")],
|
||||||
outdir: staticDir(""),
|
outdir: staticDir(""),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
|
|
@ -58,7 +58,7 @@ await build({
|
||||||
|
|
||||||
// Stream-worker entry. Lives in a separate bundle: SharedWorker
|
// Stream-worker entry. Lives in a separate bundle: SharedWorker
|
||||||
// scripts run in a different global (`self` is the worker scope, no
|
// 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
|
// at `static/stream-worker.js`; common.js's `openStream` references
|
||||||
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
|
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
|
||||||
// matches the classic-script load (`new SharedWorker(url, name)` with
|
// matches the classic-script load (`new SharedWorker(url, name)` with
|
||||||
|
|
@ -89,7 +89,6 @@ for (const entry of [
|
||||||
"theme.css",
|
"theme.css",
|
||||||
"common.css",
|
"common.css",
|
||||||
"dashboard.css",
|
"dashboard.css",
|
||||||
"flow.css",
|
|
||||||
"stats.css",
|
"stats.css",
|
||||||
]) {
|
]) {
|
||||||
await build({
|
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));
|
copyFileSync(src(html), dist(html));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -588,7 +588,7 @@ function buildContainerLi(c, opts) {
|
||||||
export function renderContainers(s) {
|
export function renderContainers(s) {
|
||||||
const root = $("containers-section");
|
const root = $("containers-section");
|
||||||
// #containers-section only exists on the dashboard. tabs.js is the
|
// #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
|
// Belt-and-suspenders for any future page that adds tabs.js
|
||||||
// without a #containers-section — matches the
|
// without a #containers-section — matches the
|
||||||
// no-op-when-target-absent convention the other renderers
|
// no-op-when-target-absent convention the other renderers
|
||||||
|
|
|
||||||
|
|
@ -337,148 +337,9 @@ code {
|
||||||
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
|
pages' sticky back-link bar) moved to @hive/shared/chrome.css, imported
|
||||||
at the top of this file. */
|
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 ─────────────────────────────────────────────────
|
/* ─── side panel ─────────────────────────────────────────────────
|
||||||
Long content (file previews, diffs, applied config) opens in
|
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
|
the dashboard. The drawer chrome itself (backdrop, drawer, resize
|
||||||
handle, header, title, close button) is now the shared
|
handle, header, title, close button) is now the shared
|
||||||
`<hive-side-panel>` element (@hive/shared/side-panel.js); what's left
|
`<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
|
carries the horizontal gutter. Full-width chrome (the tab strip, the
|
||||||
footer) then spans edge-to-edge for free,
|
footer) then spans edge-to-edge for free,
|
||||||
with no negative-margin breakout hacks. Only matches on pages that opt
|
with no negative-margin breakout hacks. Only matches on pages that opt
|
||||||
in by adding the wrapper (index.html); FL0W / ST4TS
|
in by adding the wrapper (index.html); ST4TS
|
||||||
are full-bleed with their own `.<page>-main` padding. */
|
is full-bleed with its own `.<page>-main` padding. */
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: 0 1.5em;
|
padding: 0 1.5em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shared dashboard helpers used by both index.html (./tabs.js) and
|
// Shared dashboard helpers used by index.html (./tabs.js): pure DOM helpers,
|
||||||
// flow.html (./flow.js): pure DOM helpers, the side-panel singleton,
|
// the side-panel singleton, and the path-link / file-preview infrastructure
|
||||||
// and the path-link / file-preview infrastructure for the side panel.
|
// for the side panel.
|
||||||
|
|
||||||
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
||||||
import { el } from "@hive/shared/dom.js";
|
import { el } from "@hive/shared/dom.js";
|
||||||
|
|
|
||||||
|
|
@ -180,8 +180,6 @@ body.dashboard-shell {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FL0W is a separate page (`/flow.html`) — its full-viewport
|
|
||||||
styling lives in flow.css. */
|
|
||||||
.banner {
|
.banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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">@—></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>
|
|
||||||
|
|
@ -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();
|
|
||||||
})();
|
|
||||||
})();
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
button / shared links keep you on the same view. JS owns
|
button / shared links keep you on the same view. JS owns
|
||||||
the show/hide. -->
|
the show/hide. -->
|
||||||
<header class="dashboard-chrome">
|
<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
|
swarm UI. Filled by links-menu.js from /api/state. The pages link
|
||||||
back to `/`, which is this dashboard. -->
|
back to `/`, which is this dashboard. -->
|
||||||
<div class="links-menu" id="links-menu"></div>
|
<div class="links-menu" id="links-menu"></div>
|
||||||
|
|
@ -82,7 +82,7 @@
|
||||||
<!-- (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 4G3NTS. See tab-pane-agents.) -->
|
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
|
menu at the left of this strip, not tabs — the strip holds only
|
||||||
in-page tabs. -->
|
in-page tabs. -->
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -216,8 +216,8 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ST4TS and FL0W live on their own pages (`/stats.html`,
|
<!-- ST4TS lives on its own page (`/stats.html`).
|
||||||
`/flow.html`). Their DOM only exists there — when tabs.js boots
|
Its DOM only exists there — when tabs.js boots
|
||||||
on this page the corresponding renderers no-op silently (each
|
on this page the corresponding renderers no-op silently (each
|
||||||
guard is `if (!el) return`). -->
|
guard is `if (!el) return`). -->
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -271,7 +271,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
<!-- 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
|
@hive/shared/terminal.js and the marked npm package into
|
||||||
tabs.js. -->
|
tabs.js. -->
|
||||||
<script type="module" src="/static/tabs.js" defer></script>
|
<script type="module" src="/static/tabs.js" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Pages menu in the dashboard chrome: one button that opens a popover
|
// 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
|
// Modelled on the swarm UI's `LinksMenu`. The swarm entry only exists
|
||||||
// when `/api/state` carries `swarm_public_url`, so a hive without a
|
// when `/api/state` carries `swarm_public_url`, so a hive without a
|
||||||
// configured swarm domain shows no dead link.
|
// 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. */
|
/** Menu entries for a state snapshot. `external` opens in a new tab. */
|
||||||
export function menuLinks(state) {
|
export function menuLinks(state) {
|
||||||
const links = [
|
const links = [
|
||||||
{ icon: "📡", label: "flow", url: "/flow.html" },
|
|
||||||
{ icon: "📊", label: "stats", url: "/stats.html" },
|
{ icon: "📊", label: "stats", url: "/stats.html" },
|
||||||
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
|
{ icon: "🧬", label: "api docs", url: "/api/docs/" },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
|
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
|
||||||
Extracted from the dashboard ST4TS tab. Same minimal-
|
Extracted from the dashboard ST4TS tab. Minimal chrome:
|
||||||
chrome pattern as /flow.html: the back-link header +
|
the back-link header +
|
||||||
title (.page-header / .page-back / .page-title) live in
|
title (.page-header / .page-back / .page-title) live in
|
||||||
@hive/shared/chrome.css.
|
@hive/shared/chrome.css.
|
||||||
`.hive-stats-table` lives in common.css. Only the ST4TS-specific
|
`.hive-stats-table` lives in common.css. Only the ST4TS-specific
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<link rel="stylesheet" href="/static/stats.css" />
|
<link rel="stylesheet" href="/static/stats.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="stats-shell">
|
<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
|
no full dashboard tabbar. Back link points to the
|
||||||
dashboard (served at /). -->
|
dashboard (served at /). -->
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,7 @@
|
||||||
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
// docs/web-ui/shape.md::Shape (shared by both) for the broader contract.
|
||||||
//
|
//
|
||||||
// Pure helpers (DOM, side panel, path linkification)
|
// Pure helpers (DOM, side panel, path linkification)
|
||||||
// live in `./common.js`; the flow-page surface (broker
|
// live in `./common.js`.
|
||||||
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
|
||||||
// `index.html` each load their own bundle.
|
|
||||||
// 4G3NTS (containers) domain lives in `./agents.js`.
|
// 4G3NTS (containers) domain lives in `./agents.js`.
|
||||||
|
|
||||||
import { marked } from "marked";
|
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
|
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||||
// panel preview path. Set it here on the dashboard entry so file
|
// 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;
|
window.marked = marked;
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -206,12 +204,10 @@ window.marked = marked;
|
||||||
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
||||||
// `sent` / `delivered`, anything new the backend adds) silently
|
// `sent` / `delivered`, anything new the backend adds) silently
|
||||||
// no-op. On (re)connect we kick a refreshState() to recover events
|
// no-op. On (re)connect we kick a refreshState() to recover events
|
||||||
// lost during the disconnect window (same pattern as flow.js's
|
// lost during the disconnect window.
|
||||||
// onStreamOpen).
|
|
||||||
//
|
//
|
||||||
// Both the dashboard and /flow.html subscribe to `/api/dashboard/stream`
|
// The dashboard subscribes to `/api/dashboard/stream` and ignores broker
|
||||||
// and filter client-side — the dashboard ignores broker traffic
|
// traffic client-side.
|
||||||
// and FL0W ignores mutation events.
|
|
||||||
const MUTATION_HANDLERS = {
|
const MUTATION_HANDLERS = {
|
||||||
transient_set: applyTransientSet,
|
transient_set: applyTransientSet,
|
||||||
transient_cleared: applyTransientCleared,
|
transient_cleared: applyTransientCleared,
|
||||||
|
|
@ -276,11 +272,9 @@ window.marked = marked;
|
||||||
// ─── tab routing ───────────────────────────────────────────────────────
|
// ─── tab routing ───────────────────────────────────────────────────────
|
||||||
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
|
// Hash-based: `#agents` / `#rebuild` / `#meta` / `#buildlogs` / `#kept`
|
||||||
// activate the matching pane on the dashboard. Empty
|
// activate the matching pane on the dashboard. Empty
|
||||||
// hash defaults to 4G3NTS. FL0W and ST4TS are NOT tabs —
|
// hash defaults to 4G3NTS. ST4TS is NOT a tab — it is a separate page
|
||||||
// they're separate pages (`/flow.html`, `/stats.html`) reached from
|
// (`/stats.html`) reached from the pages menu (links-menu.js). Tab routing
|
||||||
// the pages menu (links-menu.js). Tab routing only
|
// only applies when the tab DOM is present.
|
||||||
// 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).
|
|
||||||
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
||||||
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
||||||
// per-tab side-effects via the strip's onShow. Constructed below (after
|
// per-tab side-effects via the strip's onShow. Constructed below (after
|
||||||
|
|
@ -337,7 +331,4 @@ window.marked = marked;
|
||||||
// — no per-renderer hookup needed.
|
// — no per-renderer hookup needed.
|
||||||
refreshTabCounts();
|
refreshTabCounts();
|
||||||
setInterval(refreshTabCounts, 1000);
|
setInterval(refreshTabCounts, 1000);
|
||||||
|
|
||||||
// Flow-specific code (broker terminal init + the @-mention composer)
|
|
||||||
// lives in ./flow.js — loaded only by /flow.html.
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/* ─── page chrome: back-link header ────────────────────────────────
|
/* ─── page chrome: back-link header ────────────────────────────────
|
||||||
The minimal sticky "frosted bar" header used by every standalone page
|
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
|
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
|
sub-tab nav. The operator dashboard uses its own richer
|
||||||
sticky chrome (`.dashboard-chrome`); these classes cover the simple
|
sticky chrome (`.dashboard-chrome`); these classes cover the simple
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
//! Remaining single-endpoint dashboard handlers: mark-all-read, operator
|
//! Remaining single-endpoint dashboard handlers: mark-all-read,
|
||||||
//! compose (`op-send`), spawn-request and hive-wide turn stats.
|
//! spawn-request and hive-wide turn stats.
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Form, Path as AxumPath, State},
|
extract::{Path as AxumPath, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
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:#}")),
|
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
||||||
.routes(routes!(state_files::get_state_file))
|
.routes(routes!(state_files::get_state_file))
|
||||||
.routes(routes!(misc_api::api_stats_hive))
|
.routes(routes!(misc_api::api_stats_hive))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.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_logs_all))
|
||||||
.routes(routes!(build_logs::get_build_log_for_node))
|
.routes(routes!(build_logs::get_build_log_for_node))
|
||||||
.routes(routes!(build_logs::get_build_log_raw_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!(meta_inputs::post_meta_update))
|
||||||
.routes(routes!(build_logs::get_build_log_stream))
|
.routes(routes!(build_logs::get_build_log_stream))
|
||||||
.routes(routes!(state_snapshot::dashboard_stream))
|
.routes(routes!(state_snapshot::dashboard_stream))
|
||||||
.routes(routes!(state_snapshot::dashboard_history))
|
|
||||||
.routes(routes!(state_snapshot::jobq_graph))
|
.routes(routes!(state_snapshot::jobq_graph))
|
||||||
.routes(routes!(state_snapshot::jobq_rollup))
|
.routes(routes!(state_snapshot::jobq_rollup))
|
||||||
.split_for_parts();
|
.split_for_parts();
|
||||||
|
|
@ -371,7 +369,6 @@ mod router_build_probe {
|
||||||
.routes(routes!(state_files::get_state_file))
|
.routes(routes!(state_files::get_state_file))
|
||||||
.routes(routes!(misc_api::api_stats_hive))
|
.routes(routes!(misc_api::api_stats_hive))
|
||||||
.routes(routes!(misc_api::post_mark_all_read))
|
.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_logs_all))
|
||||||
.routes(routes!(build_logs::get_build_log_for_node))
|
.routes(routes!(build_logs::get_build_log_for_node))
|
||||||
.routes(routes!(build_logs::get_build_log_raw_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!(meta_inputs::post_meta_update))
|
||||||
.routes(routes!(build_logs::get_build_log_stream))
|
.routes(routes!(build_logs::get_build_log_stream))
|
||||||
.routes(routes!(state_snapshot::dashboard_stream))
|
.routes(routes!(state_snapshot::dashboard_stream))
|
||||||
.routes(routes!(state_snapshot::dashboard_history))
|
|
||||||
.routes(routes!(state_snapshot::jobq_graph))
|
.routes(routes!(state_snapshot::jobq_graph))
|
||||||
.routes(routes!(state_snapshot::jobq_rollup));
|
.routes(routes!(state_snapshot::jobq_rollup));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
//! `/api/state` cold-load snapshot plus the dashboard's live read side:
|
//! `/api/state` cold-load snapshot plus the dashboard's live read side:
|
||||||
//! the `StateSnapshot` shape and its view builders, the
|
//! the `StateSnapshot` shape and its view builders, the
|
||||||
//! `/api/dashboard/stream` SSE channel, and the `/api/dashboard/history`
|
//! `/api/dashboard/stream` SSE channel. SPA shape + SSE channels:
|
||||||
//! backfill. SPA shape + SSE channels: docs/web-ui/shape.md.
|
//! docs/web-ui/shape.md.
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::HeaderMap,
|
http::HeaderMap,
|
||||||
response::{
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
IntoResponse, Response,
|
|
||||||
sse::{Event, KeepAlive, Sse},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
|
|
@ -20,9 +17,9 @@ use utoipa::IntoParams;
|
||||||
|
|
||||||
use crate::container_view::ContainerView;
|
use crate::container_view::ContainerView;
|
||||||
|
|
||||||
|
use super::AppState;
|
||||||
use super::meta_inputs::{MetaInputView, read_meta_inputs};
|
use super::meta_inputs::{MetaInputView, read_meta_inputs};
|
||||||
use super::tombstones::{TombstoneView, build_tombstone_views};
|
use super::tombstones::{TombstoneView, build_tombstone_views};
|
||||||
use super::{AppState, error_response, scan_validated_paths};
|
|
||||||
|
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -351,105 +348,14 @@ pub(super) async fn jobq_rollup(
|
||||||
axum::Json(state.coord.job_queue.state_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`:
|
/// `/dashboard/stream` query string. Today's only field is `kinds`:
|
||||||
/// a comma-separated allow-list of event-`kind` strings.
|
/// a comma-separated allow-list of event-`kind` strings.
|
||||||
/// Empty / absent ⇒ no filter (current behaviour, all variants
|
/// Empty / absent ⇒ no filter (current behaviour, all variants
|
||||||
/// forwarded). Set ⇒ only the named kinds reach the subscriber,
|
/// forwarded). Set ⇒ only the named kinds reach the subscriber,
|
||||||
/// non-matches are skipped before the JSON serialise cost.
|
/// non-matches are skipped before the JSON serialise cost.
|
||||||
///
|
///
|
||||||
/// Useful for narrow pages (e.g. `flow.js` only cares about `sent`
|
/// Useful for narrow pages that only care about a few kinds and want to
|
||||||
/// / `delivered` / `container_state_changed` / `container_removed`)
|
/// drop the dispatch overhead on every unrelated mutation.
|
||||||
/// that want to drop the dispatch overhead on every unrelated mutation.
|
|
||||||
#[derive(Deserialize, Default, IntoParams)]
|
#[derive(Deserialize, Default, IntoParams)]
|
||||||
pub(super) struct DashboardStreamQuery {
|
pub(super) struct DashboardStreamQuery {
|
||||||
/// Comma-separated event kinds to forward. Each token is
|
/// Comma-separated event kinds to forward. Each token is
|
||||||
|
|
@ -464,8 +370,7 @@ pub(super) struct DashboardStreamQuery {
|
||||||
params(DashboardStreamQuery),
|
params(DashboardStreamQuery),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "server-sent event stream; each event's \
|
(status = 200, description = "server-sent event stream; each event's \
|
||||||
`data` is a JSON-serialised `DashboardEvent` (seq-tagged; pair \
|
`data` is a JSON-serialised `DashboardEvent` (seq-tagged)",
|
||||||
with `/api/dashboard/history` to backfill + dedupe on connect)",
|
|
||||||
body = String, content_type = "text/event-stream"),
|
body = String, content_type = "text/event-stream"),
|
||||||
),
|
),
|
||||||
tag = "state_snapshot"
|
tag = "state_snapshot"
|
||||||
|
|
|
||||||
|
|
@ -269,38 +269,6 @@ impl Broker {
|
||||||
.map_err(Into::into)
|
.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
|
/// Number of undelivered messages addressed to `recipient`. Non-mutating
|
||||||
/// — used by the harness to surface "N unread" in tool-result status
|
/// — used by the harness to surface "N unread" in tool-result status
|
||||||
/// lines without popping the queue.
|
/// lines without popping the queue.
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,10 @@
|
||||||
#
|
#
|
||||||
# $out/dashboard/ the hive-c0re dashboard assets (full layout in
|
# $out/dashboard/ the hive-c0re dashboard assets (full layout in
|
||||||
# frontend/packages/dashboard/build.mjs):
|
# frontend/packages/dashboard/build.mjs):
|
||||||
# index.html (operator SPA, served at /) flow.html
|
# index.html (operator SPA, served at /)
|
||||||
# stats.html favicon.svg
|
# stats.html favicon.svg
|
||||||
# static/{tabs,flow,stats,stream-worker}.js{,.map}
|
# static/{tabs,stats,stream-worker}.js{,.map}
|
||||||
# static/{colors,theme,common,dashboard,flow,stats}.css
|
# static/{colors,theme,common,dashboard,stats}.css
|
||||||
# $out/agent/ the per-agent default UI (layered with
|
# $out/agent/ the per-agent default UI (layered with
|
||||||
# services.hyperhive.agent.frontend.extraFiles at activation time)
|
# services.hyperhive.agent.frontend.extraFiles at activation time)
|
||||||
# index.html stats.html screen.html
|
# index.html stats.html screen.html
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue