topology: operator-driven move-agent via set_parent (#486)

This commit is contained in:
damocles 2026-05-26 19:15:00 +02:00 committed by Mara
commit 5456377622
5 changed files with 245 additions and 0 deletions

View file

@ -72,6 +72,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/cancel-reminder/{id}", post(post_cancel_reminder))
.route("/retry-reminder/{id}", post(post_retry_reminder))
.route("/request-spawn", post(post_request_spawn))
.route("/api/topology/set-parent", post(post_set_parent))
.route("/op-send", post(post_op_send))
.route("/meta-update", post(post_meta_update))
.route("/api/schedules", get(api_schedules).post(post_schedule_new))
@ -849,6 +850,17 @@ struct RequestSpawnForm {
name: String,
}
/// `POST /api/topology/set-parent` body. `child` is required.
/// `new_parent` may be:
/// - omitted entirely (form field absent) → no-op error,
/// - empty string → promote to root,
/// - non-empty → new parent's logical name.
#[derive(Deserialize)]
struct SetParentForm {
child: String,
new_parent: Option<String>,
}
#[derive(Deserialize)]
struct AnswerForm {
answer: String,
@ -1846,6 +1858,45 @@ async fn post_request_spawn(
}
}
/// `POST /api/topology/set-parent` — operator-driven parent move
/// (#486). Form fields: `child` (required, agent name), `new_parent`
/// (optional — empty / absent string ⇒ promote to root). Refuses
/// cycles, unknown agents, and reparenting the manager. 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);
match crate::topology::set_parent(&child, new_parent.as_deref()) {
Ok(()) => {
tracing::info!(
child = %child,
new_parent = ?new_parent,
"operator: set-parent via dashboard"
);
// Topology drives ContainerView.parent; refresh the
// snapshot so connected viewers see the new tree.
state.coord.rescan_containers_and_emit().await;
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("set-parent {child} failed: {e}")),
}
}
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
let logical = strip_container_prefix(&name);
state.coord.rebuild_queue.enqueue(