diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index df758a3d..bef28f6e 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -4,9 +4,7 @@ //! shape they want (HTTP redirect vs JSON). use anyhow::Result; -use hive_sh4re::{ - ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER, -}; +use hive_sh4re::{ApprovalStatus, HelperEvent, MANAGER_AGENT, Message, SYSTEM_SENDER}; use crate::coordinator::Coordinator; use crate::lifecycle; diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index d561ae16..b09bd5ee 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -9,6 +9,7 @@ use std::path::Path; use std::sync::Arc; use anyhow::{Context, Result}; +use axum::extract::Form; use axum::{ Router, extract::{Path as AxumPath, State}, @@ -19,7 +20,8 @@ use axum::{ }, routing::{get, post}, }; -use hive_sh4re::Approval; +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. @@ -169,9 +215,7 @@ fn gc_orphans(coord: &Coordinator, approvals: Vec) -> Vec { if Coordinator::agent_proposed_dir(&a.agent).exists() { true } else { - let _ = coord - .approvals - .mark_failed(a.id, "agent state dir missing"); + let _ = coord.approvals.mark_failed(a.id, "agent state dir missing"); tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval"); false } @@ -380,6 +424,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; diff --git a/hive-c0re/src/lifecycle.rs b/hive-c0re/src/lifecycle.rs index 2ad91885..dcce16c4 100644 --- a/hive-c0re/src/lifecycle.rs +++ b/hive-c0re/src/lifecycle.rs @@ -296,9 +296,8 @@ fn set_resource_limits(container: &str) -> Result<()> { let dir = format!("/run/systemd/system/container@{container}.service.d"); std::fs::create_dir_all(&dir).with_context(|| format!("create {dir}"))?; let path = format!("{dir}/hyperhive-limits.conf"); - let content = format!( - "[Service]\nMemoryMax={DEFAULT_MEMORY_MAX}\nCPUQuota={DEFAULT_CPU_QUOTA}\n", - ); + let content = + format!("[Service]\nMemoryMax={DEFAULT_MEMORY_MAX}\nCPUQuota={DEFAULT_CPU_QUOTA}\n",); std::fs::write(&path, content).with_context(|| format!("write {path}"))?; tracing::info!( %path,