swarm-ui: real hive-roster overview page
Fixes hyperhive#3223. swarm-controller: GET /api/hives (utoipa-annotated same as /health), serving the swarm's hive directory (name + domain) loaded once at startup from a new SWARM_CONTROLLER_HIVES env var. The controller's NixOS module sets it from services.hyperhive.swarm.hives, JSON-encoded the same way hive-c0re already builds HYPERHIVE_PEERS for its own peer list (environment.nix) — the full directory here rather than peers-minus-self, since a swarm-level daemon has no 'self' hive to exclude. Unset/malformed both fall back to an empty list with a warning rather than failing startup, so /health stays answerable even if this one env var is wrong. swarm-ui: App.tsx's Home route fetches /api/hives and renders it through the already-merged <Table>/<StatusChip>/<Panel> primitives — name, domain (linking out to that hive's own gateway-routed dashboard), and a static "configured" status chip until a real online/stale/offline rollup exists server-side. Also gave swarm-ui a base <a> color (theme's --blue) — base.css covers body/typography but not links, and this is genuinely page-level rather than any one component's concern. Verified end to end, not just source-reading: ran the real swarm-controller binary with SWARM_CONTROLLER_HIVES set, curled /api/hives + /health over its actual unix socket; separately served the real swarm-ui dist against a mock /api/hives and screenshotted the rendered table. Also re-verified the nginx wiring evaluates (same throwaway nixosSystem eval technique as #3212) — SWARM_CONTROLLER_HIVES resolves to the expected JSON shape. cargo test/clippy -p swarm-controller clean (2 tests, including a new load_hives one covering missing/malformed/valid env var states). npm run build + typecheck clean.
This commit is contained in:
parent
5300c242f3
commit
e7f4a19939
6 changed files with 208 additions and 22 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4427,6 +4427,8 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,57 @@
|
||||||
// Root shell component. Real route (`/`) is still a placeholder — the
|
// Root shell component. `/` is now the real hive-roster overview page —
|
||||||
// swarm's hive roster is the next piece of work to land into it — but
|
// fetches swarm-controller's `GET /api/hives` and renders it through the
|
||||||
// it now mounts inside <Shell> and uses the ui/ primitives, so that
|
// shared primitives. Status starts as a static "configured" chip; grows
|
||||||
// page lands as a content change, not a structural one.
|
// into a real online/stale/offline tone once a status rollup exists
|
||||||
|
// server-side — same component, richer data later, no rebuild.
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Route, Switch } from 'wouter-preact';
|
import { Route, Switch } from 'wouter-preact';
|
||||||
import { Shell } from './shell/Shell.js';
|
import { Shell } from './shell/Shell.js';
|
||||||
import { Panel } from './ui/panel/Panel.js';
|
import { Panel } from './ui/panel/Panel.js';
|
||||||
|
import { StatusChip } from './ui/status-chip/StatusChip.js';
|
||||||
|
import { Table, type TableColumn } from './ui/table/Table.js';
|
||||||
|
|
||||||
|
interface Hive {
|
||||||
|
name: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLUMNS: TableColumn<Hive>[] = [
|
||||||
|
{ key: 'name', header: 'name', render: (h) => h.name },
|
||||||
|
{
|
||||||
|
key: 'domain',
|
||||||
|
header: 'domain',
|
||||||
|
render: (h) => (
|
||||||
|
<a href={`https://${h.domain}/`} target="_blank" rel="noreferrer">
|
||||||
|
{h.domain}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
// Static "configured" tone until a swarm-wide status rollup exists —
|
||||||
|
// there is no data source for online/stale/offline yet, and a chip
|
||||||
|
// that always renders "positive" would misreport a genuinely offline
|
||||||
|
// hive. See StatusChip's own doc comment for the same reasoning.
|
||||||
|
{ key: 'status', header: 'status', render: () => <StatusChip label="configured" /> },
|
||||||
|
];
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
|
const [hives, setHives] = useState<Hive[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/api/hives')
|
||||||
|
.then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`http ${r.status}`);
|
||||||
|
return r.json() as Promise<Hive[]>;
|
||||||
|
})
|
||||||
|
.then(setHives)
|
||||||
|
.catch((e: unknown) => setError(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title="overview">
|
<Panel title="overview">
|
||||||
<p>hive roster lands here, sequenced after this shell.</p>
|
{error ? <p>failed to load the hive roster: {error}</p> : null}
|
||||||
|
{!error && hives === null ? <p>loading…</p> : null}
|
||||||
|
{hives ? <Table columns={COLUMNS} rows={hives} rowKey={(h) => h.name} /> : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,10 @@
|
||||||
every page needs regardless of which components a route happens to
|
every page needs regardless of which components a route happens to
|
||||||
use. */
|
use. */
|
||||||
@import "@hive/shared/base.css";
|
@import "@hive/shared/base.css";
|
||||||
|
|
||||||
|
/* base.css covers body/typography but not links — every page here links
|
||||||
|
out (hive-roster rows to each hive's own dashboard, nav, footer…), so
|
||||||
|
this is genuinely page-level, not any one component's concern. */
|
||||||
|
a {
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,18 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
environment.SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
environment.SWARM_CONTROLLER_SOCKET = cfg.socketPath;
|
||||||
|
# The swarm's hive directory, JSON-encoded — same shape hive-c0re
|
||||||
|
# already builds for HYPERHIVE_PEERS (../hive-c0re/environment.nix),
|
||||||
|
# just the full directory (this daemon has no "self" hive to
|
||||||
|
# exclude, unlike a per-hive c0re's peer list) rather than
|
||||||
|
# peers-minus-self. Consumed by `GET /api/hives`
|
||||||
|
# (swarm-controller/src/main.rs::load_hives).
|
||||||
|
environment.SWARM_CONTROLLER_HIVES = builtins.toJSON (
|
||||||
|
lib.mapAttrsToList (name: h: {
|
||||||
|
inherit name;
|
||||||
|
inherit (h) domain;
|
||||||
|
}) config.services.hyperhive.swarm.hives
|
||||||
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ path = "src/main.rs"
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
axum.workspace = true
|
axum.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
//! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a
|
//! `services.hyperhive.swarm.controller.enable` on, and serves HTTP over a
|
||||||
//! unix socket that the hive-gateway's nginx proxies to.
|
//! unix socket that the hive-gateway's nginx proxies to.
|
||||||
//!
|
//!
|
||||||
//! **Today it serves one endpoint and owns no state.** That is deliberate:
|
//! Holds one piece of read-only state: the swarm's hive directory, loaded
|
||||||
//! this slice exists to make the *unit* real — service user, runtime and
|
//! once at startup from an env var the NixOS module sets
|
||||||
//! state directories, socket, nginx reachability — so the swarm-level
|
//! (`services.hyperhive.swarm.controller`) — see [`load_hives`]. Still no
|
||||||
//! surfaces that follow have somewhere to land. Guessing those surfaces
|
//! persistence and no writes; a config change means a redeploy, same as
|
||||||
//! now would bake in a shape nobody has agreed to.
|
//! every other option this process reads.
|
||||||
//!
|
//!
|
||||||
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
|
//! Distinct from `hive-c0re`, which is per-hive: c0re owns the agents on
|
||||||
//! one host, this owns what is true across hives.
|
//! one host, this owns what is true across hives.
|
||||||
|
|
@ -23,10 +23,12 @@
|
||||||
|
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use axum::{Json, routing::get};
|
use axum::{Json, extract::State, routing::get};
|
||||||
use utoipa::OpenApi;
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::{OpenApi, ToSchema};
|
||||||
use utoipa_axum::{router::OpenApiRouter, routes};
|
use utoipa_axum::{router::OpenApiRouter, routes};
|
||||||
|
|
||||||
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
|
||||||
|
|
@ -50,9 +52,9 @@ fn socket_path() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Root of the auto-generated `OpenAPI` spec, served raw at
|
/// Root of the auto-generated `OpenAPI` spec, served raw at
|
||||||
/// `/api/openapi.json` — see the module doc comment above. One tag today
|
/// `/api/openapi.json` — see the module doc comment above. Tag list
|
||||||
/// (`health`); grows alongside the swarm-level surfaces this daemon picks
|
/// grows alongside the swarm-level surfaces this daemon picks up, same
|
||||||
/// up, same as `hive-c0re::dashboard::ApiDoc`'s tag list did.
|
/// as `hive-c0re::dashboard::ApiDoc`'s tag list did.
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
info(
|
info(
|
||||||
|
|
@ -60,7 +62,10 @@ fn socket_path() -> PathBuf {
|
||||||
description = "swarm-controller's HTTP surface, served over its unix \
|
description = "swarm-controller's HTTP surface, served over its unix \
|
||||||
socket behind the gateway's swarm-UI vhost."
|
socket behind the gateway's swarm-UI vhost."
|
||||||
),
|
),
|
||||||
tags((name = "health", description = "liveness probe"))
|
tags(
|
||||||
|
(name = "health", description = "liveness probe"),
|
||||||
|
(name = "hives", description = "the swarm's hive directory"),
|
||||||
|
)
|
||||||
)]
|
)]
|
||||||
struct ApiDoc;
|
struct ApiDoc;
|
||||||
|
|
||||||
|
|
@ -76,6 +81,61 @@ async fn health() -> &'static str {
|
||||||
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
|
concat!("swarm-controller ", env!("CARGO_PKG_VERSION"), "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One hive in the swarm's directory — the same `name`/`domain` pair
|
||||||
|
/// `services.hyperhive.swarm.hives` (nix/host-modules/swarm.nix) declares,
|
||||||
|
/// carried across unchanged rather than reshaped, so this stays a direct
|
||||||
|
/// mirror of the nix source of truth instead of a second vocabulary for
|
||||||
|
/// the same two fields.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)]
|
||||||
|
struct HiveEntry {
|
||||||
|
name: String,
|
||||||
|
domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
/// Loaded once at startup ([`load_hives`]); never mutated, so an
|
||||||
|
/// `Arc` clone per request is the whole synchronization story.
|
||||||
|
hives: Arc<Vec<HiveEntry>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Env var the controller's NixOS module sets from
|
||||||
|
/// `services.hyperhive.swarm.hives`, JSON-encoded — same shape hive-c0re
|
||||||
|
/// already builds for `HYPERHIVE_PEERS`
|
||||||
|
/// (nix/host-modules/hive-c0re/environment.nix), just the full directory
|
||||||
|
/// (this daemon has no "self" to exclude) rather than peers-minus-self.
|
||||||
|
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
|
||||||
|
|
||||||
|
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or
|
||||||
|
/// unparseable both fall back to an empty list with a warning rather than
|
||||||
|
/// failing startup — a swarm-controller that can't yet see its own config
|
||||||
|
/// (dev run, a module not wired up yet) should still serve `/health`.
|
||||||
|
fn load_hives() -> Vec<HiveEntry> {
|
||||||
|
let Some(raw) = std::env::var_os(HIVES_ENV) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
match serde_json::from_str(&raw.to_string_lossy()) {
|
||||||
|
Ok(hives) => hives,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, env = HIVES_ENV, "failed to parse hive directory, serving an empty list");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swarm's hive directory — every hive, including whichever one this
|
||||||
|
/// controller instance happens to run on (there is no "self" to exclude
|
||||||
|
/// at the swarm level, unlike `hive-c0re`'s peer list).
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/api/hives",
|
||||||
|
responses((status = 200, description = "every hive in the swarm", body = Vec<HiveEntry>)),
|
||||||
|
tag = "hives"
|
||||||
|
)]
|
||||||
|
async fn get_hives(State(state): State<AppState>) -> Json<Vec<HiveEntry>> {
|
||||||
|
Json((*state.hives).clone())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
|
|
@ -114,17 +174,24 @@ async fn main() -> Result<()> {
|
||||||
.with_context(|| format!("chmod {}", path.display()))?;
|
.with_context(|| format!("chmod {}", path.display()))?;
|
||||||
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
tracing::info!(socket = %path.display(), "swarm-controller listening");
|
||||||
|
|
||||||
let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi())
|
let state = AppState {
|
||||||
|
hives: Arc::new(load_hives()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||||
.routes(routes!(health))
|
.routes(routes!(health))
|
||||||
|
.routes(routes!(get_hives))
|
||||||
.split_for_parts();
|
.split_for_parts();
|
||||||
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
// Just the JSON, not the UI — Swagger UI itself is nginx-hosted from
|
||||||
// the nix store (see the module doc comment above). `api` is
|
// the nix store (see the module doc comment above). `api` is
|
||||||
// `Clone`; each request gets its own owned copy for `Json` to
|
// `Clone`; each request gets its own owned copy for `Json` to
|
||||||
// serialize, same as `hive-c0re::dashboard::serve`.
|
// serialize, same as `hive-c0re::dashboard::serve`.
|
||||||
let app = router.route(
|
let app = router
|
||||||
"/api/openapi.json",
|
.route(
|
||||||
get(move || async move { Json(api.clone()) }),
|
"/api/openapi.json",
|
||||||
);
|
get(move || async move { Json(api.clone()) }),
|
||||||
|
)
|
||||||
|
.with_state(state);
|
||||||
axum::serve(listener, app)
|
axum::serve(listener, app)
|
||||||
.await
|
.await
|
||||||
.context("serving swarm-controller")
|
.context("serving swarm-controller")
|
||||||
|
|
@ -132,7 +199,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::DEFAULT_SOCKET;
|
use super::{DEFAULT_SOCKET, HIVES_ENV, HiveEntry, load_hives};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// The socket must not share a directory with anything else, because
|
/// The socket must not share a directory with anything else, because
|
||||||
|
|
@ -156,4 +223,58 @@ mod tests {
|
||||||
exposes everything else in that directory to the gateway's nginx"
|
exposes everything else in that directory to the gateway's nginx"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One test, not three, deliberately: `HIVES_ENV` is a real process
|
||||||
|
/// env var, and `cargo test`'s default parallel runner would race
|
||||||
|
/// separate missing/malformed/valid tests against each other. Driving
|
||||||
|
/// all three states sequentially inside one test needs no mutex and
|
||||||
|
/// still proves each branch of `load_hives`.
|
||||||
|
///
|
||||||
|
/// SAFETY: single-threaded mutation of a process env var no other
|
||||||
|
/// test in this crate reads; restored (removed) before returning.
|
||||||
|
#[test]
|
||||||
|
fn load_hives_covers_missing_malformed_and_valid() {
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(HIVES_ENV);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
load_hives(),
|
||||||
|
Vec::<HiveEntry>::new(),
|
||||||
|
"unset env var is an empty directory, not a startup failure"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(HIVES_ENV, "not json");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
load_hives(),
|
||||||
|
Vec::<HiveEntry>::new(),
|
||||||
|
"unparseable env var falls back to empty rather than panicking — \
|
||||||
|
/health must still answer even if this daemon's own config is wrong"
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var(
|
||||||
|
HIVES_ENV,
|
||||||
|
r#"[{"name":"pr1ma","domain":"pr1ma.example.com"},{"name":"umbra","domain":"umbra.example.com"}]"#,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
load_hives(),
|
||||||
|
vec![
|
||||||
|
HiveEntry {
|
||||||
|
name: "pr1ma".to_string(),
|
||||||
|
domain: "pr1ma.example.com".to_string(),
|
||||||
|
},
|
||||||
|
HiveEntry {
|
||||||
|
name: "umbra".to_string(),
|
||||||
|
domain: "umbra.example.com".to_string(),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
std::env::remove_var(HIVES_ENV);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue