refactor(#1456): extract dashboard topology set-parent endpoints into dashboard/topology.rs
This commit is contained in:
parent
aa6e422b78
commit
aa8bf11c8b
2 changed files with 129 additions and 102 deletions
|
|
@ -35,6 +35,7 @@ mod permissions;
|
||||||
mod questions;
|
mod questions;
|
||||||
mod reminders;
|
mod reminders;
|
||||||
mod schedules;
|
mod schedules;
|
||||||
|
mod topology;
|
||||||
mod webhook;
|
mod webhook;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|
@ -42,6 +43,13 @@ struct AppState {
|
||||||
coord: Arc<Coordinator>,
|
coord: Arc<Coordinator>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(
|
||||||
|
clippy::too_many_lines,
|
||||||
|
reason = "the body is dominated by the flat axum route table — one line \
|
||||||
|
per endpoint mapping a URL to its (now per-concern submodule) \
|
||||||
|
handler; splitting that exhaustive list across helpers would \
|
||||||
|
obscure the route map for no readability gain"
|
||||||
|
)]
|
||||||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
|
|
@ -95,8 +103,11 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
)
|
)
|
||||||
.route("/retry-reminder/{id}", post(reminders::post_retry_reminder))
|
.route("/retry-reminder/{id}", post(reminders::post_retry_reminder))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/api/topology/set-parent", post(post_set_parent))
|
.route("/api/topology/set-parent", post(topology::post_set_parent))
|
||||||
.route("/api/topology/set-parent-bulk", post(post_set_parent_bulk))
|
.route(
|
||||||
|
"/api/topology/set-parent-bulk",
|
||||||
|
post(topology::post_set_parent_bulk),
|
||||||
|
)
|
||||||
.route("/api/tool-groups", get(permissions::get_tool_groups))
|
.route("/api/tool-groups", get(permissions::get_tool_groups))
|
||||||
.route(
|
.route(
|
||||||
"/api/tool-groups/{agent}",
|
"/api/tool-groups/{agent}",
|
||||||
|
|
@ -1083,30 +1094,6 @@ struct RequestSpawnForm {
|
||||||
name: String,
|
name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/topology/set-parent` body. `child` is required.
|
|
||||||
/// `new_parent` may be:
|
|
||||||
/// - absent or empty / whitespace-only → promote to root,
|
|
||||||
/// - non-empty → new parent's logical name.
|
|
||||||
///
|
|
||||||
/// (The CLI surface gates "no parent specified" behind an explicit
|
|
||||||
/// `--root` flag for safety; the HTTP surface is permissive
|
|
||||||
/// because the dashboard form encodes "no value" as the empty
|
|
||||||
/// string for the optional radio-group input.)
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SetParentForm {
|
|
||||||
child: String,
|
|
||||||
new_parent: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
|
||||||
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct SetParentBulkEntry {
|
|
||||||
child: String,
|
|
||||||
#[serde(default)]
|
|
||||||
new_parent: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct BuildLogsAllQuery {
|
struct BuildLogsAllQuery {
|
||||||
/// Max rows to return. Capped at 100. Default 30.
|
/// Max rows to return. Capped at 100. Default 30.
|
||||||
|
|
@ -2068,82 +2055,6 @@ async fn post_request_spawn(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/topology/set-parent` — operator-driven parent move.
|
|
||||||
/// Form fields: `child` (required, agent name), `new_parent`
|
|
||||||
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
|
||||||
/// cycles and unknown agents. The manager is reparentable like any
|
|
||||||
/// other agent — its privileges come from the privileged MCP socket,
|
|
||||||
/// not its tree position. On success
|
|
||||||
/// re-emits container snapshots so the dashboard tree repaints
|
|
||||||
/// without a refresh.
|
|
||||||
async fn post_set_parent(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Form(form): Form<SetParentForm>,
|
|
||||||
) -> Response {
|
|
||||||
let child = form.child.trim().to_owned();
|
|
||||||
if child.is_empty() {
|
|
||||||
return error_response("set-parent: `child` required");
|
|
||||||
}
|
|
||||||
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
|
||||||
// forms submit the empty string for a "no value" radio button,
|
|
||||||
// so this is the ergonomic encoding.
|
|
||||||
let new_parent = form
|
|
||||||
.new_parent
|
|
||||||
.as_deref()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.map(str::to_owned);
|
|
||||||
// `reparent_with_notify` wraps `topology::set_parent` with the
|
|
||||||
// three notification messages + the ContainerView rescan.
|
|
||||||
// Idempotent same-parent calls skip both the messages and the
|
|
||||||
// disk write per the topology fast-path.
|
|
||||||
match state
|
|
||||||
.coord
|
|
||||||
.reparent_with_notify(&child, new_parent.as_deref())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(()) => {
|
|
||||||
tracing::info!(
|
|
||||||
child = %child,
|
|
||||||
new_parent = ?new_parent,
|
|
||||||
"operator: set-parent via dashboard"
|
|
||||||
);
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single
|
|
||||||
/// request, producing **one** git commit. JSON body: `[{"child":"name",
|
|
||||||
/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK).
|
|
||||||
/// First validation error aborts the whole batch.
|
|
||||||
async fn post_set_parent_bulk(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
|
||||||
) -> Response {
|
|
||||||
if body.is_empty() {
|
|
||||||
return (StatusCode::OK, "ok").into_response();
|
|
||||||
}
|
|
||||||
// Collect borrows for the coordinator call.
|
|
||||||
let moves: Vec<(&str, Option<&str>)> = body
|
|
||||||
.iter()
|
|
||||||
.map(|e| {
|
|
||||||
let child: &str = &e.child;
|
|
||||||
let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty());
|
|
||||||
(child, parent)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
match state.coord.reparent_bulk_with_notify(&moves).await {
|
|
||||||
Ok(()) => {
|
|
||||||
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
|
||||||
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
|
||||||
}
|
|
||||||
Err(e) => error_response(&format!("set-parent-bulk failed: {e}")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||||
let logical = strip_container_prefix(&name);
|
let logical = strip_container_prefix(&name);
|
||||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||||
|
|
|
||||||
116
hive-c0re/src/dashboard/topology.rs
Normal file
116
hive-c0re/src/dashboard/topology.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
//! Topology (set-parent) endpoints for the dashboard.
|
||||||
|
//!
|
||||||
|
//! Operator-driven agent reparenting — single (`/api/topology/set-parent`,
|
||||||
|
//! form-encoded) and bulk (`/api/topology/set-parent-bulk`, JSON array →
|
||||||
|
//! one git commit). Both go through `Coordinator::reparent*_with_notify`,
|
||||||
|
//! which wraps `topology::set_parent` with the move-notification messages
|
||||||
|
//! and the `ContainerView` rescan.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Form, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use super::{AppState, error_response};
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent` body. `child` is required.
|
||||||
|
/// `new_parent` may be:
|
||||||
|
/// - absent or empty / whitespace-only → promote to root,
|
||||||
|
/// - non-empty → new parent's logical name.
|
||||||
|
///
|
||||||
|
/// (The CLI surface gates "no parent specified" behind an explicit
|
||||||
|
/// `--root` flag for safety; the HTTP surface is permissive
|
||||||
|
/// because the dashboard form encodes "no value" as the empty
|
||||||
|
/// string for the optional radio-group input.)
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetParentForm {
|
||||||
|
child: String,
|
||||||
|
new_parent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One entry in a `POST /api/topology/set-parent-bulk` JSON array.
|
||||||
|
/// `new_parent`: absent/null/empty-string all mean "promote to root".
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub(super) struct SetParentBulkEntry {
|
||||||
|
child: String,
|
||||||
|
#[serde(default)]
|
||||||
|
new_parent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent` — operator-driven parent move.
|
||||||
|
/// Form fields: `child` (required, agent name), `new_parent`
|
||||||
|
/// (optional — empty / absent string ⇒ promote to root). Refuses
|
||||||
|
/// cycles and unknown agents. The manager is reparentable like any
|
||||||
|
/// other agent — its privileges come from the privileged MCP socket,
|
||||||
|
/// not its tree position. On success
|
||||||
|
/// re-emits container snapshots so the dashboard tree repaints
|
||||||
|
/// without a refresh.
|
||||||
|
pub(super) async fn post_set_parent(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Form(form): Form<SetParentForm>,
|
||||||
|
) -> Response {
|
||||||
|
let child = form.child.trim().to_owned();
|
||||||
|
if child.is_empty() {
|
||||||
|
return error_response("set-parent: `child` required");
|
||||||
|
}
|
||||||
|
// Empty / whitespace-only `new_parent` ⇒ promote to root. Web
|
||||||
|
// forms submit the empty string for a "no value" radio button,
|
||||||
|
// so this is the ergonomic encoding.
|
||||||
|
let new_parent = form
|
||||||
|
.new_parent
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
// `reparent_with_notify` wraps `topology::set_parent` with the
|
||||||
|
// three notification messages + the ContainerView rescan.
|
||||||
|
// Idempotent same-parent calls skip both the messages and the
|
||||||
|
// disk write per the topology fast-path.
|
||||||
|
match state
|
||||||
|
.coord
|
||||||
|
.reparent_with_notify(&child, new_parent.as_deref())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
child = %child,
|
||||||
|
new_parent = ?new_parent,
|
||||||
|
"operator: set-parent via dashboard"
|
||||||
|
);
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /api/topology/set-parent-bulk` — move multiple agents in a single
|
||||||
|
/// request, producing **one** git commit. JSON body: `[{"child":"name",
|
||||||
|
/// "new_parent":"target-or-null"}, ...]`. Empty array is a no-op (200 OK).
|
||||||
|
/// First validation error aborts the whole batch.
|
||||||
|
pub(super) async fn post_set_parent_bulk(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
axum::Json(body): axum::Json<Vec<SetParentBulkEntry>>,
|
||||||
|
) -> Response {
|
||||||
|
if body.is_empty() {
|
||||||
|
return (StatusCode::OK, "ok").into_response();
|
||||||
|
}
|
||||||
|
// Collect borrows for the coordinator call.
|
||||||
|
let moves: Vec<(&str, Option<&str>)> = body
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let child: &str = &e.child;
|
||||||
|
let parent: Option<&str> = e.new_parent.as_deref().filter(|s| !s.is_empty());
|
||||||
|
(child, parent)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
match state.coord.reparent_bulk_with_notify(&moves).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let names: Vec<&str> = body.iter().map(|e| e.child.as_str()).collect();
|
||||||
|
tracing::info!(agents = ?names, "operator: set-parent-bulk via dashboard");
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("set-parent-bulk failed: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue