Watch
0
0
Fork
You've already forked hyperhive
0

dashboard: remove the flow page and the endpoints only it used

The flow page showed the raw broker message stream and had a compose box.
This removes the page and its bundle, its link in the pages menu, and the
styles only it used.

The history endpoint that backfilled it and the operator-send endpoint
behind the compose box had no other caller, so they go too, along with
the broker query behind the history endpoint. The stream endpoint stays:
the dashboard tabs still subscribe to it.

The docs lose the FL0W section and the references to the two endpoints.
This commit is contained in:
iris 2026-10-21 22:19:13 +02:00 • committed by mara
commit fa131b46a6
24 changed files with 62 additions and 1121 deletions

View file

@ -1,8 +1,8 @@
//! Remaining single-endpoint dashboard handlers: mark-all-read, operator
//! compose (`op-send`), spawn-request and hive-wide turn stats.
//! Remaining single-endpoint dashboard handlers: mark-all-read,
//! spawn-request and hive-wide turn stats.
use axum::{
extract::{Form, Path as AxumPath, State},
extract::{Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
@ -82,67 +82,3 @@ pub(super) async fn post_mark_all_read(
Err(e) => error_response(&format!("mark-all-read {name} failed: {e:#}")),
}
}
/// Operator-side compose form on the dashboard terminal. Drops a
/// message into the broker as `{from: "operator", to, body}`. Same
/// shape that per-agent web UIs use via `OperatorMsg`, but here the
/// operator picks the recipient explicitly with `@name`. No
/// validation that `to` resolves to a known agent — broker accepts
/// arbitrary recipients (and the agent's inbox grows whether or not
/// they exist, which is fine for spawn-then-greet flows).
#[derive(Deserialize, ToSchema)]
pub(super) struct OpSendForm {
to: String,
body: String,
}
/// Operator compose: drop a message into the
/// broker addressed to `to` (or `*` to broadcast).
#[utoipa::path(
post,
path = "/api/op-send",
request_body(content = OpSendForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "message sent", body = String),
(status = 500, description = "missing to/body, or the broker send failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_op_send(
State(state): State<AppState>,
Form(form): Form<OpSendForm>,
) -> Response {
let to = form.to.trim().to_owned();
let body = form.body.trim().to_owned();
if to.is_empty() {
return error_response("op-send: `to` required");
}
if body.is_empty() {
return error_response("op-send: `body` required");
}
if to == "*" {
let errors = state
.coord
.broadcast_send(hive_sh4re::manager::OPERATOR_RECIPIENT, &body);
if !errors.is_empty() {
return error_response(&format!(
"op-send broadcast partial fail: {}",
errors.join("; ")
));
}
} else if let Err(e) = state.coord.broker.send(&hive_sh4re::inbox::Message {
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::OPERATOR_RECIPIENT),
to: to.clone(),
body,
in_reply_to: None,
}) {
return error_response(&format!("op-send to {to} failed: {e:#}"));
}
// 200 instead of 303 → the client doesn't refetch /api/state. The
// broker `send` already emitted a `MessageEvent` which the
// dashboard channel forwarder mirrors as `DashboardEvent::Sent`,
// and the page's terminal derives from that stream — so the
// operator's send shows up the same way an agent's send does, with
// no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response()
}

View file

@ -117,7 +117,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
@ -137,7 +136,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup))
.split_for_parts();
@ -371,7 +369,6 @@ mod router_build_probe {
.routes(routes!(state_files::get_state_file))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
.routes(routes!(build_logs::get_build_log_raw_for_node))
@ -391,7 +388,6 @@ mod router_build_probe {
.routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream))
.routes(routes!(state_snapshot::dashboard_stream))
.routes(routes!(state_snapshot::dashboard_history))
.routes(routes!(state_snapshot::jobq_graph))
.routes(routes!(state_snapshot::jobq_rollup));
}

View file

@ -1,17 +1,14 @@
//! `/api/state` cold-load snapshot plus the dashboard's live read side:
//! the `StateSnapshot` shape and its view builders, the
//! `/api/dashboard/stream` SSE channel, and the `/api/dashboard/history`
//! backfill. SPA shape + SSE channels: docs/web-ui/shape.md.
//! `/api/dashboard/stream` SSE channel. SPA shape + SSE channels:
//! docs/web-ui/shape.md.
use std::convert::Infallible;
use axum::{
extract::State,
http::HeaderMap,
response::{
IntoResponse, Response,
sse::{Event, KeepAlive, Sse},
},
response::sse::{Event, KeepAlive, Sse},
};
use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream;
@ -20,9 +17,9 @@ use utoipa::IntoParams;
use crate::container_view::ContainerView;
use super::AppState;
use super::meta_inputs::{MetaInputView, read_meta_inputs};
use super::tombstones::{TombstoneView, build_tombstone_views};
use super::{AppState, error_response, scan_validated_paths};
#[allow(clippy::struct_excessive_bools)]
#[derive(Serialize)]
@ -351,105 +348,14 @@ pub(super) async fn jobq_rollup(
axum::Json(state.coord.job_queue.state_rollup())
}
/// Response body for `/api/dashboard/history`. No `ToSchema` — its
/// `events` field wraps [`crate::dashboard_events::DashboardEvent`],
/// which doesn't derive `ToSchema` either (a large enum with many
/// variants; see that type's doc comment for why annotating it is
/// out of scope here). The `responses(...)` doc below spells out the
/// shape in prose instead of a `body = ...` reference.
#[derive(Serialize)]
struct DashboardHistoryBody {
seq: u64,
events: Vec<crate::dashboard_events::DashboardEvent>,
}
#[utoipa::path(
get,
path = "/api/dashboard/history",
responses(
(status = 200, description = "`{ seq, events }` — up to the last 200 \
broker messages as `DashboardEvent::Sent`/`Delivered` JSON, plus \
`seq`: the dashboard channel's high-water mark at fetch time \
(used by clients to dedupe against buffered live SSE frames)"),
),
tag = "state_snapshot"
)]
pub(super) async fn dashboard_history(State(state): State<AppState>) -> Response {
// Backfill source for the dashboard terminal. Returns up to ~200
// historical broker messages (no other event kinds are persisted)
// converted to `DashboardEvent::Sent` JSON so the client can replay
// through the same dispatch path as live frames. Wrapped in
// `{ seq, events }`: the seq is the dashboard channel's high-water
// mark at fetch time. Clients use it to dedupe their buffered live
// SSE traffic (drop anything with `seq <= history_seq`) so a frame
// that lands between SSE-subscribe and history-fetch isn't shown
// twice and isn't lost. Historical rows carry `seq = 0`; the
// boundary seq is what closes the dedupe window.
const HISTORY_LIMIT: u64 = 200;
let seq = state.coord.current_seq();
match state.coord.broker.recent_all(HISTORY_LIMIT) {
Ok(mut messages) => {
messages.reverse();
let events: Vec<crate::dashboard_events::DashboardEvent> = messages
.into_iter()
.map(|m| match m {
crate::broker::MessageEvent::Sent {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Sent {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
crate::broker::MessageEvent::Delivered {
id,
from,
to,
body,
at,
in_reply_to,
} => {
let file_refs = scan_validated_paths(&body);
crate::dashboard_events::DashboardEvent::Delivered {
seq: 0,
id,
from,
to,
body,
at: hive_sh4re::wire_time::from_secs(at),
in_reply_to,
file_refs,
}
}
})
.collect();
axum::Json(DashboardHistoryBody { seq, events }).into_response()
}
Err(e) => error_response(&format!("dashboard/history failed: {e:#}")),
}
}
/// `/dashboard/stream` query string. Today's only field is `kinds`:
/// a comma-separated allow-list of event-`kind` strings.
/// Empty / absent ⇒ no filter (current behaviour, all variants
/// forwarded). Set ⇒ only the named kinds reach the subscriber,
/// non-matches are skipped before the JSON serialise cost.
///
/// Useful for narrow pages (e.g. `flow.js` only cares about `sent`
/// / `delivered` / `container_state_changed` / `container_removed`)
/// that want to drop the dispatch overhead on every unrelated mutation.
/// Useful for narrow pages that only care about a few kinds and want to
/// drop the dispatch overhead on every unrelated mutation.
#[derive(Deserialize, Default, IntoParams)]
pub(super) struct DashboardStreamQuery {
/// Comma-separated event kinds to forward. Each token is
@ -464,8 +370,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; pair \
with `/api/dashboard/history` to backfill + dedupe on connect)",
`data` is a JSON-serialised `DashboardEvent` (seq-tagged)",
body = String, content_type = "text/event-stream"),
),
tag = "state_snapshot"

View file

@ -269,38 +269,6 @@ impl Broker {
.map_err(Into::into)
}
/// Latest `limit` messages across every recipient, newest-first.
/// Backs the dashboard's message-flow backfill so a reload doesn't
/// blank the operator's view of recent traffic. Returns each row as
/// a [`MessageEvent::Sent`] so the dashboard's live renderer (which
/// already speaks `MessageEvent`) can replay history through the
/// same code path. We don't synthesise `Delivered` events here —
/// the recv-side acks live in a different table column and would
/// double-render on backfill; the live stream picks them up
/// immediately on the first new `recv`.
pub fn recent_all(&self, limit: u64) -> Result<Vec<MessageEvent>> {
let conn = self.conn.lock().unwrap();
let limit_i = i64::try_from(limit.min(i64::MAX as u64)).unwrap_or(i64::MAX);
let mut stmt = conn.prepare(
"SELECT id, sender, recipient, body, sent_at, in_reply_to
FROM messages
ORDER BY id DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit_i], |row| {
Ok(MessageEvent::Sent {
id: row.get(0)?,
from: row.get(1)?,
to: row.get(2)?,
body: row.get(3)?,
at: row.get(4)?,
in_reply_to: row.get(5)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Number of undelivered messages addressed to `recipient`. Non-mutating
/// — used by the harness to surface "N unread" in tool-result status
/// lines without popping the queue.