frontend: cut over Rust binaries to ServeDir; delete legacy assets

Phase 4 of #273 — the actual switch. Both axum routers now serve
their static surface via `tower_http::services::ServeDir` mounted
as a fallback service, reading the dist path from `HIVE_STATIC_DIR`
(set by Phase 3's NixOS module wiring).

Deletes:
- `hive-c0re/assets/{index.html, app.js, dashboard.css}`
- `hive-ag3nt/assets/{index.html, app.js, agent.css, stats.html,
   stats.js, screen.html}`
- The whole `hive-fr0nt/` crate (workspace member dropped, both
  hive-c0re and hive-ag3nt drop their `hive-fr0nt.workspace = true`
  dep). Its contents now live as `@hive/shared` under
  `frontend/packages/shared/`.

Rust changes:
- `hive-c0re/src/dashboard.rs`: remove `serve_index`, `serve_css`,
  `serve_app_js`, `serve_shared_js`, `serve_marked_js`,
  `serve_favicon` (all six `include_str!` handlers); replace their
  routes with a single `.fallback_service(ServeDir::new(static_dir))`
  on the router. Fail closed (anyhow::bail) if `HIVE_STATIC_DIR` is
  unset or not a directory at startup.
- `hive-ag3nt/src/web_ui.rs`: remove `serve_index`, `serve_css`,
  `serve_app_js`, `serve_shared_js`, `serve_marked_js`,
  `serve_stats`, `serve_stats_js`, `serve_screen`; same
  `fallback_service` pattern. `serve_icon` stays (consumes
  `/etc/hyperhive/icon.svg` + `branding/hyperhive.svg` fallback,
  neither of which lives under the frontend dist).
- `AgentLink` URLs for stats/screen switched from `/stats` / `/screen`
  to `/stats.html` / `/screen.html` since ServeDir doesn't auto-
  append the extension and the on-disk filename is the natural URL
  post-cutover.
- `Cargo.toml` (workspace): drop `hive-fr0nt` member + workspace
  dep, add `tower-http = { version = "0.6", features = ["fs"] }`.
- `hive-c0re/Cargo.toml` + `hive-ag3nt/Cargo.toml`: drop the
  `hive-fr0nt.workspace = true` dep, add `tower-http.workspace =
  true`.

Docs updated:
- `CLAUDE.md`: file map reflects `frontend/` (was `hive-fr0nt/` +
  `assets/`) and the ServeDir/HIVE_STATIC_DIR shape.
- `docs/web-ui.md` 'Shape (shared by both)' section: describes the
  ServeDir fallback + bundled-by-esbuild surface, no more
  `include_str!` references.
- `docs/terminal-rendering.md`: src paths point at
  `frontend/packages/{agent,shared}/src/`; marked is the npm dep,
  not vendored UMD.

Validation:
- `cargo check --workspace` — clean (5 warnings, all pre-existing
  in `rebuild_queue.rs`, none on changed files).
- `cargo clippy --workspace --all-targets` — clean (11 warnings,
  same pre-existing source).
- `cd frontend && npm run build` from the prior commit's lockfile
  produces the dist directories the new routers consume:
    dashboard: `dist/{index.html, static/{app.js, dashboard.css}}`
    agent:     `dist/{index.html, stats.html, screen.html,
                       static/{app.js, stats.js, agent.css}}`
  (favicon.svg lands in dashboard/ during the nix build —
  `nix/frontend.nix` install phase copies `branding/hyperhive.svg`
  there, since it's outside the npm tree.)

Refs #273.
This commit is contained in:
iris 2026-05-23 13:21:37 +02:00 committed by Mara
parent 2ecf15bb6f
commit 229c4292e9
24 changed files with 143 additions and 10122 deletions

View file

@ -12,7 +12,6 @@ axum.workspace = true
base64.workspace = true
reqwest.workspace = true
clap.workspace = true
hive-fr0nt.workspace = true
hive-sh4re.workspace = true
libc = "0.2"
rusqlite.workspace = true
@ -20,6 +19,7 @@ serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,116 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body>
<pre class="banner">
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
</pre>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
<!-- swarm: live containers, dormant state, meta input bumps that
affect the whole swarm. -->
<h2>◆ C0NTAINERS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
<h2>◆ K3PT ST4T3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<h2>◆ M3T4 1NPUTS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
<h2>◆ R3BU1LD QU3U3 ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- operator decisions: things waiting on you. -->
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
<h2>◆ QU3U3D R3M1ND3RS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">reminders agents have queued for themselves but not yet delivered. cancel to drop a stuck or unwanted entry.</p>
<div id="reminders-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<!-- messages: broker traffic + the compose box that produces it. -->
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ MESS4GE FL0W ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
<div class="terminal-wrap">
<div id="msgflow" class="live"><div class="meta">connecting…</div></div>
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</div>
<footer>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel. Long content (clicked file previews,
approval diffs, journald logs, applied config) opens here
instead of expanding inline. Singleton — JS swaps the title +
body and toggles `.open`. -->
<div id="side-panel" class="side-panel" aria-hidden="true">
<div class="side-panel-backdrop" id="side-panel-backdrop"></div>
<aside class="side-panel-drawer" role="dialog" aria-modal="true"
aria-labelledby="side-panel-title">
<header class="side-panel-head">
<span class="side-panel-title" id="side-panel-title"></span>
<button type="button" class="side-panel-close" id="side-panel-close"
title="close (esc)">✕</button>
</header>
<div class="side-panel-body" id="side-panel-body"></div>
</aside>
</div>
<script src="/static/hive-fr0nt.js" defer></script>
<script src="/static/marked.js" defer></script>
<script src="/static/app.js" defer></script>
</body>
</html>

View file

@ -4,7 +4,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
@ -14,7 +14,7 @@ use axum::{
extract::{Path as AxumPath, State},
http::{HeaderMap, StatusCode},
response::{
Html, IntoResponse, Response,
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
routing::{get, post},
@ -23,6 +23,7 @@ use hive_sh4re::Approval;
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
use tokio_stream::{Stream, StreamExt};
use tower_http::services::ServeDir;
use crate::actions;
use crate::container_view::{ContainerView, claude_has_session};
@ -37,11 +38,20 @@ struct AppState {
}
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
.map(PathBuf::from)
.context(
"HIVE_STATIC_DIR env var not set — point it at the bundled \
dashboard dist (see services.hive-c0re.frontend in nix)",
)?;
if !static_dir.is_dir() {
anyhow::bail!(
"HIVE_STATIC_DIR ({}) is not a directory",
static_dir.display()
);
}
tracing::info!(static_dir = %static_dir.display(), "dashboard static dir resolved");
let app = Router::new()
.route("/", get(serve_index))
.route("/static/dashboard.css", get(serve_css))
.route("/static/app.js", get(serve_app_js))
.route("/favicon.svg", get(serve_favicon))
.route("/api/state", get(api_state))
.route("/approve/{id}", post(post_approve))
.route("/deny/{id}", post(post_deny))
@ -66,8 +76,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/meta-update", post(post_meta_update))
.route("/dashboard/stream", get(dashboard_stream))
.route("/dashboard/history", get(dashboard_history))
.route("/static/hive-fr0nt.js", get(serve_shared_js))
.route("/static/marked.js", get(serve_marked_js))
// Anything not matched by the dynamic routes above falls
// through to the bundled dashboard dist (GET / →
// dist/index.html, /favicon.svg → dist/favicon.svg,
// /static/dashboard.css → dist/static/dashboard.css, etc.).
.fallback_service(ServeDir::new(&static_dir))
.with_state(AppState { coord });
let addr = SocketAddr::from(([0, 0, 0, 0], port));
let listener = bind_with_retry(addr).await?;
@ -77,11 +90,14 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
}
// ---------------------------------------------------------------------------
// Static asset handlers: the dashboard is an SPA. `GET /` returns the
// (static) shell; `GET /static/*` serves the CSS + JS app; `GET /api/state`
// returns the current snapshot as JSON. The JS app fetches state on load,
// re-fetches after every async-form submit, and listens on
// `/dashboard/stream` for the unified live event channel.
// The dashboard is an SPA. Its HTML shell + bundled JS / CSS / favicon
// live in the directory pointed at by `HIVE_STATIC_DIR` (set by the
// hive-c0re NixOS module to `${frontend}/dashboard`), served by the
// `tower_http::ServeDir` fallback declared in `serve()`. The dynamic
// surface — `/api/state` and the action endpoints — is owned here.
// The JS app fetches state on load, re-fetches after every async-form
// submit, and listens on `/dashboard/stream` for the unified live event
// channel.
// ---------------------------------------------------------------------------
/// `SO_REUSEADDR` bind with retry. Mirrors the per-agent variant in
@ -142,56 +158,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
sock.listen(1024)
}
async fn serve_index() -> impl IntoResponse {
Html(include_str!("../assets/index.html"))
}
async fn serve_css() -> impl IntoResponse {
// Prepend the shared palette/typography so per-page styles only need
// to declare what's actually page-specific. One HTTP request, no
// per-asset cache to invalidate.
let body = format!(
"{}\n{}\n{}",
hive_fr0nt::BASE_CSS,
hive_fr0nt::TERMINAL_CSS,
include_str!("../assets/dashboard.css"),
);
([("content-type", "text/css")], body)
}
async fn serve_app_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
include_str!("../assets/app.js"),
)
}
/// Dashboard favicon — the hyperhive mark. Static: the dashboard
/// represents the whole hive, so it always uses the project logo
/// (per-agent pages serve their own configurable `/icon` instead).
async fn serve_favicon() -> impl IntoResponse {
(
[("content-type", "image/svg+xml")],
include_str!("../../branding/hyperhive.svg"),
)
}
async fn serve_shared_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::TERMINAL_JS,
)
}
/// Vendored `marked` bundle — the side panel renders markdown file
/// previews with it.
async fn serve_marked_js() -> impl IntoResponse {
(
[("content-type", "application/javascript")],
hive_fr0nt::MARKED_JS,
)
}
#[derive(Serialize)]
struct StateSnapshot {
/// Broker seq at the moment this snapshot was assembled. Clients