dashboard: remove the L0GS page and its journal backend
Logs live at swarm level now (VictoriaLogs, read through Grafana
Explore). The hive dashboard's own journal viewer goes, with
everything that existed only to serve it:
- frontend: /logs.html, logs.js, logs.css, the H0M3 "Logs" tile and
the per-agent menu's "journal logs ->" link. The build-log list
rules BU1LDS used from logs.css move into builds.css.
- hive-c0re: GET /api/journal/{name} and GET /api/journal-host, and
priv_client::read_container_journal.
- hive-priv / hive-priv-sock: the ReadContainerJournal request, its
JournalQuery/JournalOutput wire types and the journalctl -M executor.
- nix: hive-c0re's systemd-journal supplementary group, whose only
reader was the journal-host handler.
- docs: the L0GS section, the API entry and the security table row.
Closes #5113
Closes #3819
This commit is contained in:
parent
71e7fd8b20
commit
884fb3e08b
31 changed files with 192 additions and 1231 deletions
|
|
@ -247,7 +247,7 @@ bind-mount compatibility with user namespace UID mapping first.
|
|||
|
||||
`hive-c0re` runs as the unprivileged system user `hive-core`
|
||||
(`/var/lib/hyperhive` owned by `hive-core:hive-core`). It can't
|
||||
directly invoke `nixos-container`, `journalctl -M`, or act on a system
|
||||
directly invoke `nixos-container` or act on a system
|
||||
unit (`systemctl reload nginx`) — those require root. `hive-priv` fills
|
||||
this gap.
|
||||
|
||||
|
|
@ -275,7 +275,6 @@ known operations; there is no arbitrary command pass-through:
|
|||
| `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
| `ReadContainerJournal` | `journalctl -M <container> -n <n> [filters...]` |
|
||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) |
|
||||
| `EnsureAgentSocketDir` | `mkdirat` `/run/hive-agent/<name>` `0751 root:root` under an `O_NOFOLLOW` parent fd; leaves an existing directory alone, refuses anything else |
|
||||
|
|
@ -313,8 +312,8 @@ writes to only the paths `hive-priv` legitimately needs.
|
|||
|
||||
### Privilege boundary summary
|
||||
|
||||
| Component | Runs as | Privilege needed for |
|
||||
| ---------------------------- | -------------- | ------------------------------------------------------------ |
|
||||
| `hive-c0re` | `hive-core` | broker, HTTP dashboard |
|
||||
| `hive-priv` | `root` | container lifecycle, journal reads, bind mounts, cred writes |
|
||||
| `hive-ag3nt` (per-container) | per-agent user | turn execution, MCP serving |
|
||||
| Component | Runs as | Privilege needed for |
|
||||
| ---------------------------- | -------------- | --------------------------------------------- |
|
||||
| `hive-c0re` | `hive-core` | broker, HTTP dashboard |
|
||||
| `hive-priv` | `root` | container lifecycle, bind mounts, cred writes |
|
||||
| `hive-ag3nt` (per-container) | per-agent user | turn execution, MCP serving |
|
||||
|
|
|
|||
|
|
@ -28,8 +28,7 @@ of your time. It's a single page with exactly four tabs:
|
|||
|
||||
Everything else lives on its own page instead, all reachable from the
|
||||
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
|
||||
the whole swarm), **Logs** (`/logs.html`, per-agent and host
|
||||
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||
the whole swarm), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||
**Builds** (`/builds.html`, the rebuild queue and build history), and
|
||||
**Core** (`/core.html`, tombstones and container resource use). Your local
|
||||
browser preferences (notifications) live in the dashboard's Y3R C4LL
|
||||
|
|
@ -72,9 +71,10 @@ merging a config change or bumping a flake input.
|
|||
takes effect until you hit **save all** at the bottom of the tab; a
|
||||
save queues a rebuild for whichever agents actually changed.
|
||||
|
||||
**Read an agent's logs.** The Logs page's AGENT tab pulls a live
|
||||
journald view for any agent + service; the per-agent `⋮` menu's
|
||||
**journal logs →** link jumps straight there, pre-filtered.
|
||||
**Read an agent's logs.** Logs aren't in the hive dashboard. Every
|
||||
hive ships its agents', daemons' and infra containers' journals to the
|
||||
swarm's VictoriaLogs; read them in Grafana's **Explore** — see
|
||||
[`swarm/services.md`](../swarm/services.md#logs-victorialogs).
|
||||
|
||||
**Set up a schedule.** Scheduled prompts are swarm-level: set them with
|
||||
`swarmctl schedule` ([`swarm/schedules.md`](../swarm/schedules.md)).
|
||||
|
|
|
|||
|
|
@ -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`, `logs.css`, `agent.css`)
|
||||
(`common.css`, `dashboard.css`, `flow.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.)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ hive-c0re serves the dashboard at `/dashboard.html` (with the home page 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 (`#swarm`, `#call`,
|
||||
`#permissions`, `#schedules`) drives which pane is
|
||||
active; hash changes don't reload the page. FL0W, L0GS, and the other
|
||||
active; hash changes don't reload the page. FL0W and the other
|
||||
standalone pages are reachable from the H0M3 hub at `/`, not
|
||||
from the dashboard tab strip.
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ from the dashboard tab strip.
|
|||
at `/`. Every surface links back to the hub rather than to each other.
|
||||
- **Tab strip**: `◆ SW4RM ◆`, `◆ Y3R C4LL ◆`, `◆ P3RM1SS10NS ◆`,
|
||||
`◆ SCH3DUL3S ◆`. In-page tabs only — the SYST3M panels live on the
|
||||
standalone **C0R3** page (`/core.html`), and FL0W / L0GS / ST4TS
|
||||
standalone **C0R3** page (`/core.html`), and FL0W / ST4TS
|
||||
live on their own pages too, all reachable from the
|
||||
H0M3 hub (not the tab strip). Count pills on SW4RM
|
||||
(container count), Y3R C4LL (unread
|
||||
|
|
@ -29,7 +29,7 @@ from the dashboard tab strip.
|
|||
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`)
|
||||
— sits below the tab strip.
|
||||
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
||||
on **every** page (dashboard + every stand-alone page — FL0W, L0GS,
|
||||
on **every** page (dashboard + every stand-alone page — FL0W,
|
||||
H0M3, C0R3, BU1LDS, ST4TS), injected at the top
|
||||
of `<body>` by `renderServerWarnings` in `common.js`. Driven by
|
||||
`state.server_warnings` — a list of `{ kind, level, message }` — and
|
||||
|
|
@ -53,9 +53,9 @@ from the dashboard tab strip.
|
|||
cold-load and updates it live as broker `sent` events arrive; it's
|
||||
preserved when `hive_name` / `swarm_name` later replace the raw title.
|
||||
|
||||
The FL0W and L0GS pages use a slim header (a `← home` back-link + the
|
||||
page title) rather than the dashboard tab strip — they're standalone
|
||||
surfaces, not tab panes.
|
||||
The FL0W page uses a slim header (a `← home` back-link + the page
|
||||
title) rather than the dashboard tab strip — it's a standalone surface,
|
||||
not a tab pane.
|
||||
|
||||
## SW4RM tab
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ instead — it's agent-terminal-only, unrelated to this section.
|
|||
|
||||
Passive / rare-interaction state, reached from the **Core** tile on the
|
||||
H0M3 hub (served at
|
||||
`/core.html`), with the same minimal chrome as `/logs.html`: a `← home`
|
||||
`/core.html`), with the same minimal chrome as `/builds.html`: a `← home`
|
||||
back-link + a `createTabStrip` sub-tab nav (**K3PT ST4T3** default,
|
||||
then **C0NT41N3R L04D**). The page is its own esbuild bundle (`core.js`)
|
||||
that cold-loads `/api/state` and subscribes to `/api/dashboard/stream` for
|
||||
|
|
@ -400,7 +400,7 @@ tab activation and on each dashboard state load.
|
|||
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 **Stats** tile on the H0M3
|
||||
hub, same minimal chrome as `/flow.html` / `/logs.html`. Distinct from
|
||||
hub, same minimal chrome as `/flow.html`. Distinct from
|
||||
each agent's own `/stats` page (which carries the per-agent trend
|
||||
charts): ST4TS is the swarm-level rollup.
|
||||
|
||||
|
|
@ -488,7 +488,7 @@ re-renders the terminal row. The operator addresses the root agent as `@root`.
|
|||
## H0M3 page (`/`)
|
||||
|
||||
The H0M3 hub is the primary landing page (served at `/` by default). A
|
||||
responsive grid of link tiles — Dashboard, Flow, Logs, Builds, Stats,
|
||||
responsive grid of link tiles — Dashboard, Flow, Builds, Stats,
|
||||
Core, API — each pointing to their respective
|
||||
surfaces, all unconditionally shown (no gating). The page is a pure
|
||||
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
|
||||
|
|
@ -496,45 +496,6 @@ from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).
|
|||
`home.js` fills the swarm/hive identity line at the top. All dashboard
|
||||
sub-pages include a `← Home` back-link for navigation.
|
||||
|
||||
## L0GS page (`/logs.html`)
|
||||
|
||||
A dedicated log-viewer page (not a tab pane — a separate HTML page),
|
||||
reachable from the Logs tile on the H0M3 hub. Minimal chrome:
|
||||
a `← home` back link and a three-item sub-tab strip. Tab
|
||||
routing is hash-based (`#agent`, `#infra`, `#system`); default is
|
||||
`#agent`. (Build log history lives on the BU1LDS page — see above.)
|
||||
|
||||
**AGENT sub-tab** — per-container journald viewer. Two selects: agent
|
||||
name (populated from `GET /api/state`) and unit filter
|
||||
(`hive-agent.service` / `hive-mcp-http.service` /
|
||||
`hive-bash-daemon.service` / `hive-matrix-daemon.service` /
|
||||
`(full machine journal)`). Fetches
|
||||
`GET /api/journal/{name}?unit=<unit>&lines=500` on selection change
|
||||
or `↻ refresh`. Output rendered as a `<pre>` block. A `?agent=<name>`
|
||||
and/or `?unit=<svc>` URL param pre-selects the agent + unit on page
|
||||
load — the per-agent `⋮` menu's **journal logs →** entry uses this to
|
||||
deep-link directly to a specific agent's journal. A "fetched N ago"
|
||||
chip appears after the `↻ refresh` button following each successful
|
||||
fetch and ticks every 30 s.
|
||||
|
||||
**INFRA sub-tab** — journald viewer for the three hive infrastructure
|
||||
containers (`hive-forge`, `hive-gateway`, `hive-matrix`), a
|
||||
fixed client-side list (`INFRA_NAMES` in `logs.js` — no dashboard API
|
||||
exposes just the name list). No unit filter (infra containers don't run
|
||||
the per-agent hive daemons) — always the full machine journal (or, for
|
||||
the gateway, the host journal filtered to its own unit). Fetches
|
||||
`GET /api/journal/{name}?lines=500`, same "fetched N ago" ticker as
|
||||
AGENT. A `?agent=<name>` deep-link routes here instead of AGENT when
|
||||
the name is one of the three infra containers.
|
||||
|
||||
**SYSTEM sub-tab** — host-side service logs. Unit selector
|
||||
(`hive-c0re.service` / `hive-priv.service`). Fetches
|
||||
`GET /api/journal-host?unit=<unit>&lines=500` on activation
|
||||
and on `↻ refresh`. Rendered as a `<pre>` block. A "fetched N ago"
|
||||
chip ticks every 30 s. Available to the operator unconditionally (not
|
||||
capability-gated — the endpoint lives on the hive-c0re dashboard,
|
||||
behind the gateway).
|
||||
|
||||
## Container row
|
||||
|
||||
A full-height **square agent icon** (5em, capped) on the left. The
|
||||
|
|
@ -700,9 +661,6 @@ per-agent actions and navigation links. Contents:
|
|||
Identical to the bulk actions on the selection bar but operate
|
||||
on one agent without requiring a selection.
|
||||
- `↻ R3BU1LD` — always available; queues a rebuild for this agent.
|
||||
- `journal logs →` — opens `/logs.html#agent?agent=<name>` so the
|
||||
operator lands directly in the AGENT log tab pre-filtered to this
|
||||
container, without having to pick an agent from the dropdown.
|
||||
- `DESTR0Y` / `PURG3` — destructive, each prompts for confirmation
|
||||
via the themed dialog (see **Themed dialogs** below).
|
||||
- `deployed:<sha> ↗` — present when the agent has a `deployed_sha`
|
||||
|
|
@ -884,8 +842,6 @@ below — some endpoints aren't in it yet.
|
|||
stdout+stderr as `text/plain`. Used by the `↓ raw` / `↓ download
|
||||
raw` links in BU1LDS. Works on both finished and in-progress
|
||||
build logs.
|
||||
- `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
|
||||
a managed container; rendered in the side panel.
|
||||
- `GET /static/marked.js` serves the vendored `marked` bundle used
|
||||
for markdown previews.
|
||||
- `GET /api/state-file?path=<host-or-container-path>` — bounded
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
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` / `logs.css` / `home.css` /
|
||||
`dashboard.css` / `flow.css` / `home.css` /
|
||||
`stats.css` / `core.css` / `builds.css`);
|
||||
`common.css` inlines `@hive/shared`'s
|
||||
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
||||
|
|
@ -247,7 +247,7 @@ values rather than per-row caches.
|
|||
tagged with `data-restore-key="<stable-key>"` survives the
|
||||
refresh. `snapshotOpenDetails()` walks managed sections before
|
||||
render, `restoreOpenDetails()` re-applies after. Long-content
|
||||
drill-ins (file previews, diffs, journald logs) now open in the
|
||||
drill-ins (file previews, diffs) now open in the
|
||||
**side panel** (see below) rather than expanding inline. The
|
||||
dashboard's own answered-questions history list was the only
|
||||
`<details data-restore-key>` consumer and is gone
|
||||
|
|
@ -261,8 +261,8 @@ that needs it rather than ripped out along with their one caller.
|
|||
swipes in from the right — a singleton `#side-panel` with a
|
||||
titled header, a close button, and a scrollable body. Closes on
|
||||
the button, selecting the backdrop, or `Escape`. `Panel.open(title,
|
||||
node)` swaps the body; the JS builders for file previews and
|
||||
journald logs all render into it. **The
|
||||
node)` swaps the body; the JS builder for file previews
|
||||
renders into it. **The
|
||||
drawer width is drag-to-resize**: a thin 6px hit-strip on
|
||||
the left edge captures pointer events, resizes the drawer in
|
||||
real-time (pointer capture keeps dragging even if the cursor
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at
|
||||
// `/`, dashboard/flow/logs/core/builds at their own
|
||||
// `/`, dashboard/flow/core/builds at their 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.
|
||||
|
|
@ -35,7 +35,6 @@ await build({
|
|||
entryPoints: [
|
||||
src("tabs.js"),
|
||||
src("flow.js"),
|
||||
src("logs.js"),
|
||||
src("home.js"),
|
||||
src("stats.js"),
|
||||
src("core.js"),
|
||||
|
|
@ -101,7 +100,6 @@ for (const entry of [
|
|||
"common.css",
|
||||
"dashboard.css",
|
||||
"flow.css",
|
||||
"logs.css",
|
||||
"home.css",
|
||||
"stats.css",
|
||||
"core.css",
|
||||
|
|
@ -120,7 +118,6 @@ for (const html of [
|
|||
"index.html",
|
||||
"dashboard.html",
|
||||
"flow.html",
|
||||
"logs.html",
|
||||
"stats.html",
|
||||
"core.html",
|
||||
"builds.html",
|
||||
|
|
|
|||
|
|
@ -140,24 +140,6 @@ class HiveAgentMenu extends HTMLElement {
|
|||
const menuSep = () =>
|
||||
el("li", { class: "agent-menu-sep", role: "separator" });
|
||||
|
||||
// Navigation link item (opens in same tab by default).
|
||||
const menuLink = (label, href, title) => {
|
||||
const li = el("li", { role: "presentation" });
|
||||
const a = el(
|
||||
"a",
|
||||
{
|
||||
class: "agent-menu-item",
|
||||
href,
|
||||
role: "menuitem",
|
||||
title: title || "",
|
||||
},
|
||||
label,
|
||||
);
|
||||
a.addEventListener("click", close);
|
||||
li.append(a);
|
||||
return li;
|
||||
};
|
||||
|
||||
// Show only actions that are applicable in the current state.
|
||||
if (c.running) {
|
||||
dropdown.append(
|
||||
|
|
@ -209,15 +191,6 @@ class HiveAgentMenu extends HTMLElement {
|
|||
action: "/api/rebuild/",
|
||||
confirm: `rebuild ${c.name}? hot-reloads the container.`,
|
||||
}),
|
||||
menuSep(),
|
||||
// Deep-link to the AGENT log tab pre-filtered to this container.
|
||||
// The ?agent= param is read by logs.js on load and pre-selects this
|
||||
// agent's journal without extra clicks.
|
||||
menuLink(
|
||||
"journal logs →",
|
||||
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
|
||||
`view ${c.name} journal logs`,
|
||||
),
|
||||
);
|
||||
|
||||
dropdown.append(
|
||||
|
|
|
|||
|
|
@ -1,14 +1,11 @@
|
|||
/* ─── /builds.html — BU1LDS page ────────────────────────────────────
|
||||
The build lifecycle hub: rebuild queue + live log, meta inputs, and
|
||||
build log history on one page. Carved out of /core.html (rebuild
|
||||
queue + meta inputs) and /logs.html (BUILD tab).
|
||||
build log history on one page.
|
||||
|
||||
Imports system-sections.css for rebuild queue + meta inputs + live-log
|
||||
component styles, and logs.css for the build-logs-* component styles.
|
||||
Body-scoped `.logs-shell` rules in logs.css don't apply here
|
||||
(`.builds-shell` body); only the build-logs-* component rules are used. */
|
||||
component styles; the build-logs-* rules for the BUILD L0GS list are
|
||||
below. */
|
||||
@import "./system-sections.css";
|
||||
@import "./logs.css";
|
||||
@import "@hive/shared/jobq-graph.css";
|
||||
|
||||
body.builds-shell {
|
||||
|
|
@ -27,3 +24,116 @@ body.builds-shell {
|
|||
.builds-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
align-items: center;
|
||||
margin-bottom: 0.9em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ─── build-log list ──────────────────────────────────────────────
|
||||
Scrollable list of log header rows; clicking a row expands full
|
||||
stdout+stderr inline. */
|
||||
.build-logs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.build-logs-item {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
.build-logs-row-btn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
text-align: left;
|
||||
padding: 0.25em 0.4em;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.build-logs-row-btn:hover {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.build-logs-kind {
|
||||
font-weight: 600;
|
||||
}
|
||||
.build-logs-age {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.build-logs-cmdline {
|
||||
color: var(--muted);
|
||||
font-size: 0.82em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 40em;
|
||||
}
|
||||
.build-logs-detail {
|
||||
padding: 0 0.4em 0.4em;
|
||||
}
|
||||
.build-logs-output {
|
||||
margin: 0.3em 0 0;
|
||||
padding: 0.4em 0.6em;
|
||||
background: var(--crust);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 30em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.build-logs-dl {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 2px;
|
||||
margin-top: 0.3em;
|
||||
display: none;
|
||||
}
|
||||
.build-logs-dl:not([hidden]) {
|
||||
display: inline-block;
|
||||
}
|
||||
.build-logs-dl:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
|
||||
.build-logs-live-badge {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.build-logs-live-badge.badge-running {
|
||||
animation: live-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.build-logs-runtime {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@
|
|||
</head>
|
||||
<body class="builds-shell">
|
||||
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
|
||||
meta inputs, and build log history on one page. Carved out of the
|
||||
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
|
||||
tab) so the full build lifecycle is in one place. Same minimal
|
||||
meta inputs, and build log history on one page. Same minimal
|
||||
chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
|
||||
sub-tab nav. Default sub-tab is the rebuild queue. -->
|
||||
<header class="page-header">
|
||||
|
|
@ -74,7 +72,7 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
|
||||
<!-- BUILD L0GS: all-agents build log history.
|
||||
Click a row to expand stdout + stderr. Live builds stream in real
|
||||
time. Lazy-loaded on first tab activation; auto-refreshes when
|
||||
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
// - R3BU1LD QU3U3: pending + running rebuilds + live build log inline
|
||||
// - M3T4 1NPUTS: nix flake input selector + meta-update trigger
|
||||
// - BUILD L0GS: all-agents build log history (lazy-loaded on tab show)
|
||||
//
|
||||
// Carved out of /core.html (rebuild queue + meta inputs) and /logs.html
|
||||
// (BUILD tab) so the full build lifecycle is in one place. The section
|
||||
// renderers here are direct copies from core.js / logs.js with only the
|
||||
// deep-link URL and count-pill id adjusted.
|
||||
|
||||
import {
|
||||
$,
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
@import "@hive/shared/pill.css";
|
||||
|
||||
/* ─── global typography ─────────────────────────────────────────────
|
||||
Element-level rules shared across all three pages (index, flow,
|
||||
logs). These must not reference page-specific chrome classes. */
|
||||
Element-level rules shared across every page. These must not
|
||||
reference page-specific chrome classes. */
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
|
|
@ -152,8 +152,7 @@ code {
|
|||
}
|
||||
|
||||
/* ─── buttons ───────────────────────────────────────────────────────
|
||||
.btn base + semantic colour/size modifiers. Logs page uses
|
||||
.btn-restart for the ↻ refresh buttons; dashboard uses all variants. */
|
||||
.btn base + semantic colour/size modifiers. */
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
|
|
@ -516,7 +515,7 @@ code {
|
|||
}
|
||||
|
||||
/* ─── side panel ─────────────────────────────────────────────────
|
||||
Long content (file previews, diffs, journald, 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
|
||||
the dashboard. The drawer chrome itself (backdrop, drawer, resize
|
||||
handle, header, title, close button) is now the shared
|
||||
|
|
@ -605,8 +604,8 @@ hive-side-panel .md td {
|
|||
carries the horizontal gutter. Full-width chrome (the tab strip, the
|
||||
server-warnings banner, the footer) then spans edge-to-edge for free,
|
||||
with no negative-margin breakout hacks. Only matches on pages that opt
|
||||
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
|
||||
ST4TS are full-bleed with their own `.<page>-main` padding. */
|
||||
in by adding the wrapper (dashboard.html, index.html); FL0W / ST4TS
|
||||
are full-bleed with their own `.<page>-main` padding. */
|
||||
.page-content {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -292,12 +292,10 @@ export function openStream(url) {
|
|||
return target;
|
||||
}
|
||||
|
||||
// Stream a build log into a <pre>, returning the EventSource. Shared by the
|
||||
// L0GS page BUILD tab and the C0R3 rebuild-queue live-log panel so the
|
||||
// append / sticky-scroll / stderr-separator / reconnect-replay logic lives in
|
||||
// one place. Appends `stdout_append` then `stderr_append` (one `--- stderr ---`
|
||||
// separator) frames from `GET /api/build-logs/id/{id}/stream`; auto-scrolls to
|
||||
// the bottom unless the operator scrolled up; on the terminal `done` frame
|
||||
// Stream a build log into a <pre>, returning the EventSource. Used by the
|
||||
// BU1LDS page's BUILD L0GS tab. Appends `stdout_append` then
|
||||
// `stderr_append` (one `--- stderr ---` separator) frames from
|
||||
// `GET /api/build-logs/id/{id}/stream`; auto-scrolls to the bottom unless the operator scrolled up; on the terminal `done` frame
|
||||
// closes the stream and calls `onDone(status)`; on a non-transient error
|
||||
// closes and calls `onError()`. The backend replays accumulated output on each
|
||||
// (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling.
|
||||
|
|
@ -348,7 +346,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
|||
|
||||
// ─── side panel ─────────────────────────────────────────────────────────
|
||||
// Singleton drawer that swipes in from the right. Long content
|
||||
// (file previews, journald logs, applied config)
|
||||
// (file previews, applied config)
|
||||
// opens here via `sidePanel.open(title, node)` instead of expanding
|
||||
// inline. Body is swapped on each open; closing just slides out so
|
||||
// the content stays visible through the transition.
|
||||
|
|
@ -654,7 +652,7 @@ export const NOTIF = (() => {
|
|||
|
||||
// ─── server warnings banner ──────────────────────────────────────────
|
||||
// A generic top-of-page banner shown on every page (dashboard + the
|
||||
// stand-alone FL0W / L0GS / H0M3 pages). The backend decides what to
|
||||
// stand-alone FL0W / H0M3 pages). The backend decides what to
|
||||
// warn about — `/api/state.server_warnings` is a list of
|
||||
// `{ kind, level, message }` — and this just renders it, coloured by
|
||||
// `level` (`warn` amber / `crit` red). Adding a new system warning is a
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ body.core-shell {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
/* Fill the header row next to the ← home back-link, same as the logs
|
||||
sub-tab strip. */
|
||||
/* Fill the header row next to the ← home back-link, same as the
|
||||
builds sub-tab strip. */
|
||||
.core-tabbar {
|
||||
flex: 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<!-- C0R3: the host/coordinator surface. Standalone page (served at
|
||||
/core.html) carved out of the dashboard's old SYST3M tab so the
|
||||
dashboard tab strip stays lean. Same minimal chrome as
|
||||
/logs.html — a `← home` back-link to the H0M3 hub + a
|
||||
/builds.html — a `← home` back-link to the H0M3 hub + a
|
||||
<hive-tab-strip> sub-tab nav. Two sub-tabs: kept state (tombstones)
|
||||
and container load. Rebuild queue + meta inputs have moved to
|
||||
/builds.html (the build lifecycle hub). Default tab: K3PT ST4T3.
|
||||
|
|
|
|||
|
|
@ -90,11 +90,9 @@
|
|||
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||
under SW4RM. See tab-pane-swarm.) -->
|
||||
|
||||
<!-- FL0W, L0GS, and the other standalone pages are reachable from
|
||||
the H0M3 hub (served at /), not from the dashboard tab strip —
|
||||
the strip holds only in-page tabs. Contextual deep-links into
|
||||
the log viewer (an agent's logs, a build entry's log) live
|
||||
inside the relevant dashboard content. -->
|
||||
<!-- FL0W and the other standalone pages are reachable from the
|
||||
H0M3 hub (served at /), not from the dashboard tab strip —
|
||||
the strip holds only in-page tabs. -->
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
|
|
@ -335,8 +333,8 @@
|
|||
</p>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in detail panel (clicked file previews, journald logs,
|
||||
applied config) is a <hive-side-panel> element
|
||||
<!-- Slide-in detail panel (clicked file previews, applied config)
|
||||
is a <hive-side-panel> element
|
||||
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
||||
creates + appends it to <body> lazily on first use, so nothing
|
||||
needs to be pre-declared here. -->
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ body.flow-shell .flow-main-slim {
|
|||
|
||||
/* Agent filter: a select in the FL0W header narrows the
|
||||
timeline to messages involving one agent. Non-matching rows get
|
||||
`.flow-hidden`; the select reuses the journal-unit control look. */
|
||||
`.flow-hidden`. */
|
||||
.flow-filter {
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
|
|
|
|||
|
|
@ -64,14 +64,6 @@
|
|||
>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/logs.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📜</span>
|
||||
<span class="home-tile-label">Logs</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">agent · infra · system logs</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/stats.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📊</span>
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
/* ─── /logs.html — log viewer ──────────────────────────────────────
|
||||
Same minimal-chrome pattern as /flow.html but with a sub-tab strip
|
||||
instead of a full dashboard tabbar. */
|
||||
|
||||
body.logs-shell {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Full-viewport flex column so the log pane can scroll internally
|
||||
instead of the whole page scrolling. The sticky-top wrapper and
|
||||
page-header shrink to their natural height; .logs-main takes the
|
||||
rest and each visible .logs-pane fills it. */
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Base tab styling now lives in @hive/shared/tabs.css (.hive-tab*). The
|
||||
logs strip only adds a layout delta: fill the header row next to the
|
||||
← home back-link. `min-width: 0` lets this flex:1 nav shrink below its
|
||||
intrinsic content width instead of being bumped onto its own row under
|
||||
the back-link (the `.page-header` is explicitly nowrap; see chrome.css). */
|
||||
.logs-tabbar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logs-main {
|
||||
padding: 1.2em 1.5em 0;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.logs-pane {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 1.2em;
|
||||
}
|
||||
.logs-toolbar {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
align-items: center;
|
||||
margin-bottom: 0.9em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ─── per-container journald + build-log viewers ──────────────────
|
||||
Both open inside the logs page: a toolbar above a scrollable
|
||||
output area. `.journal-*` classes power the agent and system log
|
||||
tabs; `.build-logs-*` power the build tab. */
|
||||
|
||||
.journal-controls {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
.journal-unit {
|
||||
font-family: inherit;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
.logs-toolbar .journal-unit {
|
||||
background: var(--border);
|
||||
border-color: var(--purple-dim);
|
||||
border-radius: 4px;
|
||||
padding: 0.25em 0.5em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.journal-refresh {
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
}
|
||||
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
|
||||
.logs-fetch-ts {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.journal-output {
|
||||
margin: 0;
|
||||
background: var(--crust);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.5em 0.7em;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
white-space: pre;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
/* ─── build-log list ──────────────────────────────────────────────
|
||||
Build tab: toolbar above a scrollable list of log header rows;
|
||||
clicking a row expands full stdout+stderr inline. */
|
||||
.build-logs-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.build-logs-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
.build-logs-refresh {
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
}
|
||||
.build-logs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.build-logs-loading,
|
||||
.build-logs-empty,
|
||||
.build-logs-error {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
padding: 0.3em 0;
|
||||
}
|
||||
.build-logs-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.build-logs-item {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.15em 0;
|
||||
}
|
||||
.build-logs-row-btn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
align-items: baseline;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 0.85em;
|
||||
text-align: left;
|
||||
padding: 0.25em 0.4em;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.build-logs-row-btn:hover {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.build-logs-kind {
|
||||
font-weight: 600;
|
||||
}
|
||||
.build-logs-age {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.build-logs-cmdline {
|
||||
color: var(--muted);
|
||||
font-size: 0.82em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 40em;
|
||||
}
|
||||
.build-logs-detail {
|
||||
padding: 0 0.4em 0.4em;
|
||||
}
|
||||
.build-logs-output {
|
||||
margin: 0.3em 0 0;
|
||||
padding: 0.4em 0.6em;
|
||||
background: var(--crust);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
max-height: 30em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.build-logs-dl {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.1em 0.4em;
|
||||
border-radius: 2px;
|
||||
margin-top: 0.3em;
|
||||
display: none;
|
||||
}
|
||||
.build-logs-dl:not([hidden]) {
|
||||
display: inline-block;
|
||||
}
|
||||
.build-logs-dl:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
|
||||
.build-logs-live-badge {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.build-logs-live-badge.badge-running {
|
||||
animation: live-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.build-logs-runtime {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // LOGS</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/logs.css" />
|
||||
</head>
|
||||
<body class="logs-shell">
|
||||
<!-- Minimal chrome: back link + sub-tab strip.
|
||||
Same pattern as flow.html — no full dashboard tabbar. Back link
|
||||
points to the H0M3 hub (served at /), not the dashboard.
|
||||
Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
|
||||
moved to /builds.html (the build lifecycle hub). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar logs-tabbar"
|
||||
id="logs-tabbar"
|
||||
prefix="logs"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="logs-main">
|
||||
<!-- AGENT: journald viewer for a specific agent container.
|
||||
Agent selector + unit filter + line count. Backed by
|
||||
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-agent"
|
||||
data-tab-pane="agent"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-agent"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="agent-select" class="journal-unit"></select>
|
||||
<select id="agent-unit-select" class="journal-unit">
|
||||
<option value="hive-agent.service">hive-agent.service</option>
|
||||
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
|
||||
<option value="hive-bash-daemon.service">
|
||||
hive-bash-daemon.service
|
||||
</option>
|
||||
<option value="hive-matrix-daemon.service">
|
||||
hive-matrix-daemon.service
|
||||
</option>
|
||||
<option value="">(full machine journal)</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="agent-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="agent-output" class="journal-output">
|
||||
select an agent above</pre
|
||||
>
|
||||
</section>
|
||||
|
||||
<!-- INFRA: journald viewer for infrastructure containers (hive-forge,
|
||||
hive-gateway, hive-matrix). Always fetches the full
|
||||
machine journal — no unit filter (infra containers don't run the
|
||||
per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N.
|
||||
Deep-link: ?agent=hive-forge routes here instead of the AGENT tab. -->
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-infra"
|
||||
data-tab-pane="infra"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-infra"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="infra-select" class="journal-unit"></select>
|
||||
<button type="button" class="btn btn-restart" id="infra-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="infra-output" class="journal-output">
|
||||
select a container above</pre
|
||||
>
|
||||
</section>
|
||||
|
||||
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
||||
journal via GET /api/journal-host?unit=hive-c0re.service. -->
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-system"
|
||||
data-tab-pane="system"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-system"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="system-unit-select" class="journal-unit">
|
||||
<option value="hive-c0re.service">hive-c0re.service</option>
|
||||
<option value="hive-priv.service">hive-priv.service</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="system-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="system-output" class="journal-output">loading…</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/logs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,284 +0,0 @@
|
|||
// /logs.html entry point: log viewer with three sub-tabs (AGENT, INFRA,
|
||||
// SYSTEM). Build log history lives at /builds.html, not here.
|
||||
// AGENT — per-container journald viewer, backed by GET /api/journal/{name}
|
||||
// INFRA — infra-container journald viewer (hive-forge, hive-gateway, …),
|
||||
// same API but full machine journal only (no unit filter)
|
||||
// SYSTEM — host service logs, backed by GET /api/journal-host
|
||||
//
|
||||
// Tab routing via URL hash (#agent, #infra, #system). Default: #agent.
|
||||
//
|
||||
// URL params `?agent=name` and `?unit=svc` pre-select the agent + unit.
|
||||
// When `?agent=` names an infra container the INFRA tab is activated instead.
|
||||
// Last-fetched timestamp is shown next to the refresh button on AGENT,
|
||||
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
|
||||
|
||||
import { $, fmtAgeSecs, initServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
|
||||
(() => {
|
||||
initServerWarnings();
|
||||
|
||||
// ─── tab routing ──────────────────────────────────────────────────────
|
||||
// The shared hash-routed tab strip (@hive/shared/tabs.js) handles the
|
||||
// active toggle + aria-selected + pane visibility + lazy fetches via
|
||||
// onShow. Constructed in the init block below (after the fetch fns +
|
||||
// element refs it depends on exist). `logTabs.active()` replaces the
|
||||
// old `activeTab()`.
|
||||
let logTabs;
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
|
||||
function fmtFetchTs(fetchedAt) {
|
||||
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
|
||||
return (
|
||||
"fetched " + (ageSecs < 5 ? "just now" : fmtAgeSecs(ageSecs) + " ago")
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AGENT tab ────────────────────────────────────────────────────────
|
||||
|
||||
const agentSelect = $("agent-select");
|
||||
const agentUnitSelect = $("agent-unit-select");
|
||||
const agentRefresh = $("agent-refresh");
|
||||
const agentOutput = $("agent-output");
|
||||
const agentFetchTs = $("agent-fetch-ts");
|
||||
|
||||
let agentFetching = false;
|
||||
let agentLastFetch = 0;
|
||||
async function fetchAgent() {
|
||||
if (!agentSelect || !agentOutput) return;
|
||||
const name = agentSelect.value;
|
||||
if (!name) {
|
||||
agentOutput.textContent = "select an agent above";
|
||||
return;
|
||||
}
|
||||
if (agentFetching) return;
|
||||
agentFetching = true;
|
||||
agentOutput.textContent = "fetching…";
|
||||
if (agentFetchTs) agentFetchTs.hidden = true;
|
||||
const unit = agentUnitSelect ? agentUnitSelect.value : "";
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
if (unit) params.set("unit", unit);
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/journal/" + encodeURIComponent(name) + "?" + params,
|
||||
);
|
||||
const text = await resp.text();
|
||||
agentOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
agentOutput.scrollTop = agentOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
agentLastFetch = Date.now();
|
||||
if (agentFetchTs) {
|
||||
agentFetchTs.textContent = fmtFetchTs(agentLastFetch);
|
||||
agentFetchTs.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
agentOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
agentFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentSelect) agentSelect.addEventListener("change", fetchAgent);
|
||||
if (agentUnitSelect) agentUnitSelect.addEventListener("change", fetchAgent);
|
||||
if (agentRefresh) agentRefresh.addEventListener("click", fetchAgent);
|
||||
|
||||
// ─── INFRA tab ────────────────────────────────────────────────────────
|
||||
// Infra containers (hive-forge, hive-gateway, hive-matrix) don't
|
||||
// run the per-agent hive daemons, so the unit filter is inapplicable. We
|
||||
// always fetch the full machine journal for them.
|
||||
|
||||
const infraSelect = $("infra-select");
|
||||
const infraRefresh = $("infra-refresh");
|
||||
const infraOutput = $("infra-output");
|
||||
const infraFetchTs = $("infra-fetch-ts");
|
||||
|
||||
let infraFetching = false;
|
||||
let infraLastFetch = 0;
|
||||
async function fetchInfra() {
|
||||
if (!infraSelect || !infraOutput) return;
|
||||
const name = infraSelect.value;
|
||||
if (!name) {
|
||||
infraOutput.textContent = "select a container above";
|
||||
return;
|
||||
}
|
||||
if (infraFetching) return;
|
||||
infraFetching = true;
|
||||
infraOutput.textContent = "fetching…";
|
||||
if (infraFetchTs) infraFetchTs.hidden = true;
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/journal/" + encodeURIComponent(name) + "?" + params,
|
||||
);
|
||||
const text = await resp.text();
|
||||
infraOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
infraOutput.scrollTop = infraOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
infraLastFetch = Date.now();
|
||||
if (infraFetchTs) {
|
||||
infraFetchTs.textContent = fmtFetchTs(infraLastFetch);
|
||||
infraFetchTs.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
infraOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
infraFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (infraSelect) infraSelect.addEventListener("change", fetchInfra);
|
||||
if (infraRefresh) infraRefresh.addEventListener("click", fetchInfra);
|
||||
|
||||
// Fixed allowlist — the three hive infra services never change at
|
||||
// runtime, and there's no dashboard API exposing just the name list
|
||||
// (the one that used to, `/api/state`'s `infra_containers` field, was
|
||||
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
|
||||
const INFRA_NAMES = ["hive-forge", "hive-gateway", "hive-matrix"];
|
||||
|
||||
// ─── container list init ──────────────────────────────────────────────
|
||||
// Fetch /api/state once to populate the AGENT selector (agents only);
|
||||
// INFRA is a fixed list (see INFRA_NAMES). Also handles the ?agent= /
|
||||
// ?unit= deep-link, routing to the INFRA tab when the named container
|
||||
// is an infra container.
|
||||
async function loadContainerLists() {
|
||||
if (infraSelect) {
|
||||
infraSelect.replaceChildren();
|
||||
infraSelect.append(el("option", { value: "" }, "— select container —"));
|
||||
for (const name of INFRA_NAMES) {
|
||||
infraSelect.append(el("option", { value: name }, name));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) return;
|
||||
const state = await resp.json();
|
||||
|
||||
// Populate AGENT selector (agents only — no infra optgroup).
|
||||
if (agentSelect) {
|
||||
agentSelect.replaceChildren();
|
||||
agentSelect.append(el("option", { value: "" }, "— select agent —"));
|
||||
for (const c of state.containers || []) {
|
||||
agentSelect.append(el("option", { value: c.name }, c.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-link: honour ?agent= and ?unit= URL params.
|
||||
const urlAgent = new URLSearchParams(location.search).get("agent");
|
||||
const urlUnit = new URLSearchParams(location.search).get("unit");
|
||||
if (urlAgent) {
|
||||
if (INFRA_NAMES.includes(urlAgent)) {
|
||||
// Route to INFRA tab.
|
||||
logTabs.show("infra");
|
||||
if (infraSelect) {
|
||||
infraSelect.value = urlAgent;
|
||||
fetchInfra();
|
||||
}
|
||||
} else if (agentSelect) {
|
||||
// Route to AGENT tab.
|
||||
const found = Array.from(agentSelect.options).some(
|
||||
(o) => o.value === urlAgent,
|
||||
);
|
||||
if (found) {
|
||||
agentSelect.value = urlAgent;
|
||||
if (urlUnit && agentUnitSelect) {
|
||||
const unitFound = Array.from(agentUnitSelect.options).some(
|
||||
(o) => o.value === urlUnit,
|
||||
);
|
||||
if (unitFound) agentUnitSelect.value = urlUnit;
|
||||
}
|
||||
fetchAgent();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/**/
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SYSTEM tab ───────────────────────────────────────────────────────
|
||||
|
||||
const systemUnitSelect = $("system-unit-select");
|
||||
const systemRefresh = $("system-refresh");
|
||||
const systemOutput = $("system-output");
|
||||
const systemFetchTs = $("system-fetch-ts");
|
||||
|
||||
let systemFetching = false;
|
||||
let systemLastFetch = 0;
|
||||
async function fetchSystem() {
|
||||
if (!systemOutput) return;
|
||||
if (systemFetching) return;
|
||||
systemFetching = true;
|
||||
systemOutput.textContent = "fetching…";
|
||||
if (systemFetchTs) systemFetchTs.hidden = true;
|
||||
const unit = systemUnitSelect
|
||||
? systemUnitSelect.value
|
||||
: "hive-c0re.service";
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
if (unit) params.set("unit", unit);
|
||||
try {
|
||||
const resp = await fetch("/api/journal-host?" + params);
|
||||
const text = await resp.text();
|
||||
systemOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
systemOutput.scrollTop = systemOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
systemLastFetch = Date.now();
|
||||
if (systemFetchTs) {
|
||||
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
|
||||
systemFetchTs.hidden = false;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
systemOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
systemFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (systemUnitSelect)
|
||||
systemUnitSelect.addEventListener("change", fetchSystem);
|
||||
if (systemRefresh) systemRefresh.addEventListener("click", fetchSystem);
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Wire the shared tab strip now that fetchSystem + the element refs it
|
||||
// needs are defined. Its initial show() paints the active pane and, if
|
||||
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
|
||||
// Default: AGENT.
|
||||
logTabs = document.getElementById("logs-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: "agent", label: "AGENT" },
|
||||
{ id: "infra", label: "INFRA" },
|
||||
{ id: "system", label: "SYSTEM" },
|
||||
],
|
||||
defaultId: "agent",
|
||||
onShow: (id) => {
|
||||
if (id === "system") fetchSystem();
|
||||
},
|
||||
});
|
||||
loadContainerLists();
|
||||
|
||||
// Tick the last-fetched timestamps every 30s so "fetched 1m ago" stays
|
||||
// accurate without a manual refresh.
|
||||
setInterval(() => {
|
||||
if (agentLastFetch && agentFetchTs && !agentFetchTs.hidden) {
|
||||
agentFetchTs.textContent = fmtFetchTs(agentLastFetch);
|
||||
}
|
||||
if (infraLastFetch && infraFetchTs && !infraFetchTs.hidden) {
|
||||
infraFetchTs.textContent = fmtFetchTs(infraLastFetch);
|
||||
}
|
||||
if (systemLastFetch && systemFetchTs && !systemFetchTs.hidden) {
|
||||
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
|
||||
}
|
||||
}, 30_000);
|
||||
})();
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
/* ─── /stats.html — hive-wide turn-stats rollup ────────────────────
|
||||
Extracted from the dashboard ST4TS tab. Same minimal-
|
||||
chrome pattern as /flow.html and /logs.html: the back-link header +
|
||||
chrome pattern as /flow.html: the back-link header +
|
||||
title (.page-header / .page-back / .page-title) live in
|
||||
@hive/shared/chrome.css.
|
||||
`.hive-stats-table` also lives in common.css — it's shared with the
|
||||
|
|
|
|||
|
|
@ -11,14 +11,14 @@
|
|||
<link rel="stylesheet" href="/static/stats.css" />
|
||||
</head>
|
||||
<body class="stats-shell">
|
||||
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
|
||||
logs.html — no full dashboard tabbar. Back link points to the
|
||||
<!-- Minimal chrome: back link + title. Same pattern as flow.html —
|
||||
no full dashboard tabbar. Back link points to the
|
||||
H0M3 hub (served at /), not the dashboard. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">ST4TS</span>
|
||||
<!-- Window selector lives in the header so it behaves like a tab strip
|
||||
(matches the pattern on /logs.html). Hash-routed via createTabStrip. -->
|
||||
(matches the sub-tab strips on /core.html). Hash-routed via createTabStrip. -->
|
||||
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
|
||||
<button type="button" class="btn" data-tab="1h">1h</button>
|
||||
<button type="button" class="btn" data-tab="4h">4h</button>
|
||||
|
|
|
|||
|
|
@ -159,8 +159,7 @@ function clearSelection() {
|
|||
}
|
||||
// Esc clears the current selection (operator escape hatch — mirrors
|
||||
// the side-panel close pattern). Ignored when an editable element
|
||||
// has focus so typing in compose / answer / journal-search isn't
|
||||
// intercepted.
|
||||
// has focus so typing in compose / answer isn't intercepted.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (!selectionState.size) return;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/* ─── page chrome: back-link header ────────────────────────────────
|
||||
The minimal sticky "frosted bar" header used by every standalone page
|
||||
reached from the H0M3 hub — /flow, /logs, /stats, /settings. A back
|
||||
link to H0M3 on the left, then either a page title or (on /logs) a
|
||||
createTabStrip sub-tab nav. The operator dashboard uses its own richer
|
||||
reached from the H0M3 hub — /flow, /stats, /core, /builds. A back
|
||||
link to H0M3 on the left, then either a page title or (on /core and
|
||||
/builds) a sub-tab nav. The operator dashboard uses its own richer
|
||||
sticky chrome (`.dashboard-chrome`); these classes cover the simple
|
||||
pages.
|
||||
|
||||
|
|
@ -24,10 +24,9 @@
|
|||
border-bottom: 1px solid var(--purple-dim);
|
||||
display: flex;
|
||||
/* Keep the back-link + sub-tab nav on one row. Without an explicit
|
||||
nowrap a flex:1 sub-tab strip (e.g. /logs.html's `.logs-tabbar`)
|
||||
whose intrinsic min-content width can't shrink (default
|
||||
`min-width: auto`) gets pushed onto its own line. The strip pairs
|
||||
this with `min-width: 0` so it can shrink instead of wrapping. */
|
||||
nowrap a flex:1 sub-tab strip (e.g. `.core-tabbar`) whose intrinsic
|
||||
min-content width can't shrink (default `min-width: auto`) gets
|
||||
pushed onto its own line. */
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 1.5em;
|
||||
|
|
|
|||
|
|
@ -1,253 +0,0 @@
|
|||
//! Journal-read endpoints for the dashboard.
|
||||
//!
|
||||
//! `GET /api/journal/{name}` reads a managed agent container's journal, OR
|
||||
//! one of the three hive infra services (`hive-forge`, `hive-gateway`,
|
||||
//! `hive-matrix` — [`hive_priv_sock::InfraContainer`] is the
|
||||
//! allowlist). A container's journal is a `journalctl -M` read, delegated
|
||||
//! to the root helper since entering a machine needs privileges hive-c0re
|
||||
//! doesn't have; `hive-gateway` is nginx on the host, so it reads host
|
||||
//! journald filtered to that unit and needs no helper at all.
|
||||
//! `GET /api/journal-host` reads host-side journald, both gated by an
|
||||
//! allow-list of known units so arbitrary unit names can't be probed.
|
||||
//! Operator-only by virtue of the dashboard binding host-only.
|
||||
|
||||
use axum::{
|
||||
extract::Path as AxumPath,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{Ident, error_problem, strip_container_prefix};
|
||||
use crate::lifecycle;
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct JournalQuery {
|
||||
/// Optional systemd unit filter — e.g. `hive-agent.service`. When
|
||||
/// omitted, returns the full machine journal.
|
||||
#[serde(default)]
|
||||
unit: Option<String>,
|
||||
/// Number of trailing lines to return. Capped at 5000.
|
||||
#[serde(default)]
|
||||
lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// Read `journalctl -M <container> -b` and return its text output.
|
||||
///
|
||||
/// Operator-only by virtue of the dashboard being host-bound. hive-c0re
|
||||
/// runs unprivileged (privsep), so the `-M` read — which enters the
|
||||
/// container namespace and needs root — is delegated to hive-priv.
|
||||
///
|
||||
/// `name` is either a managed agent name (`iris`, optionally already
|
||||
/// carrying the `h-` prefix) or one of the three infra names (`hive-forge` /
|
||||
/// `hive-gateway` / `hive-matrix` — see
|
||||
/// [`hive_priv_sock::InfraContainer`]). Infra targets don't run the
|
||||
/// per-agent hive daemons, so `unit` is ignored for them — the whole
|
||||
/// machine journal, or for the gateway the host journal filtered to its
|
||||
/// own unit.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/journal/{name}",
|
||||
params(
|
||||
("name" = String, Path, description = "agent name, or one of the three infra container names"),
|
||||
JournalQuery,
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
|
||||
(status = 400, description = "bad agent name or unknown unit"),
|
||||
(status = 404, description = "no such managed container"),
|
||||
(status = 500, description = "container list unreadable"),
|
||||
),
|
||||
tag = "journal"
|
||||
)]
|
||||
pub(super) async fn get_journal(
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::extract::Query(q): axum::extract::Query<JournalQuery>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
|
||||
if let Ok(infra) = name.parse::<hive_priv_sock::InfraContainer>() {
|
||||
return match infra.target() {
|
||||
hive_priv_sock::InfraTarget::Container(machine) => {
|
||||
read_journal_response(machine, None, lines).await
|
||||
}
|
||||
// No machine to enter — the gateway's nginx is a host unit, so
|
||||
// this is a plain host-journal read filtered to it. `-M` is
|
||||
// what needed root here, not journalctl itself.
|
||||
hive_priv_sock::InfraTarget::HostUnit(unit) => {
|
||||
read_host_journal_response(Some(unit), lines).await
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Defense-in-depth format check so weird chars never reach the
|
||||
// shellout below — the `lifecycle::list()` existence check would
|
||||
// catch them anyway, but rejecting at the boundary keeps the
|
||||
// failure mode crisp.
|
||||
let name = match Ident::parse(&name) {
|
||||
Ok(n) => n,
|
||||
Err(reason) => {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("bad agent name: {reason}")));
|
||||
}
|
||||
};
|
||||
// Validate the container name against the list of managed
|
||||
// containers so we don't shell out with arbitrary input.
|
||||
let container = strip_container_prefix(name.as_str());
|
||||
let prefixed = format!("{}{container}", lifecycle::AGENT_PREFIX);
|
||||
let live = match lifecycle::list().await {
|
||||
Ok(live) => live,
|
||||
Err(e) => {
|
||||
return Err(error_problem(&format!(
|
||||
"journal: listing containers: {e:#}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if !live.iter().any(|c| c == &prefixed) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::NOT_FOUND)
|
||||
.with_detail(format!("journal: no managed container {prefixed:?}")));
|
||||
}
|
||||
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(u) => {
|
||||
// accept any of the per-container hive daemons [.service] —
|
||||
// anything else refused.
|
||||
let allowed = [
|
||||
"hive-agent.service",
|
||||
"hive-mcp-http.service",
|
||||
"hive-bash-daemon.service",
|
||||
"hive-subagent-daemon.service",
|
||||
"hive-matrix-daemon.service",
|
||||
];
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("journal: unknown unit {unit:?}")));
|
||||
}
|
||||
Some(unit)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
read_journal_response(&prefixed, unit, lines).await
|
||||
}
|
||||
|
||||
/// Shared `journalctl -M <machine> [-u <unit>]` shellout + response
|
||||
/// formatting for [`get_journal`], factored out so the infra-container
|
||||
/// branch (no `unit` filtering) and the agent-container branch (allow-listed
|
||||
/// `unit` filtering) don't duplicate the priv-client call + stdout/stderr
|
||||
/// combining.
|
||||
async fn read_journal_response(
|
||||
machine: &str,
|
||||
unit: Option<String>,
|
||||
lines: u32,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
match crate::priv_client::read_container_journal(
|
||||
machine,
|
||||
hive_priv_sock::JournalQuery {
|
||||
lines,
|
||||
boot: true,
|
||||
output: hive_priv_sock::JournalOutput::ShortIso,
|
||||
unit,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((stdout, stderr)) => {
|
||||
// Combine stdout + stderr — journalctl emits to both on errors.
|
||||
let mut body = stdout;
|
||||
if !stderr.is_empty() {
|
||||
body.push_str("\n--- stderr ---\n");
|
||||
body.push_str(&stderr);
|
||||
}
|
||||
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("journal read: {e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct JournalHostQuery {
|
||||
/// Service unit name to filter to. If omitted, returns all logs.
|
||||
#[serde(default)]
|
||||
unit: Option<String>,
|
||||
/// Number of trailing lines. Capped at 5000. Default 500.
|
||||
#[serde(default)]
|
||||
lines: Option<u32>,
|
||||
}
|
||||
|
||||
/// Host-side journald (no
|
||||
/// `-M` container flag).
|
||||
///
|
||||
/// Restricted to an allow-list of known host services so arbitrary unit
|
||||
/// names can't be probed. Operator-only by virtue of the dashboard binding
|
||||
/// to a host-only port.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/journal-host",
|
||||
params(JournalHostQuery),
|
||||
responses(
|
||||
(status = 200, description = "journal text", body = String, content_type = "text/plain"),
|
||||
(status = 400, description = "unknown unit"),
|
||||
),
|
||||
tag = "journal"
|
||||
)]
|
||||
pub(super) async fn get_journal_host(
|
||||
axum::extract::Query(q): axum::extract::Query<JournalHostQuery>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let lines = q.lines.unwrap_or(500).min(5000);
|
||||
// `nginx.service` is the gateway — its logs are host-side.
|
||||
let allowed = ["hive-c0re.service", "hive-priv.service", "nginx.service"];
|
||||
let unit = match q.unit.as_deref().filter(|s| !s.is_empty()) {
|
||||
Some(u) => {
|
||||
let unit = if u.ends_with(".service") {
|
||||
u.to_owned()
|
||||
} else {
|
||||
format!("{u}.service")
|
||||
};
|
||||
if !allowed.contains(&unit.as_str()) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("journal-host: unknown unit {unit:?}")));
|
||||
}
|
||||
Some(unit)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
read_host_journal_response(unit.as_deref(), lines).await
|
||||
}
|
||||
|
||||
/// `journalctl [-u <unit>]` on the host + response formatting. No `-M`, so
|
||||
/// no root and no priv-client hop — hive-c0re reads host journald directly.
|
||||
///
|
||||
/// ⚠️ `unit` is trusted by the time it gets here: [`get_journal_host`]
|
||||
/// allow-lists an operator-supplied one, and [`get_journal`] passes a unit
|
||||
/// that came from the [`hive_priv_sock::InfraContainer`] enum. Don't hand
|
||||
/// this a raw query parameter.
|
||||
async fn read_host_journal_response(
|
||||
unit: Option<&str>,
|
||||
lines: u32,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let mut cmd = tokio::process::Command::new("journalctl");
|
||||
cmd.args(["--no-pager", "--output=short-iso", "--lines"])
|
||||
.arg(lines.to_string());
|
||||
if let Some(u) = unit {
|
||||
cmd.args(["-u", u]);
|
||||
}
|
||||
match cmd.output().await {
|
||||
Ok(out) => {
|
||||
let mut body = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
if !out.status.success() {
|
||||
body.push_str("\n--- stderr ---\n");
|
||||
body.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
Ok(([("content-type", "text/plain; charset=utf-8")], body).into_response())
|
||||
}
|
||||
Err(e) => Err(error_problem(&format!("journalctl spawn: {e}"))),
|
||||
}
|
||||
}
|
||||
|
|
@ -33,7 +33,6 @@ use crate::lifecycle;
|
|||
),
|
||||
tags(
|
||||
(name = "health", description = "hive-wide liveness/readiness probes"),
|
||||
(name = "journal", description = "container + host journal reads"),
|
||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
|
|
@ -54,7 +53,6 @@ mod build_logs;
|
|||
// server reach it as `crate::dashboard::Ident`.
|
||||
pub(crate) use hive_types::Ident;
|
||||
mod health;
|
||||
mod journal;
|
||||
mod lifecycle_ops;
|
||||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
|
|
@ -115,8 +113,6 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
|||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(journal::get_journal))
|
||||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
|
|
@ -331,8 +327,6 @@ mod router_build_probe {
|
|||
let _ = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
.routes(routes!(health::get_health_live))
|
||||
.routes(routes!(health::get_health_ready))
|
||||
.routes(routes!(journal::get_journal))
|
||||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
|
|
|
|||
|
|
@ -8,9 +8,8 @@
|
|||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use hive_priv_sock::{
|
||||
BindMount, CredentialMount, InfraAction, InfraContainer, JournalQuery,
|
||||
MEASURE_AGENT_DISK_TIMEOUT, NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
|
||||
PrivStream,
|
||||
BindMount, CredentialMount, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
|
||||
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||
};
|
||||
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
|
||||
|
||||
|
|
@ -284,22 +283,6 @@ pub async fn list_containers() -> Result<String> {
|
|||
Ok(stdout)
|
||||
}
|
||||
|
||||
/// Read a container's journal via the root helper (`journalctl -M`).
|
||||
/// Returns `(stdout, stderr)`; a non-zero journalctl exit is reported in
|
||||
/// `stderr` rather than as an `Err`, so callers can surface either.
|
||||
pub async fn read_container_journal(
|
||||
container: &str,
|
||||
query: JournalQuery,
|
||||
) -> Result<(String, String)> {
|
||||
check(
|
||||
call(&PrivRequest::ReadContainerJournal {
|
||||
container: container.to_owned(),
|
||||
query,
|
||||
})
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn write_nspawn_flags(
|
||||
container: &str,
|
||||
binds: &[BindMount],
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ pub const AGENT_PREFIX: &str = "h-";
|
|||
|
||||
/// Sibling service containers managed by hive-c0re. This doubles as the
|
||||
/// authoritative allowlist for the requests that name a container as a
|
||||
/// string (bind-mount edits, journal reads): only these — or a valid agent
|
||||
/// string (bind-mount edits): only these — or a valid agent
|
||||
/// name — are accepted. `hive-c0re` is deliberately absent; so is the
|
||||
/// gateway, whose nginx is a plain host unit rather than a container.
|
||||
/// hive-priv re-validates against this list root-side, so it's authoritative
|
||||
|
|
@ -44,7 +44,7 @@ pub const AGENT_PREFIX: &str = "h-";
|
|||
/// [`InfraContainer`] is what gates
|
||||
/// [`PrivRequest::ControlInfraContainer`], and it has one variant more than
|
||||
/// this list (the gateway). Keep the distinction — a name that belongs to
|
||||
/// no container has no business reaching a `-M` / `nixos-container` call.
|
||||
/// no container has no business reaching a `nixos-container` call.
|
||||
pub const SIBLING_CONTAINERS: &[&str] = &["hive-forge", "hive-matrix"];
|
||||
|
||||
/// Lifecycle verb for [`PrivRequest::ControlInfraContainer`]. Maps directly
|
||||
|
|
@ -95,11 +95,10 @@ pub enum InfraContainer {
|
|||
/// other variant is a container.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum InfraTarget {
|
||||
/// An nspawn container, controlled via `container@<name>.service` and
|
||||
/// readable with `journalctl -M <name>`.
|
||||
/// An nspawn container, controlled via `container@<name>.service`.
|
||||
Container(&'static str),
|
||||
/// A plain host unit. There is no machine to enter: no `-M` journal,
|
||||
/// no `nixos-container` verb.
|
||||
/// A plain host unit. There is no machine to enter: no
|
||||
/// `nixos-container` verb.
|
||||
HostUnit(&'static str),
|
||||
}
|
||||
|
||||
|
|
@ -187,64 +186,6 @@ pub const AGENT_RUNTIME_ROOT: &str = "/run/hyperhive/agents";
|
|||
/// layout cares about. Root-owned; hive-priv creates it on first use.
|
||||
pub const MIGRATE_STAGING_ROOT: &str = "/var/lib/hyperhive/migrate-staging";
|
||||
|
||||
/// Output format for `ReadContainerJournal`. Maps to journalctl
|
||||
/// `--output=<...>`. Restricted to the two formats hive callers use so
|
||||
/// the wire type can't smuggle an arbitrary `--output` value.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum JournalOutput {
|
||||
/// `short` — the journalctl default (syslog-style timestamps).
|
||||
#[default]
|
||||
Short,
|
||||
/// `short-iso` — ISO 8601 timestamps.
|
||||
ShortIso,
|
||||
}
|
||||
|
||||
impl JournalOutput {
|
||||
/// The string journalctl expects after `--output=`.
|
||||
#[must_use]
|
||||
pub fn as_journalctl(self) -> &'static str {
|
||||
match self {
|
||||
JournalOutput::Short => "short",
|
||||
JournalOutput::ShortIso => "short-iso",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// journalctl knobs for `ReadContainerJournal`. Grouped into one value so
|
||||
/// the read-journal call chain (`priv_client::read_container_journal` →
|
||||
/// hive-priv's executor) and its several hive-c0re callers pass a single
|
||||
/// struct instead of eight positional args that travelled together 1:1.
|
||||
/// `Default` is the common case (last N lines, short format, no filters);
|
||||
/// callers fill `lines` and override only the knobs they need via
|
||||
/// `..Default::default()`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct JournalQuery {
|
||||
/// `-n <lines>`.
|
||||
pub lines: u32,
|
||||
/// `-b` — restrict to the current boot.
|
||||
#[serde(default)]
|
||||
pub boot: bool,
|
||||
/// `--output=<...>`.
|
||||
#[serde(default)]
|
||||
pub output: JournalOutput,
|
||||
/// `-u <unit>`.
|
||||
#[serde(default)]
|
||||
pub unit: Option<String>,
|
||||
/// `-p <priority>`.
|
||||
#[serde(default)]
|
||||
pub priority: Option<String>,
|
||||
/// `--grep=<regex>`.
|
||||
#[serde(default)]
|
||||
pub grep: Option<String>,
|
||||
/// `--since=<ts>`.
|
||||
#[serde(default)]
|
||||
pub since: Option<String>,
|
||||
/// `--until=<ts>`.
|
||||
#[serde(default)]
|
||||
pub until: Option<String>,
|
||||
}
|
||||
|
||||
/// One bind-mount entry for `WriteNspawnFlags`.
|
||||
/// hive-priv constructs `--bind=<host_path>:<container_path>` (or `--bind-ro=`)
|
||||
/// and validates both paths before writing the conf file.
|
||||
|
|
@ -342,24 +283,6 @@ pub enum PrivRequest {
|
|||
/// `nixos-container list`
|
||||
ListContainers,
|
||||
|
||||
// --- Container journal reads ---
|
||||
/// Read a container's journal via `journalctl -M <container>`.
|
||||
/// Requires root: the machine-bus transport enters the container's
|
||||
/// namespace, so this can't run from the unprivileged hive-c0re
|
||||
/// process. hive-priv validates `container` against the managed-
|
||||
/// container allowlist, then runs journalctl and returns its output.
|
||||
///
|
||||
/// The filters (`unit` / `priority` / `grep` / `since` / `until`)
|
||||
/// are applied within the already-authorized machine and passed to
|
||||
/// journalctl as plain argument values; they can't widen access
|
||||
/// beyond the validated `container`.
|
||||
ReadContainerJournal {
|
||||
/// System container name (`h-<agent>` or a sibling service).
|
||||
container: String,
|
||||
/// journalctl knobs (see [`JournalQuery`]).
|
||||
query: JournalQuery,
|
||||
},
|
||||
|
||||
// --- Config file writes ---
|
||||
/// Update `/etc/nixos-containers/<container>.conf`: strip old network-isolation
|
||||
/// vars, write `PRIVATE_NETWORK` + bridge settings, and set `EXTRA_NSPAWN_FLAGS`
|
||||
|
|
@ -777,9 +700,8 @@ mod tests {
|
|||
fn infra_container_enum_matches_sibling_containers() {
|
||||
// The two lists are the same set *minus the things that aren't
|
||||
// containers*: every `Container` variant must appear in
|
||||
// SIBLING_CONTAINERS and vice versa, so a `-M` journal read or a
|
||||
// `nixos-container` verb can never be pointed at a name that has no
|
||||
// machine behind it.
|
||||
// SIBLING_CONTAINERS and vice versa, so a `nixos-container` verb can
|
||||
// never be pointed at a name that has no machine behind it.
|
||||
let mut from_enum: Vec<&str> = InfraContainer::ALL
|
||||
.iter()
|
||||
.filter_map(|c| match c.target() {
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ use std::path::{Path, PathBuf};
|
|||
use anyhow::{Context as _, Result, anyhow, bail};
|
||||
use hive_priv_sock::{
|
||||
AGENT_PREFIX, AGENT_RUNTIME_ROOT, AGENT_STATE_ROOT, BindMount, CredentialMount, InfraAction,
|
||||
InfraContainer, JournalQuery, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT,
|
||||
NetworkIsolation, PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse,
|
||||
PrivStream, PrivStreamLine, SIBLING_CONTAINERS,
|
||||
InfraContainer, MEASURE_AGENT_DISK_TIMEOUT, META_DIR, MIGRATE_STAGING_ROOT, NetworkIsolation,
|
||||
PAUSED_MARKER_FILE, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
|
||||
PrivStreamLine, SIBLING_CONTAINERS,
|
||||
};
|
||||
use tokio::io::{AsyncWriteExt, BufReader};
|
||||
use tokio::net::unix::OwnedWriteHalf;
|
||||
|
|
@ -372,11 +372,6 @@ async fn exec(
|
|||
|
||||
PrivRequest::ListContainers => container_run(&["list"]).await,
|
||||
|
||||
PrivRequest::ReadContainerJournal {
|
||||
ref container,
|
||||
ref query,
|
||||
} => exec_read_container_journal(container, query).await,
|
||||
|
||||
PrivRequest::WriteNspawnFlags {
|
||||
ref container,
|
||||
ref binds,
|
||||
|
|
@ -535,15 +530,6 @@ async fn destroy_container(name: &str) -> Result<(String, String)> {
|
|||
container_run(&["destroy", &container_system_name(name)]).await
|
||||
}
|
||||
|
||||
/// `ReadContainerJournal`.
|
||||
async fn exec_read_container_journal(
|
||||
container: &str,
|
||||
query: &JournalQuery,
|
||||
) -> Result<(String, String)> {
|
||||
validate_container_system_name(container)?;
|
||||
read_container_journal(container, query).await
|
||||
}
|
||||
|
||||
/// `SetAgentPaused`.
|
||||
fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
|
|
@ -2695,60 +2681,6 @@ async fn container_run_streaming(
|
|||
Ok((String::new(), String::new()))
|
||||
}
|
||||
|
||||
/// Read a container's journal as root via `journalctl -M`. Returns
|
||||
/// `(stdout, stderr)`. Unlike `container_run` a non-zero exit is *not* a
|
||||
/// hard error — journalctl's own diagnostic (folded into `stderr` with
|
||||
/// the exit status) is what the caller surfaces to the operator, so the
|
||||
/// helper never bails.
|
||||
async fn read_container_journal(container: &str, query: &JournalQuery) -> Result<(String, String)> {
|
||||
let mut args: Vec<String> = vec![
|
||||
"-M".to_owned(),
|
||||
container.to_owned(),
|
||||
"--no-pager".to_owned(),
|
||||
format!("--output={}", query.output.as_journalctl()),
|
||||
"-n".to_owned(),
|
||||
query.lines.to_string(),
|
||||
];
|
||||
if query.boot {
|
||||
args.push("-b".to_owned());
|
||||
}
|
||||
if let Some(u) = &query.unit {
|
||||
args.push("-u".to_owned());
|
||||
args.push(u.clone());
|
||||
}
|
||||
if let Some(p) = &query.priority {
|
||||
args.push("-p".to_owned());
|
||||
args.push(p.clone());
|
||||
}
|
||||
// `--grep=`/`--since=`/`--until=` use the `=`-joined form so a value
|
||||
// can never be parsed as a separate journalctl flag.
|
||||
if let Some(g) = &query.grep {
|
||||
args.push(format!("--grep={g}"));
|
||||
}
|
||||
if let Some(s) = &query.since {
|
||||
args.push(format!("--since={s}"));
|
||||
}
|
||||
if let Some(u) = &query.until {
|
||||
args.push(format!("--until={u}"));
|
||||
}
|
||||
let out = Command::new("journalctl")
|
||||
.args(&args)
|
||||
.output()
|
||||
.await
|
||||
.context("invoke journalctl -M")?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let stderr = if out.status.success() {
|
||||
String::from_utf8_lossy(&out.stderr).into_owned()
|
||||
} else {
|
||||
format!(
|
||||
"journalctl -M {container} exited {}: {}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
)
|
||||
};
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
/// Synchronise the host's nginx unit after an `agents.conf` write.
|
||||
///
|
||||
/// Queries `ActiveState` and dispatches:
|
||||
|
|
|
|||
|
|
@ -303,7 +303,6 @@ in
|
|||
RestartSec = 2;
|
||||
User = "hive-core";
|
||||
Group = "hive-core";
|
||||
SupplementaryGroups = [ "systemd-journal" ];
|
||||
RuntimeDirectory = "hyperhive";
|
||||
RuntimeDirectoryMode = runtimeDirMode;
|
||||
RuntimeDirectoryPreserve = "yes";
|
||||
|
|
@ -364,7 +363,7 @@ in
|
|||
ProtectSystem = "strict";
|
||||
ReadWritePaths = [ "/var/lib/hive-gateway/conf" ];
|
||||
ProtectKernelTunables = true; # no sysctl writes
|
||||
ProtectKernelLogs = true; # reads logs via systemd-journal group, not /dev/kmsg
|
||||
ProtectKernelLogs = true; # no /dev/kmsg access needed
|
||||
ProtectControlGroups = true; # cgroup writes go through hive-priv, not c0re directly
|
||||
RestrictNamespaces = true; # namespace creation goes through hive-priv
|
||||
LockPersonality = true; # no personality changes needed
|
||||
|
|
|
|||
|
|
@ -16,10 +16,10 @@
|
|||
# $out/dashboard/ the hive-c0re dashboard assets (full layout in
|
||||
# frontend/packages/dashboard/build.mjs):
|
||||
# index.html (H0M3 hub, served at /) dashboard.html (operator SPA,
|
||||
# served at /dashboard.html) flow.html logs.html
|
||||
# served at /dashboard.html) flow.html
|
||||
# stats.html favicon.svg
|
||||
# static/{home,tabs,flow,logs,stats,stream-worker}.js{,.map}
|
||||
# static/{colors,theme,common,home,dashboard,flow,logs,stats}.css
|
||||
# static/{home,tabs,flow,stats,stream-worker}.js{,.map}
|
||||
# static/{colors,theme,common,home,dashboard,flow,stats}.css
|
||||
# $out/agent/ the per-agent default UI (layered with
|
||||
# hyperhive.frontend.extraFiles at activation time)
|
||||
# index.html stats.html screen.html
|
||||
|
|
|
|||
Loading…
Reference in a new issue