From 1333532d3f14af195e5df969eb43874d99034791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 01:59:53 +0200 Subject: [PATCH] =?UTF-8?q?dashboard:=20T4LK=20form=20=E2=80=94=20operator?= =?UTF-8?q?=20sends=20messages=20from=20the=20browser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-c0re/src/dashboard.rs | 69 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index d561ae1..9d46b3c 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -19,7 +19,9 @@ use axum::{ }, routing::{get, post}, }; -use hive_sh4re::Approval; +use axum::extract::Form; +use hive_sh4re::{Approval, MANAGER_AGENT, Message}; +use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::{Stream, StreamExt}; @@ -39,6 +41,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/", get(index)) .route("/approve/{id}", post(post_approve)) .route("/deny/{id}", post(post_deny)) + .route("/send", post(post_send)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); @@ -65,11 +68,35 @@ async fn index(headers: HeaderMap, State(state): State) -> Html\n\n\n\nhyperhive // h1ve-c0re\n{STYLE}\n\n\n{BANNER}\n{containers}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", + "\n\n\n\nhyperhive // h1ve-c0re\n{STYLE}\n\n\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n\n\n", containers = render_containers(&containers, &hostname), + talk = render_talk(&containers), )) } +#[derive(Deserialize)] +struct SendForm { + to: String, + body: String, +} + +async fn post_send(State(state): State, Form(form): Form) -> Response { + let to = form.to.trim().to_owned(); + let body = form.body.trim().to_owned(); + if to.is_empty() || body.is_empty() { + return error_response("send: `to` and `body` required"); + } + let msg = Message { + from: "operator".into(), + to, + body, + }; + match state.coord.broker.send(&msg) { + Ok(()) => Redirect::to("/").into_response(), + Err(e) => error_response(&format!("send failed: {e:#}")), + } +} + async fn messages_stream( State(state): State, ) -> Sse>> { @@ -159,6 +186,25 @@ async fn render_approvals(approvals: &[Approval]) -> String { out } +fn render_talk(containers: &[String]) -> String { + let mut options = String::new(); + let _ = writeln!( + options, + "", + ); + for container in containers { + if container == MANAGER_NAME { + continue; + } + if let Some(name) = container.strip_prefix(AGENT_PREFIX) { + let _ = writeln!(options, ""); + } + } + format!( + "

◆ T4LK ◆

\n
══════════════════════════════════════════════════════════════
\n
\n \n \n \n
\n

sends as from: operator. Replies stream into the message panel below.

\n" + ) +} + /// Filter out approvals whose agent state dir was wiped out from under us /// (e.g. by a test script's cleanup). Marks them failed so they fall out of /// `pending` on next render. @@ -380,6 +426,25 @@ const STYLE: &str = r#" .btn:hover { background: rgba(255,255,255,0.05); text-shadow: 0 0 12px currentColor; } .btn-approve { color: var(--green); border-color: var(--green); } .btn-deny { color: var(--red); border-color: var(--red); } + .btn-talk { color: var(--cyan); border-color: var(--cyan); } + .talkform { + display: flex; + gap: 0.6em; + align-items: stretch; + margin-top: 0.5em; + } + .talkform select, .talkform input { + font-family: inherit; + font-size: 1em; + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + padding: 0.4em 0.6em; + } + .talkform select { color: var(--amber); } + .talkform input { flex: 1; } + .talkform input::placeholder { color: var(--muted); } + .talkform input:focus, .talkform select:focus { outline: 1px solid var(--purple); } details { margin-top: 0.5em; } summary { cursor: pointer;