drop hive and agent response fields the frontend never reads
This commit is contained in:
parent
1875181d59
commit
a437f9ccd5
3 changed files with 347 additions and 598 deletions
|
|
@ -2,16 +2,11 @@
|
|||
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
response::{IntoResponse, Response},
|
||||
http::StatusCode,
|
||||
};
|
||||
|
||||
use super::AppState;
|
||||
|
||||
#[derive(serde::Serialize, utoipa::ToSchema)]
|
||||
pub(super) struct CancelResultBody {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// Drop still-queued work from the job
|
||||
/// queue.
|
||||
///
|
||||
|
|
@ -23,27 +18,25 @@ pub(super) struct CancelResultBody {
|
|||
/// Refuses `Running` / terminal nodes: an
|
||||
/// in-flight node owns the agent's nix store + nixos-container update
|
||||
/// lock and can't be safely interrupted from the queue side. Always
|
||||
/// returns 200; the body is `{"cancelled": true}` on a successful
|
||||
/// flip to Cancelled, `{"cancelled": false}` when the DAG was
|
||||
/// Running / terminal / gone. On success a fresh `RebuildQueueChanged`
|
||||
/// snapshot fires so the state flip surfaces live.
|
||||
/// returns 200 with an empty body, whether or not anything was cancelled
|
||||
/// (Running / terminal / gone nodes are left alone). On a successful flip to
|
||||
/// Cancelled a fresh `RebuildQueueChanged` snapshot fires so the state flip
|
||||
/// surfaces live.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/rebuild-queue/{id}/cancel",
|
||||
params(("id" = u64, Path, description = "job-queue node id (a DAG's root cancels the group)")),
|
||||
responses((status = 200, description = "whether the DAG was cancelled", body = CancelResultBody)),
|
||||
responses((status = 200, description = "request handled; an empty body, whether or not the node was cancelled")),
|
||||
tag = "rebuild_queue"
|
||||
)]
|
||||
pub(super) async fn post_rebuild_queue_cancel(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<u64>,
|
||||
) -> Response {
|
||||
) -> StatusCode {
|
||||
if state.coord.job_queue.cancel(id) {
|
||||
// Any terminal side effect is the DAG's own spared tail node, which the
|
||||
// scheduler picks up on its next pass — nothing to fire from here.
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
axum::Json(CancelResultBody { cancelled: true }).into_response()
|
||||
} else {
|
||||
axum::Json(CancelResultBody { cancelled: false }).into_response()
|
||||
}
|
||||
StatusCode::OK
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,14 +24,6 @@ use super::tombstones::{TombstoneView, build_tombstone_views};
|
|||
#[allow(clippy::struct_excessive_bools)]
|
||||
#[derive(Serialize)]
|
||||
pub(super) struct StateSnapshot {
|
||||
/// Broker seq at the moment this snapshot was assembled. Clients
|
||||
/// dedupe their buffered SSE traffic against this value: any
|
||||
/// `MessageEvent` with `seq <= snapshot.seq` is already reflected in
|
||||
/// the snapshot (or pre-dates it); anything with `seq > snapshot.seq`
|
||||
/// is post-snapshot and should be applied. Set to 0 in the
|
||||
/// pre-emit case (no events ever fired) — clients treat that as
|
||||
/// "apply everything you've buffered".
|
||||
seq: u64,
|
||||
hostname: String,
|
||||
/// Current rev of the pinned `hyperhive` flake input, resolved the
|
||||
/// same way `get_agent_meta`'s per-agent `hyperhive_rev` is (see
|
||||
|
|
@ -46,15 +38,6 @@ pub(super) struct StateSnapshot {
|
|||
/// survive after a destroy-without-purge. The operator can re-spawn
|
||||
/// with the same name to resume, or PURG3 to wipe them.
|
||||
tombstones: Vec<TombstoneView>,
|
||||
/// Agents whose FNV-1a hashed web UI port collides with at least
|
||||
/// one other. Operator resolves by renaming. The dashboard renders
|
||||
/// a banner at the top listing each cluster.
|
||||
port_conflicts: Vec<PortConflict>,
|
||||
|
||||
/// Whether the hive-forge container is up. When true the dashboard
|
||||
/// links each container's config into the forge's `agent-configs`
|
||||
/// repos.
|
||||
forge_present: bool,
|
||||
/// Whether `hive-gateway` is in front of this dashboard. Sourced
|
||||
/// from the `HIVE_GATEWAY_ENABLED` env var, which the c0re NixOS
|
||||
/// module now always sets (the gateway runs unconditionally
|
||||
|
|
@ -89,13 +72,6 @@ pub(super) struct StateSnapshot {
|
|||
swarm_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct PortConflict {
|
||||
port: u16,
|
||||
/// All agent names sharing this port (sorted, ≥2 entries).
|
||||
agents: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TransientView {
|
||||
name: String,
|
||||
|
|
@ -107,8 +83,7 @@ struct TransientView {
|
|||
/// Cold-load snapshot of the whole dashboard.
|
||||
///
|
||||
/// Includes the roster, tombstones, job queue, and more. Live
|
||||
/// clients then follow `/api/dashboard/stream` (SSE) for incremental updates
|
||||
/// keyed off `seq`.
|
||||
/// clients then follow `/api/dashboard/stream` (SSE) for incremental updates.
|
||||
// `StateSnapshot` is a large tree of nested view types (`ContainerView`,
|
||||
// `TransientView`, ...) with no `ToSchema` anywhere in that
|
||||
// graph; wiring it up is a schema-modelling project of its own, well past
|
||||
|
|
@ -130,14 +105,6 @@ pub(super) async fn api_state(
|
|||
.unwrap_or("localhost");
|
||||
let hostname = host.split(':').next().unwrap_or(host).to_owned();
|
||||
|
||||
// Capture the unified dashboard-channel seq *before* any read so the
|
||||
// dedupe contract is "events with seq > snapshot.seq are
|
||||
// post-snapshot, never missed." An event landing during snapshot
|
||||
// construction may be doubly applied (snapshot caught the write +
|
||||
// client also applies the SSE frame) — that's a renderer's problem
|
||||
// to make idempotent, not ours to avoid here.
|
||||
let seq = state.coord.current_seq();
|
||||
|
||||
// Refresh the coordinator's cached container snapshot before
|
||||
// reading. Cold-load clients then see whatever the latest rescan
|
||||
// produced; live clients converge via the matching
|
||||
|
|
@ -169,18 +136,14 @@ pub(super) async fn api_state(
|
|||
let transient_snapshot = state.coord.transient_snapshot();
|
||||
let transients = build_transient_views(&containers, &transient_snapshot);
|
||||
let tombstones = build_tombstone_views(&containers);
|
||||
let port_conflicts = build_port_conflicts(&containers);
|
||||
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
hostname,
|
||||
hyperhive_rev: crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake),
|
||||
containers,
|
||||
transients,
|
||||
|
||||
tombstones,
|
||||
port_conflicts,
|
||||
forge_present: crate::forge::is_present().await,
|
||||
gateway_enabled: std::env::var_os("HIVE_GATEWAY_ENABLED").is_some_and(|v| {
|
||||
// Accept any truthy string ("1", "true", "yes"): the c0re
|
||||
// NixOS module sets the literal "1" (the gateway always runs
|
||||
|
|
@ -203,29 +166,6 @@ pub(super) async fn api_state(
|
|||
})
|
||||
}
|
||||
|
||||
/// Group containers by their assigned web UI port; clusters with more
|
||||
/// than one member are port-hash collisions the operator resolves by
|
||||
/// renaming an agent.
|
||||
///
|
||||
/// Every container's port comes from [`crate::lifecycle::agent_web_port`],
|
||||
/// which hashes the name into one range for all of them — so the manager
|
||||
/// is a candidate for a collision exactly like any sub-agent.
|
||||
fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
|
||||
let mut by_port: std::collections::BTreeMap<u16, Vec<String>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for c in containers {
|
||||
by_port.entry(c.port).or_default().push(c.name.clone());
|
||||
}
|
||||
by_port
|
||||
.into_iter()
|
||||
.filter(|(_, agents)| agents.len() > 1)
|
||||
.map(|(port, mut agents)| {
|
||||
agents.sort();
|
||||
PortConflict { port, agents }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Transient state for agents whose container does NOT yet exist
|
||||
/// (`Spawning`). Lifecycle ops on existing containers surface as
|
||||
/// `ContainerView.pending` inline; this list only catches pre-creation.
|
||||
|
|
@ -253,8 +193,7 @@ fn build_transient_views(
|
|||
.collect();
|
||||
// The caller rebuilds this map on every poll, so its iteration order
|
||||
// differs between renders and the rows would shuffle under an operator
|
||||
// watching an operation run. Sorted for the same reason
|
||||
// `build_port_conflicts` sorts its clusters.
|
||||
// watching an operation run. Sorted so the order is stable.
|
||||
out.sort_by(|a, b| {
|
||||
a.name
|
||||
.cmp(&b.name)
|
||||
|
|
@ -355,7 +294,7 @@ pub(super) struct DashboardStreamQuery {
|
|||
params(DashboardStreamQuery),
|
||||
responses(
|
||||
(status = 200, description = "server-sent event stream; each event's \
|
||||
`data` is a JSON-serialised `DashboardEvent` (seq-tagged)",
|
||||
`data` is a JSON-serialised `DashboardEvent`",
|
||||
body = String, content_type = "text/event-stream"),
|
||||
),
|
||||
tag = "state_snapshot"
|
||||
|
|
@ -378,8 +317,7 @@ pub(super) async fn dashboard_stream(
|
|||
if set.is_empty() { None } else { Some(set) }
|
||||
});
|
||||
let stream = BroadcastStream::new(rx).filter_map(move |res| {
|
||||
// Drop lagged frames. Browsers reconnect; the seq dedupe on
|
||||
// reconnect skips any frame already reflected in the snapshot.
|
||||
// Drop lagged frames. Browsers reconnect and refetch the snapshot.
|
||||
let event = res.ok()?;
|
||||
if let Some(filter) = kind_filter.as_ref()
|
||||
&& !filter.contains(event.kind_tag())
|
||||
|
|
@ -398,7 +336,7 @@ mod tests {
|
|||
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
use super::{ContainerView, build_port_conflicts, build_transient_views};
|
||||
use super::{ContainerView, build_transient_views};
|
||||
use crate::job_queue::RunningTransient;
|
||||
|
||||
fn rt(agent: &str, label: &str, age_secs: i64) -> RunningTransient {
|
||||
|
|
@ -418,6 +356,18 @@ mod tests {
|
|||
m
|
||||
}
|
||||
|
||||
fn cv(name: &str, port: u16, running: bool) -> ContainerView {
|
||||
ContainerView {
|
||||
name: name.to_owned(),
|
||||
container: format!("h-{name}"),
|
||||
port,
|
||||
running,
|
||||
failed: false,
|
||||
needs_login: false,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_whose_container_exists_is_not_transient() {
|
||||
let snap = snapshot(vec![rt("alice", "spawn", 5)]);
|
||||
|
|
@ -471,92 +421,44 @@ mod tests {
|
|||
assert!(build_transient_views(&[], &snapshot(vec![])).is_empty());
|
||||
}
|
||||
|
||||
fn cv(name: &str, port: u16, running: bool) -> ContainerView {
|
||||
ContainerView {
|
||||
name: name.to_owned(),
|
||||
container: format!("h-{name}"),
|
||||
port,
|
||||
running,
|
||||
failed: false,
|
||||
needs_login: false,
|
||||
active_model: None,
|
||||
status_text: None,
|
||||
status_set_at: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The JSON object a cold-loading dashboard receives carries exactly these
|
||||
/// keys; a field added back must be added here, with a reader.
|
||||
#[test]
|
||||
fn distinct_ports_produce_no_conflicts() {
|
||||
let cs = [cv("alice", 8101, true), cv("bob", 8102, true)];
|
||||
assert!(build_port_conflicts(&cs).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_container_does_not_conflict_with_itself() {
|
||||
assert!(build_port_conflicts(&[cv("alice", 8101, true)]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_agents_on_one_port_are_reported_together() {
|
||||
let cs = [cv("alice", 8101, true), cv("bob", 8101, true)];
|
||||
let got = build_port_conflicts(&cs);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].port, 8101);
|
||||
assert_eq!(got[0].agents, ["alice", "bob"]);
|
||||
}
|
||||
|
||||
/// The manager takes its port from the same hash as everyone else, so
|
||||
/// it collides like any other name. A doc comment here used to claim it
|
||||
/// sat at a fixed 8000 and could not — no such special case exists.
|
||||
#[test]
|
||||
fn the_manager_can_collide_with_a_sub_agent() {
|
||||
let cs = [cv("manager", 8101, true), cv("alice", 8101, true)];
|
||||
let got = build_port_conflicts(&cs);
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].agents, ["alice", "manager"]);
|
||||
}
|
||||
|
||||
/// A stopped agent keeps its hashed port, so the clash is real the
|
||||
/// moment it starts. Reporting it while it is down is the point.
|
||||
#[test]
|
||||
fn a_stopped_container_still_claims_its_port() {
|
||||
let cs = [cv("alice", 8101, true), cv("bob", 8101, false)];
|
||||
assert_eq!(build_port_conflicts(&cs).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_sorted_within_a_conflict() {
|
||||
let cs = [cv("zoe", 8101, true), cv("adam", 8101, true)];
|
||||
assert_eq!(build_port_conflicts(&cs)[0].agents, ["adam", "zoe"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_two_agents_land_in_one_cluster() {
|
||||
let cs = [
|
||||
cv("c", 8101, true),
|
||||
cv("a", 8101, true),
|
||||
cv("b", 8101, true),
|
||||
];
|
||||
let got = build_port_conflicts(&cs);
|
||||
assert_eq!(got.len(), 1, "one cluster, not one row per pair");
|
||||
assert_eq!(got[0].agents, ["a", "b", "c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_clusters_are_ordered_by_port() {
|
||||
let cs = [
|
||||
cv("d", 8300, true),
|
||||
cv("c", 8300, true),
|
||||
cv("b", 8200, true),
|
||||
cv("a", 8200, true),
|
||||
];
|
||||
let ports: Vec<u16> = build_port_conflicts(&cs).iter().map(|p| p.port).collect();
|
||||
assert_eq!(ports, [8200, 8300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_containers_is_not_a_conflict() {
|
||||
assert!(build_port_conflicts(&[]).is_empty());
|
||||
fn the_state_snapshot_serialises_exactly_its_read_fields() {
|
||||
let snap = super::StateSnapshot {
|
||||
hostname: "h".to_owned(),
|
||||
hyperhive_rev: None,
|
||||
containers: Vec::new(),
|
||||
transients: Vec::new(),
|
||||
tombstones: Vec::new(),
|
||||
gateway_enabled: true,
|
||||
swarm_public_url: None,
|
||||
authelia_public_url: None,
|
||||
hive_name: None,
|
||||
swarm_name: None,
|
||||
};
|
||||
let value = serde_json::to_value(&snap).unwrap();
|
||||
let mut keys: Vec<&str> = value
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect();
|
||||
keys.sort_unstable();
|
||||
assert_eq!(
|
||||
keys,
|
||||
[
|
||||
"authelia_public_url",
|
||||
"containers",
|
||||
"gateway_enabled",
|
||||
"hive_name",
|
||||
"hostname",
|
||||
"hyperhive_rev",
|
||||
"swarm_name",
|
||||
"swarm_public_url",
|
||||
"tombstones",
|
||||
"transients",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
|
|||
let containers = coord.containers_snapshot().await;
|
||||
let tombstones = build_tombstone_views(&containers);
|
||||
coord.emit_dashboard_event(crate::dashboard_events::DashboardEvent::TombstonesChanged {
|
||||
seq: coord.next_seq(),
|
||||
tombstones,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue