675 lines
26 KiB
Rust
675 lines
26 KiB
Rust
//! Hyperhive dashboard. Lists managed containers (with deep-links to each
|
|
//! container's web UI), pending approvals (with unified diff vs the applied
|
|
//! repo, plus approve/deny buttons), and the manager.
|
|
|
|
use std::convert::Infallible;
|
|
use std::fmt::Write as _;
|
|
use std::net::SocketAddr;
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::{Context, Result};
|
|
use axum::extract::Form;
|
|
use axum::{
|
|
Router,
|
|
extract::{Path as AxumPath, State},
|
|
http::{HeaderMap, StatusCode},
|
|
response::{
|
|
Html, IntoResponse, Redirect, Response,
|
|
sse::{Event, KeepAlive, Sse},
|
|
},
|
|
routing::{get, post},
|
|
};
|
|
use hive_sh4re::{Approval, MANAGER_AGENT, Message};
|
|
use serde::Deserialize;
|
|
use tokio_stream::wrappers::BroadcastStream;
|
|
use tokio_stream::{Stream, StreamExt};
|
|
|
|
use crate::actions;
|
|
use crate::coordinator::Coordinator;
|
|
use crate::lifecycle::{self, AGENT_PREFIX, MANAGER_NAME};
|
|
|
|
const MANAGER_PORT: u16 = 8000;
|
|
|
|
#[derive(Clone)]
|
|
struct AppState {
|
|
coord: Arc<Coordinator>,
|
|
}
|
|
|
|
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))
|
|
.route("/destroy/{name}", post(post_destroy))
|
|
.route("/rebuild/{name}", post(post_rebuild))
|
|
.route("/request-spawn", post(post_request_spawn))
|
|
.route("/send", post(post_send))
|
|
.route("/messages/stream", get(messages_stream))
|
|
.with_state(AppState { coord });
|
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
|
let listener = tokio::net::TcpListener::bind(addr)
|
|
.await
|
|
.with_context(|| format!("bind dashboard on port {port}"))?;
|
|
tracing::info!(%port, "dashboard listening");
|
|
axum::serve(listener, app).await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn index(headers: HeaderMap, State(state): State<AppState>) -> Html<String> {
|
|
let host = headers
|
|
.get("host")
|
|
.and_then(|h| h.to_str().ok())
|
|
.unwrap_or("localhost");
|
|
let hostname = host.split(':').next().unwrap_or(host).to_owned();
|
|
|
|
let containers = lifecycle::list().await.unwrap_or_default();
|
|
let transient = state.coord.transient_snapshot();
|
|
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
|
|
let approvals = gc_orphans(
|
|
&state.coord,
|
|
state.coord.approvals.pending().unwrap_or_default(),
|
|
);
|
|
let approvals_html = render_approvals(&approvals).await;
|
|
|
|
// Auto-refresh the dashboard root while there's a spawn in flight, so the
|
|
// operator sees the new agent show up in the container list without
|
|
// having to reload manually. 2s is a reasonable poll interval for
|
|
// nixos-container create + start, which usually finishes in <30s.
|
|
let refresh = if transient.is_empty() {
|
|
String::new()
|
|
} else {
|
|
"<meta http-equiv=\"refresh\" content=\"2\">".to_owned()
|
|
};
|
|
|
|
Html(format!(
|
|
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<title>hyperhive // h1ve-c0re</title>\n{refresh}\n{STYLE}\n</head>\n<body>\n{BANNER}\n{containers}\n{talk}\n{approvals_html}\n{MSG_FLOW}\n{FOOTER}\n{MSG_FLOW_JS}\n</body>\n</html>\n",
|
|
containers = render_containers(&containers, &transient, current_rev.as_deref(), &hostname),
|
|
talk = render_talk(&containers),
|
|
))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SendForm {
|
|
to: String,
|
|
body: String,
|
|
}
|
|
|
|
async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) -> 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<AppState>,
|
|
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
|
|
let rx = state.coord.broker.subscribe();
|
|
let stream = BroadcastStream::new(rx).filter_map(|res| {
|
|
// Drop lagged events. Browsers reconnect; nothing to do here.
|
|
let event = res.ok()?;
|
|
let json = serde_json::to_string(&event).ok()?;
|
|
Some(Ok(Event::default().data(json)))
|
|
});
|
|
Sse::new(stream).keep_alive(KeepAlive::default())
|
|
}
|
|
|
|
async fn post_approve(State(state): State<AppState>, AxumPath(id): AxumPath<i64>) -> Response {
|
|
match actions::approve(state.coord.clone(), 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:#}")),
|
|
}
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct RequestSpawnForm {
|
|
name: String,
|
|
}
|
|
|
|
async fn post_request_spawn(
|
|
State(state): State<AppState>,
|
|
Form(form): Form<RequestSpawnForm>,
|
|
) -> Response {
|
|
let name = form.name.trim().to_owned();
|
|
if name.is_empty() {
|
|
return error_response("spawn: `name` required");
|
|
}
|
|
match state
|
|
.coord
|
|
.approvals
|
|
.submit_kind(&name, hive_sh4re::ApprovalKind::Spawn, "")
|
|
{
|
|
Ok(id) => {
|
|
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
|
|
Redirect::to("/").into_response()
|
|
}
|
|
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_rebuild(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
let Some(current_rev) = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake)
|
|
else {
|
|
return error_response(
|
|
"rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`",
|
|
);
|
|
};
|
|
let result = crate::auto_update::rebuild_agent(&state.coord, &name, ¤t_rev).await;
|
|
match result {
|
|
Ok(()) => Redirect::to("/").into_response(),
|
|
Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
async fn post_destroy(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
|
match actions::destroy(&state.coord, &name).await {
|
|
Ok(()) => Redirect::to("/").into_response(),
|
|
Err(e) => error_response(&format!("destroy {name} failed: {e:#}")),
|
|
}
|
|
}
|
|
|
|
fn error_response(message: &str) -> 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],
|
|
transient: &std::collections::HashMap<String, crate::coordinator::TransientState>,
|
|
current_rev: Option<&str>,
|
|
hostname: &str,
|
|
) -> String {
|
|
let mut out = String::from(
|
|
"<h2>◆ C0NTAINERS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
|
);
|
|
out.push_str("<form method=\"POST\" action=\"/request-spawn\" class=\"spawnform\">\n <input name=\"name\" placeholder=\"new agent name (≤9 chars)\" maxlength=\"9\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-spawn\">◆ R3QU3ST SP4WN</button>\n</form>\n<p class=\"meta\">spawn requests queue as approvals. operator approves below to actually create the container.</p>\n");
|
|
// Render in-flight spawns first so the operator sees feedback immediately.
|
|
if !transient.is_empty() {
|
|
out.push_str("<ul>\n");
|
|
for (name, state) in transient {
|
|
// Skip names that already exist in `containers` (race: spawn finished
|
|
// between transient set and list refresh).
|
|
if containers.iter().any(|c| c == &format!("h-{name}")) {
|
|
continue;
|
|
}
|
|
let secs = state.since.elapsed().as_secs();
|
|
let label = match state.kind {
|
|
crate::coordinator::TransientKind::Spawning => "spawning…",
|
|
};
|
|
let _ = writeln!(
|
|
out,
|
|
"<li><span class=\"glyph spinner\">◐</span> <span class=\"agent\">{name}</span> <span class=\"role role-pending\">{label}</span> <span class=\"meta\">nixos-container create + start ({secs}s)</span></li>",
|
|
);
|
|
}
|
|
out.push_str("</ul>\n");
|
|
}
|
|
if containers.is_empty() && transient.is_empty() {
|
|
out.push_str("<p class=\"empty\">▓ no managed containers ▓</p>\n");
|
|
return out;
|
|
}
|
|
out.push_str("<ul>\n");
|
|
for container in containers {
|
|
if container == MANAGER_NAME {
|
|
let update_badge = update_badge_for(MANAGER_NAME, current_rev);
|
|
let _ = writeln!(
|
|
out,
|
|
"<li><span class=\"glyph\">▓█▓▒░</span> <a href=\"http://{hostname}:{MANAGER_PORT}/\">{container}</a> <span class=\"role role-m1nd\">m1nd</span>{update_badge} <span class=\"meta\">:{MANAGER_PORT}</span></li>",
|
|
);
|
|
} else if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
|
let port = lifecycle::agent_web_port(name);
|
|
let claude_dir = Coordinator::agent_claude_dir(name);
|
|
let login_badge = if claude_has_session(&claude_dir) {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
" <a class=\"role role-pending\" href=\"http://{hostname}:{port}/\">needs login →</a>",
|
|
)
|
|
};
|
|
let update_badge = update_badge_for(name, current_rev);
|
|
let _ = writeln!(
|
|
out,
|
|
"<li><span class=\"glyph\">▒░▒░░</span> <a href=\"http://{hostname}:{port}/\">{name}</a> <span class=\"role role-ag3nt\">ag3nt</span>{login_badge}{update_badge} <span class=\"meta\">{container} :{port}</span>\n <form method=\"POST\" action=\"/destroy/{name}\" class=\"inline\" onsubmit=\"return confirm('destroy {name}? container is removed; state + creds kept.');\"><button class=\"btn btn-destroy\" type=\"submit\">DESTR0Y</button></form>\n</li>",
|
|
);
|
|
}
|
|
}
|
|
out.push_str("</ul>\n");
|
|
out
|
|
}
|
|
|
|
async fn render_approvals(approvals: &[Approval]) -> String {
|
|
let mut out = String::from(
|
|
"<h2>◆ P3NDING APPR0VALS ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n",
|
|
);
|
|
if approvals.is_empty() {
|
|
out.push_str("<p class=\"empty\">▓ queue empty ▓</p>\n");
|
|
return out;
|
|
}
|
|
out.push_str("<ul class=\"approvals\">\n");
|
|
for a in approvals {
|
|
match a.kind {
|
|
hive_sh4re::ApprovalKind::ApplyCommit => {
|
|
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>\n <div class=\"row\"><span class=\"glyph\">→</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind\">apply</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),
|
|
);
|
|
}
|
|
hive_sh4re::ApprovalKind::Spawn => {
|
|
let _ = writeln!(
|
|
out,
|
|
"<li>\n <div class=\"row\"><span class=\"glyph\">⊕</span> <span class=\"id\">#{id}</span> <span class=\"agent\">{agent}</span> <span class=\"kind kind-spawn\">spawn</span> <span class=\"meta\">new sub-agent — container will be created on approve</span>\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</li>",
|
|
id = a.id,
|
|
agent = a.agent,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
out.push_str("</ul>\n");
|
|
out
|
|
}
|
|
|
|
fn render_talk(containers: &[String]) -> String {
|
|
let mut options = String::new();
|
|
let _ = writeln!(
|
|
options,
|
|
"<option value=\"{MANAGER_AGENT}\">manager (hm1nd)</option>",
|
|
);
|
|
for container in containers {
|
|
if container == MANAGER_NAME {
|
|
continue;
|
|
}
|
|
if let Some(name) = container.strip_prefix(AGENT_PREFIX) {
|
|
let _ = writeln!(options, "<option value=\"{name}\">{name}</option>");
|
|
}
|
|
}
|
|
format!(
|
|
"<h2>◆ T4LK ◆</h2>\n<div class=\"divider\">══════════════════════════════════════════════════════════════</div>\n<form method=\"POST\" action=\"/send\" class=\"talkform\">\n <select name=\"to\" required>{options}</select>\n <input name=\"body\" placeholder=\"message body...\" required autocomplete=\"off\">\n <button type=\"submit\" class=\"btn btn-talk\">◆ S3ND</button>\n</form>\n<p class=\"meta\">sends as <code>from: operator</code>. Replies stream into the message panel below.</p>\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.
|
|
fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<Approval> {
|
|
approvals
|
|
.into_iter()
|
|
.filter(|a| {
|
|
// Spawn approvals are for not-yet-existent agents; the proposed
|
|
// dir is supposed to be missing.
|
|
if matches!(a.kind, hive_sh4re::ApprovalKind::Spawn) {
|
|
return true;
|
|
}
|
|
if Coordinator::agent_proposed_dir(&a.agent).exists() {
|
|
true
|
|
} else {
|
|
let _ = coord.approvals.mark_failed(a.id, "agent state dir missing");
|
|
tracing::info!(id = a.id, agent = %a.agent, "auto-failed orphan approval");
|
|
false
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Returns either an empty string (agent is up-to-date / no rev known) or
|
|
/// a clickable "needs update" badge whose form POSTs to /rebuild/<name>.
|
|
fn update_badge_for(name: &str, current_rev: Option<&str>) -> String {
|
|
let Some(rev) = current_rev else {
|
|
return String::new();
|
|
};
|
|
if !crate::auto_update::agent_needs_update(name, rev) {
|
|
return String::new();
|
|
}
|
|
format!(
|
|
" <form method=\"POST\" action=\"/rebuild/{name}\" class=\"inline\" onsubmit=\"return confirm('rebuild {name}? hot-reloads the container.');\"><button class=\"role role-pending btn-inline\" type=\"submit\" title=\"agent's last build is older than current hyperhive rev\">needs update ↻</button></form>",
|
|
)
|
|
}
|
|
|
|
/// Host-side mirror of `hive_ag3nt::login::has_session`. Returns true if the
|
|
/// agent's bound `~/.claude/` dir on disk contains any regular file. The
|
|
/// dashboard reads this each render so logins driven from the agent web UI
|
|
/// (Phase 8 step 4) reflect within one auto-refresh cycle.
|
|
fn claude_has_session(dir: &Path) -> bool {
|
|
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
return false;
|
|
};
|
|
entries
|
|
.flatten()
|
|
.any(|e| e.file_type().is_ok_and(|t| t.is_file()))
|
|
}
|
|
|
|
async fn approval_diff(agent: &str, commit_ref: &str) -> String {
|
|
let proposed = Coordinator::agent_proposed_dir(agent);
|
|
if !proposed.exists() {
|
|
return format!(
|
|
"(proposed dir {} does not exist — agent destroyed?)",
|
|
proposed.display()
|
|
);
|
|
}
|
|
if !proposed.join(".git").exists() {
|
|
return format!("(no git repo at {})", proposed.display());
|
|
}
|
|
let applied = Coordinator::agent_applied_dir(agent).join("agent.nix");
|
|
let applied_text = std::fs::read_to_string(&applied).unwrap_or_default();
|
|
match git_show(&proposed, commit_ref).await {
|
|
Ok(s) => unified_diff(&applied_text, &s),
|
|
Err(e) => format!("(error: {e:#})"),
|
|
}
|
|
}
|
|
|
|
async fn git_show(proposed_dir: &Path, commit_ref: &str) -> Result<String> {
|
|
let out = lifecycle::git_command()
|
|
.current_dir(proposed_dir)
|
|
.args(["show", &format!("{commit_ref}:agent.nix")])
|
|
.output()
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"spawn `git show` in {} (HYPERHIVE_GIT={})",
|
|
proposed_dir.display(),
|
|
std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "<unset>".into()),
|
|
)
|
|
})?;
|
|
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>"#;
|
|
|
|
const MSG_FLOW: &str = r#"<h2>◆ MESS4GE FL0W ◆</h2>
|
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker.</p>
|
|
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>"#;
|
|
|
|
const MSG_FLOW_JS: &str = r#"<script>
|
|
(() => {
|
|
const flow = document.getElementById('msgflow');
|
|
if (!flow) return;
|
|
flow.innerHTML = '';
|
|
const es = new EventSource('/messages/stream');
|
|
const MAX_ROWS = 200;
|
|
const tsFmt = (n) => new Date(n * 1000).toISOString().slice(11, 19);
|
|
const esc = (s) => s.replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
es.onmessage = (e) => {
|
|
let m;
|
|
try { m = JSON.parse(e.data); } catch { return; }
|
|
const row = document.createElement('div');
|
|
row.className = 'msgrow ' + m.kind;
|
|
const kind = m.kind === 'sent' ? '→' : '✓';
|
|
row.innerHTML =
|
|
'<span class="msg-ts">' + tsFmt(m.at) + '</span>' +
|
|
'<span class="msg-arrow">' + kind + '</span>' +
|
|
'<span class="msg-from">' + esc(m.from) + '</span>' +
|
|
'<span class="msg-sep">→</span>' +
|
|
'<span class="msg-to">' + esc(m.to) + '</span>' +
|
|
'<span class="msg-body">' + esc(m.body) + '</span>';
|
|
flow.insertBefore(row, flow.firstChild);
|
|
while (flow.childNodes.length > MAX_ROWS) flow.removeChild(flow.lastChild);
|
|
};
|
|
es.onerror = () => {
|
|
flow.insertBefore(Object.assign(document.createElement('div'), {
|
|
className: 'msgrow meta', textContent: '[connection lost — retrying]'
|
|
}), flow.firstChild);
|
|
};
|
|
})();
|
|
</script>"#;
|
|
|
|
const FOOTER: &str = r#"<footer>
|
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
|
<p>▲△▲ <a href="https://git.berlin.ccc.de/vinzenz/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
|
</footer>"#;
|
|
|
|
const STYLE: &str = r#"
|
|
<style>
|
|
:root {
|
|
--bg: #0a0014;
|
|
--bg-elev: #18002a;
|
|
--fg: #e0d4ff;
|
|
--muted: #6c5c8c;
|
|
--purple: #cc66ff;
|
|
--purple-dim: #4a1a6a;
|
|
--cyan: #00ffff;
|
|
--pink: #ff3399;
|
|
--amber: #ffaa00;
|
|
--green: #00ff88;
|
|
--red: #ff4466;
|
|
--border: #2a0a4a;
|
|
}
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--fg);
|
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
max-width: 70em;
|
|
margin: 1.5em auto;
|
|
padding: 0 1.5em;
|
|
line-height: 1.6;
|
|
}
|
|
.banner {
|
|
color: var(--purple);
|
|
text-align: center;
|
|
margin: 0 0 1em 0;
|
|
font-size: 0.95em;
|
|
text-shadow: 0 0 6px rgba(204, 102, 255, 0.5);
|
|
overflow-x: auto;
|
|
}
|
|
h1, h2 {
|
|
color: var(--purple);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.15em;
|
|
margin-top: 2em;
|
|
text-shadow: 0 0 8px rgba(204, 102, 255, 0.4);
|
|
}
|
|
.divider {
|
|
color: var(--purple-dim);
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
margin-bottom: 0.5em;
|
|
}
|
|
ul { list-style: none; padding-left: 0; }
|
|
li { padding: 0.5em 0; }
|
|
.glyph { color: var(--purple); margin-right: 0.5em; }
|
|
a {
|
|
color: var(--cyan);
|
|
text-decoration: none;
|
|
text-shadow: 0 0 4px rgba(0, 255, 255, 0.5);
|
|
font-weight: bold;
|
|
}
|
|
a:hover {
|
|
color: #fff;
|
|
text-shadow: 0 0 12px rgba(0, 255, 255, 0.9);
|
|
}
|
|
.role {
|
|
display: inline-block;
|
|
margin-left: 0.4em;
|
|
padding: 0.05em 0.5em;
|
|
border: 1px solid;
|
|
border-radius: 2px;
|
|
font-size: 0.8em;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
}
|
|
.role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(255, 51, 153, 0.1); }
|
|
.role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(255, 170, 0, 0.1); }
|
|
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
|
|
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
|
|
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
|
|
.empty { color: var(--muted); font-style: italic; }
|
|
code {
|
|
color: var(--amber);
|
|
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; }
|
|
ul form.inline { display: inline-block; }
|
|
.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); }
|
|
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
|
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
|
|
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
|
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
|
.spawnform input {
|
|
font-family: inherit;
|
|
font-size: 1em;
|
|
background: var(--bg-elev);
|
|
color: var(--fg);
|
|
border: 1px solid var(--border);
|
|
padding: 0.4em 0.6em;
|
|
flex: 1;
|
|
}
|
|
.spawnform input::placeholder { color: var(--muted); }
|
|
.spawnform input:focus { outline: 1px solid var(--purple); }
|
|
.role-pending { color: var(--amber); border-color: var(--amber); }
|
|
.btn-inline {
|
|
font-family: inherit;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
margin-left: 0.4em;
|
|
}
|
|
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
|
|
.kind {
|
|
display: inline-block;
|
|
margin-left: 0.4em;
|
|
padding: 0.05em 0.5em;
|
|
border: 1px solid var(--purple-dim);
|
|
color: var(--purple-dim);
|
|
border-radius: 2px;
|
|
font-size: 0.75em;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
}
|
|
.kind-spawn { color: var(--amber); border-color: var(--amber); }
|
|
.spinner {
|
|
display: inline-block;
|
|
animation: spin 1s linear infinite;
|
|
color: var(--amber);
|
|
}
|
|
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
|
.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;
|
|
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;
|
|
}
|
|
.msgflow {
|
|
background: var(--bg-elev);
|
|
border: 1px solid var(--border);
|
|
padding: 0.8em;
|
|
font-size: 0.85em;
|
|
line-height: 1.5;
|
|
max-height: 32em;
|
|
overflow-y: auto;
|
|
}
|
|
.msgrow { display: grid; grid-template-columns: auto auto auto auto auto 1fr; gap: 0.6em; align-items: baseline; padding: 0.1em 0; }
|
|
.msgrow.sent .msg-arrow { color: var(--cyan); }
|
|
.msgrow.delivered .msg-arrow { color: var(--green); }
|
|
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
|
.msg-arrow { font-weight: bold; }
|
|
.msg-from { color: var(--amber); }
|
|
.msg-sep { color: var(--muted); }
|
|
.msg-to { color: var(--pink); }
|
|
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
|
footer {
|
|
margin-top: 4em;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
font-size: 0.9em;
|
|
}
|
|
footer a { color: var(--purple); }
|
|
</style>
|
|
"#;
|