feat(#1843): static-serve the dashboard via the gateway, hive-c0re API-only

nginx proxied `<hive>/` straight to hive-c0re:7000, and hive-c0re served the
dashboard dist itself via `tower_http::ServeDir` (from `HIVE_STATIC_DIR` baked
into its service env). So a frontend-only change rebuilt the hive-c0re unit and
restarted the core daemon — every operator session dropped its SSE stream for a
pure CSS/JS change.

The gateway nginx now static-serves the dashboard dist directly; hive-c0re's
dashboard router is API-only. The split uses the Accept-header SPA fallback (the
same `map $http_accept` pattern the matrix/agent vhosts already use), so no
backend prefix has to be enumerated: a browser navigation (Accept: text/html)
whose path is not an on-disk asset gets the SPA index.html; everything else
(every /api route, the bare action/mutation routes, the two SSE streams, the
knowledge webhook — all Accept != text/html) falls through `try_files` to the
`@c0re` named location and is reverse-proxied to hive-c0re. A new c0re route
needs no gateway change.

- hive-c0re.nix: expose the themed dist as a new internal read-only option
  `services.hyperhive.c0re.servedFrontend`; drop `HIVE_STATIC_DIR` from the
  service env (the router no longer serves files).
- hive-gateway.nix: read that option in host-module scope (dashboardDist),
  static-serve `dashboard/` with the Accept-header `try_files ... @c0re` split;
  `@c0re` carries `proxy_buffering off` + a 1d read timeout for the SSE streams
  and a duplicated auth_basic block (named locations do not inherit it). The
  dashboard map is unconditional; the matrix map stays gated on the matrix GUI.
- dashboard.rs: drop the ServeDir fallback + the HIVE_STATIC_DIR resolution; the
  router 404s unmatched paths (the gateway only proxies non-static requests).
- hive-c0re/Cargo.toml: drop the now-unused tower-http dependency.
- docs/gateway.md: document the dashboard static split + the `@c0re` fall-through.

The store path is reachable inside the gateway nspawn container (shared
/nix/store), mirroring how HIVE_AGENT_FRONTEND_DIR already exposes the per-agent
UIs. The gateway and c0re changes must land together (atomic cutover) or the
dashboard 404s — this needs a watched gateway + c0re rebuild.
This commit is contained in:
atlas 2026-06-22 00:23:53 +02:00 committed by mara
commit 4db8a8cd3d
6 changed files with 68 additions and 58 deletions

View file

@ -4,7 +4,7 @@
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
@ -23,7 +23,6 @@ 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::container_view::{ContainerView, claude_has_session};
use crate::coordinator::Coordinator;
@ -65,19 +64,8 @@ struct AppState {
obscure the route map for no readability gain"
)]
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");
// API-only: the gateway static-serves the dashboard dist and proxies
// non-static requests here (see hive-gateway.nix). Unmatched paths 404.
let app = Router::new()
.route("/api/state", get(api_state))
.route("/approve/{id}", post(approvals::post_approve))
@ -219,11 +207,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/api/meta-update", post(post_meta_update))
.route("/api/dashboard/stream", get(dashboard_stream))
.route("/api/dashboard/history", get(dashboard_history))
// 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))
// No static fallback — the gateway owns the dist; unmatched paths 404.
.with_state(AppState { coord });
// Binds loopback-only; external access via gateway.
// Rationale: docs/gateway.md::Firewall posture.