rebuild button on agent UI (cross-origin POST to dashboard /rebuild)

This commit is contained in:
müde 2026-05-15 15:57:11 +02:00
parent 824914807a
commit f1fd787f17
7 changed files with 42 additions and 6 deletions

View file

@ -91,8 +91,12 @@ async fn index(State(state): State<AppState>) -> Html<String> {
(LoginState::NeedsLogin, None) => render_needs_login_idle(), (LoginState::NeedsLogin, None) => render_needs_login_idle(),
(LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session), (LoginState::NeedsLogin, Some(session)) => render_login_in_progress(&session),
}; };
let dashboard_port = std::env::var("HIVE_DASHBOARD_PORT")
.ok()
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(7000);
Html(format!( Html(format!(
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n</body>\n</html>\n", "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>{label} // hyperhive</title>\n{STYLE}\n</head>\n<body>\n<pre class=\"banner\">░▒▓█▓▒░ {label} ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>\n<h2>◆ {label} ◆ <a href=\"#\" id=\"rebuild-btn\" class=\"btn-rebuild\" data-port=\"{dashboard_port}\" data-label=\"{label}\">↻ R3BU1LD</a></h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n{body}\n<script>\n(function() {{\n const b = document.getElementById('rebuild-btn');\n b.addEventListener('click', function(e) {{\n e.preventDefault();\n if (!confirm('rebuild ' + b.dataset.label + '? container will hot-reload.')) return;\n const url = window.location.protocol + '//' + window.location.hostname + ':' + b.dataset.port + '/rebuild/' + b.dataset.label;\n const form = document.createElement('form');\n form.method = 'POST';\n form.action = url;\n document.body.appendChild(form);\n form.submit();\n }});\n}})();\n</script>\n</body>\n</html>\n",
label = state.label, label = state.label,
)) ))
} }
@ -435,6 +439,19 @@ const STYLE: &str = r#"
.btn:hover { background: rgba(204, 102, 255, 0.1); } .btn:hover { background: rgba(204, 102, 255, 0.1); }
.btn-login { color: var(--amber); border-color: var(--amber); } .btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; } .btn-cancel { color: #ff6b6b; border-color: #ff6b6b; font-size: 0.85em; padding: 0.15em 0.6em; }
.btn-rebuild {
color: var(--amber);
border: 1px solid var(--amber);
padding: 0.15em 0.6em;
font-size: 0.55em;
font-family: inherit;
text-decoration: none;
letter-spacing: 0.1em;
margin-left: 0.6em;
vertical-align: middle;
cursor: pointer;
}
.btn-rebuild:hover { background: rgba(255, 184, 77, 0.1); }
.btn-send { color: var(--green); border-color: var(--green); } .btn-send { color: var(--green); border-color: var(--green); }
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; } .sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.sendform input { .sendform input {

View file

@ -48,6 +48,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&agent_dir, &agent_dir,
&applied_dir, &applied_dir,
&claude_dir, &claude_dir,
coord.dashboard_port,
) )
.await .await
} }
@ -69,6 +70,7 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
&proposed_dir, &proposed_dir,
&applied_dir, &applied_dir,
&claude_dir, &claude_dir,
coord_bg.dashboard_port,
) )
.await; .await;
coord_bg.clear_transient(&agent_bg); coord_bg.clear_transient(&agent_bg);

View file

@ -64,6 +64,7 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
&agent_dir, &agent_dir,
&applied_dir, &applied_dir,
&claude_dir, &claude_dir,
coord.dashboard_port,
) )
.await?; .await?;
std::fs::write(rev_marker_path(name), current_rev) std::fs::write(rev_marker_path(name), current_rev)
@ -112,6 +113,7 @@ pub async fn ensure_manager(coord: &Arc<Coordinator>) -> Result<()> {
&proposed, &proposed,
&applied, &applied,
&claude_dir, &claude_dir,
coord.dashboard_port,
) )
.await?; .await?;
if let Some(rev) = current_rev { if let Some(rev) = current_rev {

View file

@ -29,6 +29,10 @@ pub struct Coordinator {
/// URL of the hyperhive flake (no fragment). Inlined into per-agent /// URL of the hyperhive flake (no fragment). Inlined into per-agent
/// `flake.nix` files as `inputs.hyperhive.url`. /// `flake.nix` files as `inputs.hyperhive.url`.
pub hyperhive_flake: String, pub hyperhive_flake: String,
/// TCP port the host's hive-c0re dashboard listens on. Inlined into
/// each per-agent flake so the agent's web UI can build the right
/// rebuild-button URL pointing back at the dashboard.
pub dashboard_port: u16,
agents: Mutex<HashMap<String, AgentSocket>>, agents: Mutex<HashMap<String, AgentSocket>>,
/// Agents whose lifecycle action (currently just spawn) is in flight. /// Agents whose lifecycle action (currently just spawn) is in flight.
/// Read by the dashboard to render a spinner; cleared when the action /// Read by the dashboard to render a spinner; cleared when the action
@ -51,13 +55,14 @@ pub enum TransientKind {
} }
impl Coordinator { impl Coordinator {
pub fn open(db_path: &Path, hyperhive_flake: String) -> Result<Self> { pub fn open(db_path: &Path, hyperhive_flake: String, dashboard_port: u16) -> Result<Self> {
let broker = Broker::open(db_path).context("open broker")?; let broker = Broker::open(db_path).context("open broker")?;
let approvals = Approvals::open(db_path).context("open approvals")?; let approvals = Approvals::open(db_path).context("open approvals")?;
Ok(Self { Ok(Self {
broker: Arc::new(broker), broker: Arc::new(broker),
approvals: Arc::new(approvals), approvals: Arc::new(approvals),
hyperhive_flake, hyperhive_flake,
dashboard_port,
agents: Mutex::new(HashMap::new()), agents: Mutex::new(HashMap::new()),
transient: Mutex::new(HashMap::new()), transient: Mutex::new(HashMap::new()),
}) })

View file

@ -101,10 +101,11 @@ pub async fn spawn(
proposed_dir: &Path, proposed_dir: &Path,
applied_dir: &Path, applied_dir: &Path,
claude_dir: &Path, claude_dir: &Path,
dashboard_port: u16,
) -> Result<()> { ) -> Result<()> {
validate(name)?; validate(name)?;
setup_proposed(proposed_dir, name).await?; setup_proposed(proposed_dir, name).await?;
setup_applied(applied_dir, name, hyperhive_flake).await?; setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
ensure_claude_dir(claude_dir)?; ensure_claude_dir(claude_dir)?;
let container = container_name(name); let container = container_name(name);
let flake_ref = format!("{}#default", applied_dir.display()); let flake_ref = format!("{}#default", applied_dir.display());
@ -145,9 +146,10 @@ pub async fn rebuild(
agent_dir: &Path, agent_dir: &Path,
applied_dir: &Path, applied_dir: &Path,
claude_dir: &Path, claude_dir: &Path,
dashboard_port: u16,
) -> Result<()> { ) -> Result<()> {
validate(name)?; validate(name)?;
setup_applied(applied_dir, name, hyperhive_flake).await?; setup_applied(applied_dir, name, hyperhive_flake, dashboard_port).await?;
ensure_claude_dir(claude_dir)?; ensure_claude_dir(claude_dir)?;
let container = container_name(name); let container = container_name(name);
let flake_ref = format!("{}#default", applied_dir.display()); let flake_ref = format!("{}#default", applied_dir.display());
@ -205,7 +207,12 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> {
/// Maintain the authoritative applied repo. Rewrites `flake.nix` every call /// Maintain the authoritative applied repo. Rewrites `flake.nix` every call
/// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix` /// (so a new hyperhive flake URL propagates on rebuild); seeds `agent.nix`
/// only on first call. `apply_commit` overwrites `agent.nix` later. /// only on first call. `apply_commit` overwrites `agent.nix` later.
pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str) -> Result<()> { pub async fn setup_applied(
applied_dir: &Path,
name: &str,
hyperhive_flake: &str,
dashboard_port: u16,
) -> Result<()> {
std::fs::create_dir_all(applied_dir) std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?; .with_context(|| format!("create {}", applied_dir.display()))?;
@ -242,6 +249,7 @@ pub async fn setup_applied(applied_dir: &Path, name: &str, hyperhive_flake: &str
systemd.services.{service}.environment = {{ systemd.services.{service}.environment = {{
HIVE_PORT = "{port}"; HIVE_PORT = "{port}";
HIVE_LABEL = "{name}"; HIVE_LABEL = "{name}";
HIVE_DASHBOARD_PORT = "{dashboard_port}";
}}; }};
}} }}
]; ];

View file

@ -85,7 +85,7 @@ async fn main() -> Result<()> {
db, db,
dashboard_port, dashboard_port,
} => { } => {
let coord = Arc::new(Coordinator::open(&db, hyperhive_flake)?); let coord = Arc::new(Coordinator::open(&db, hyperhive_flake, dashboard_port)?);
manager_server::start(coord.clone())?; manager_server::start(coord.clone())?;
// Auto-create the manager container if it isn't there yet. Block // Auto-create the manager container if it isn't there yet. Block
// on this — without hm1nd the system has no manager harness. // on this — without hm1nd the system has no manager harness.

View file

@ -72,6 +72,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
&proposed_dir, &proposed_dir,
&applied_dir, &applied_dir,
&claude_dir, &claude_dir,
coord.dashboard_port,
) )
.await .await
{ {
@ -110,6 +111,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
&agent_dir, &agent_dir,
&applied_dir, &applied_dir,
&claude_dir, &claude_dir,
coord.dashboard_port,
) )
.await?; .await?;
HostResponse::success() HostResponse::success()