Phase 7a: dashboard approve/deny + unified diff (similar crate)
This commit is contained in:
parent
75df5f6c29
commit
c82d41728c
6 changed files with 181 additions and 35 deletions
|
|
@ -22,6 +22,7 @@ hive-sh4re = { path = "hive-sh4re" }
|
|||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
similar = "2"
|
||||
tokio = { version = "1", features = [
|
||||
"io-util",
|
||||
"macros",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ hive-sh4re.workspace = true
|
|||
rusqlite.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
similar.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
45
hive-c0re/src/actions.rs
Normal file
45
hive-c0re/src/actions.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//! Operations that are exposed through more than one surface (the host admin
|
||||
//! socket *and* the dashboard's POST endpoints). Each function takes a
|
||||
//! `&Coordinator` and the request parameters; callers stitch the response
|
||||
//! shape they want (HTTP redirect vs JSON).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle;
|
||||
|
||||
/// Approve a pending request: read the agent.nix at the approval's commit from
|
||||
/// the proposed repo, copy into the applied repo, commit there, and rebuild
|
||||
/// the agent container. On failure marks the approval failed (with the error
|
||||
/// note) and returns the error.
|
||||
pub async fn approve(coord: &Coordinator, id: i64) -> Result<()> {
|
||||
let approval = coord.approvals.mark_approved(id)?;
|
||||
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding");
|
||||
|
||||
let agent_dir = coord.register_agent(&approval.agent)?;
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||
let result: Result<()> = async {
|
||||
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref).await?;
|
||||
lifecycle::rebuild(
|
||||
&approval.agent,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(approval.id, ¬e);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deny(coord: &Coordinator, id: i64) -> Result<()> {
|
||||
coord.approvals.mark_denied(id)?;
|
||||
tracing::info!(%id, "approval denied");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
//! Hyperhive dashboard. Lists managed containers (with deep-links to each
|
||||
//! container's web UI), pending approvals, and the manager.
|
||||
//! container's web UI), pending approvals (with unified diff vs the applied
|
||||
//! repo, plus approve/deny buttons), and the manager.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use axum::{Router, extract::State, http::HeaderMap, response::Html, routing::get};
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Path as AxumPath, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
use hive_sh4re::Approval;
|
||||
use tokio::process::Command;
|
||||
|
||||
use crate::actions;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
||||
|
||||
|
|
@ -22,6 +32,8 @@ struct AppState {
|
|||
pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/approve/{id}", post(post_approve))
|
||||
.route("/deny/{id}", post(post_deny))
|
||||
.with_state(AppState { coord });
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
|
|
@ -41,17 +53,42 @@ async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String
|
|||
|
||||
let containers = lifecycle::list().await.unwrap_or_default();
|
||||
let approvals = state.coord.approvals.pending().unwrap_or_default();
|
||||
let approvals_html = render_approvals(&approvals).await;
|
||||
|
||||
Html(format!(
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{style}\n</head>\n<body>\n{banner}\n{containers}\n{approvals}\n{footer}\n</body>\n</html>\n",
|
||||
style = STYLE,
|
||||
banner = BANNER,
|
||||
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{approvals_html}\n{FOOTER}\n</body>\n</html>\n",
|
||||
containers = render_containers(&containers, &hostname),
|
||||
approvals = render_approvals(&approvals),
|
||||
footer = FOOTER,
|
||||
))
|
||||
}
|
||||
|
||||
async fn post_approve(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> Response {
|
||||
match actions::approve(&state.coord, id).await {
|
||||
Ok(()) => Redirect::to("/").into_response(),
|
||||
Err(e) => error_response(format!("approve {id} failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_deny(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
||||
match actions::deny(&state.coord, id) {
|
||||
Ok(()) => Redirect::to("/").into_response(),
|
||||
Err(e) => error_response(format!("deny {id} failed: {e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(message: String) -> Response {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Html(format!(
|
||||
"<!doctype html>\n<html>\n<head>{STYLE}</head>\n<body>{BANNER}\n<h2>◆ ERR0R ◆</h2>\n<pre class=\"diff\">{message}</pre>\n<p><a href=\"/\">← back</a></p>\n</body></html>",
|
||||
message = html_escape(&message),
|
||||
)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn render_containers(containers: &[String], hostname: &str) -> String {
|
||||
let mut out = String::from(
|
||||
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
||||
|
|
@ -79,7 +116,7 @@ fn render_containers(containers: &[String], hostname: &str) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
fn render_approvals(approvals: &[Approval]) -> String {
|
||||
async fn render_approvals(approvals: &[Approval]) -> String {
|
||||
let mut out = String::from(
|
||||
"<h2>◆ P3NDING APPR0VALS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
||||
);
|
||||
|
|
@ -87,20 +124,63 @@ fn render_approvals(approvals: &[Approval]) -> String {
|
|||
out.push_str("<p class=\"empty\">▓ queue empty ▓</p>\n");
|
||||
return out;
|
||||
}
|
||||
out.push_str("<ul>\n");
|
||||
out.push_str("<ul class=\"approvals\">\n");
|
||||
for a in approvals {
|
||||
let sha_short = &a.commit_ref[..a.commit_ref.len().min(12)];
|
||||
let diff = approval_diff(&a.agent, &a.commit_ref).await;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"<li><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code> <span class=\"meta\">approve via <code>hive-c0re approve {id}</code></span></li>",
|
||||
"<li>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <code>{sha_short}</code>\n <form method=\"POST\" action=\"/approve/{id}\" class=\"inline\"><button class=\"btn btn-approve\" type=\"submit\">◆ APPR0VE</button></form>\n <form method=\"POST\" action=\"/deny/{id}\" class=\"inline\"><button class=\"btn btn-deny\" type=\"submit\">DENY</button></form>\n </div>\n <details><summary>diff vs applied</summary><pre class=\"diff\">{diff}</pre></details>\n</li>",
|
||||
id = a.id,
|
||||
agent = a.agent,
|
||||
diff = html_escape(&diff),
|
||||
);
|
||||
}
|
||||
out.push_str("</ul>\n");
|
||||
out
|
||||
}
|
||||
|
||||
async fn approval_diff(agent: &str, commit_ref: &str) -> String {
|
||||
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix");
|
||||
let proposed = Coordinator::agent_proposed_dir(agent);
|
||||
let applied_text = std::fs::read_to_string(&applied).unwrap_or_default();
|
||||
let proposed_text = match git_show(&proposed, commit_ref).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => return format!("(error reading proposed agent.nix: {e:#})"),
|
||||
};
|
||||
unified_diff(&applied_text, &proposed_text)
|
||||
}
|
||||
|
||||
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(proposed_dir)
|
||||
.args(["show", &format!("{commit_ref}:agent.nix")])
|
||||
.output()
|
||||
.await
|
||||
.context("git show")?;
|
||||
if !out.status.success() {
|
||||
anyhow::bail!(
|
||||
"git show {commit_ref}:agent.nix failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn unified_diff(applied: &str, proposed: &str) -> String {
|
||||
let diff = similar::TextDiff::from_lines(applied, proposed);
|
||||
diff.unified_diff()
|
||||
.context_radius(3)
|
||||
.header("applied", "proposed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
|
||||
const BANNER: &str = r#"<pre class="banner">
|
||||
░▒▓█▓▒░ HYPERHIVE ░▒▓█▓▒░ HIVE-C0RE ░▒▓█▓▒░ WE ARE THE WIRED ░▒▓█▓▒░
|
||||
</pre>"#;
|
||||
|
|
@ -114,6 +194,7 @@ const STYLE: &str = r#"
|
|||
<style>
|
||||
:root {
|
||||
--bg: #0a0014;
|
||||
--bg-elev: #18002a;
|
||||
--fg: #e0d4ff;
|
||||
--muted: #6c5c8c;
|
||||
--purple: #cc66ff;
|
||||
|
|
@ -121,6 +202,8 @@ const STYLE: &str = r#"
|
|||
--cyan: #00ffff;
|
||||
--pink: #ff3399;
|
||||
--amber: #ffaa00;
|
||||
--green: #00ff88;
|
||||
--red: #ff4466;
|
||||
--border: #2a0a4a;
|
||||
}
|
||||
body {
|
||||
|
|
@ -184,12 +267,48 @@ const STYLE: &str = r#"
|
|||
.empty { color: var(--muted); font-style: italic; }
|
||||
code {
|
||||
color: var(--amber);
|
||||
background: #18002a;
|
||||
background: var(--bg-elev);
|
||||
padding: 0.1em 0.4em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.approvals .row { display: flex; align-items: center; flex-wrap: wrap; gap: 0.4em; }
|
||||
.approvals form.inline { display: inline; margin-left: 0.4em; }
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
background: transparent;
|
||||
border: 1px solid;
|
||||
padding: 0.25em 0.8em;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
.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); }
|
||||
details { margin-top: 0.5em; }
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
summary:hover { color: var(--purple); }
|
||||
.diff {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.8em;
|
||||
margin-top: 0.4em;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
color: var(--fg);
|
||||
white-space: pre;
|
||||
}
|
||||
footer {
|
||||
margin-top: 4em;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use anyhow::{Result, bail};
|
|||
use clap::{Parser, Subcommand};
|
||||
use hive_sh4re::{HostRequest, HostResponse};
|
||||
|
||||
mod actions;
|
||||
mod agent_server;
|
||||
mod approvals;
|
||||
mod broker;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use hive_sh4re::{HostRequest, HostResponse};
|
|||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
||||
use crate::actions;
|
||||
use crate::coordinator::Coordinator;
|
||||
use crate::lifecycle;
|
||||
|
||||
|
|
@ -94,33 +95,11 @@ async fn dispatch(req: &HostRequest, coord: &Coordinator) -> HostResponse {
|
|||
HostRequest::List => HostResponse::list(lifecycle::list().await?),
|
||||
HostRequest::Pending => HostResponse::pending(coord.approvals.pending()?),
|
||||
HostRequest::Approve { id } => {
|
||||
let approval = coord.approvals.mark_approved(*id)?;
|
||||
tracing::info!(%approval.id, %approval.agent, %approval.commit_ref, "approval: applying + rebuilding");
|
||||
let agent_dir = coord.register_agent(&approval.agent)?;
|
||||
let proposed_dir = Coordinator::agent_proposed_dir(&approval.agent);
|
||||
let applied_dir = Coordinator::agent_applied_dir(&approval.agent);
|
||||
let result: anyhow::Result<()> = async {
|
||||
lifecycle::apply_commit(&applied_dir, &proposed_dir, &approval.commit_ref)
|
||||
.await?;
|
||||
lifecycle::rebuild(
|
||||
&approval.agent,
|
||||
&coord.hyperhive_flake,
|
||||
&agent_dir,
|
||||
&applied_dir,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
if let Err(e) = result {
|
||||
let note = format!("{e:#}");
|
||||
let _ = coord.approvals.mark_failed(approval.id, ¬e);
|
||||
return Err(e);
|
||||
}
|
||||
actions::approve(coord, *id).await?;
|
||||
HostResponse::success()
|
||||
}
|
||||
HostRequest::Deny { id } => {
|
||||
coord.approvals.mark_denied(*id)?;
|
||||
tracing::info!(%id, "approval denied");
|
||||
actions::deny(coord, *id)?;
|
||||
HostResponse::success()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue