Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1c69b134a | ||
|
|
fe8fb15f8f | ||
|
|
40938d8b54 |
5 changed files with 132 additions and 141 deletions
45
TODO.md
45
TODO.md
|
|
@ -57,22 +57,41 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
Repro: manager calls `ask_operator`, tool result is
|
Repro: manager calls `ask_operator`, tool result is
|
||||||
`question queued (id=N)` (so the row is in sqlite), but the
|
`question queued (id=N)` (so the row is in sqlite), but the
|
||||||
M1ND H4S QU3STI0NS section keeps showing "no pending
|
M1ND H4S QU3STI0NS section keeps showing "no pending
|
||||||
questions". Last seen with id=5. Suspected paths:
|
questions". Last seen with id=5. Diagnostic step landed:
|
||||||
- `OperatorQuestions::pending()` returns Err and the
|
`api_state` now warn-logs (target=`api_state`) when any of
|
||||||
`unwrap_or_default()` in `api_state` hides it. Surface the
|
its source queries fail instead of silently
|
||||||
error (warn-log) and check.
|
`unwrap_or_default`-ing — next repro should print the
|
||||||
- serialization: a new field in `OpQuestion` (e.g.
|
underlying error in journald and tell us whether this is
|
||||||
`deadline_at: Option<i64>`) deserializes wrong against an
|
sqlite (likely `OperatorQuestions::pending()` row-decode
|
||||||
old row whose columns don't match the new SELECT order →
|
panic on a migrated column) or dashboard-JS-side
|
||||||
`row.get(N)?` panics for that row, the whole iterator
|
(`renderQuestions` exception). Re-investigate with the new
|
||||||
errors, `pending()` returns Err. Diagnose by curl
|
log once the bug fires.
|
||||||
`/api/state | jq '.questions'` and compare with sqlite
|
|
||||||
counts.
|
|
||||||
- dashboard JS swallows a render error. Open browser console
|
|
||||||
and look for exceptions during `renderQuestions`.
|
|
||||||
|
|
||||||
## UI / UX
|
## UI / UX
|
||||||
|
|
||||||
|
- **Dashboard layout overhaul.** A 3-column attempt (swarm
|
||||||
|
/ 0per4t0r 1n / m3ss4g3s) landed + was reverted in 74ba8a6
|
||||||
|
— looked worse in practice (sticky col-heads fighting the
|
||||||
|
banner, sub-heads too small, columns too narrow for the
|
||||||
|
container rows). Sections are now ordered semantically in
|
||||||
|
a single column (swarm bits first, then decisions, then
|
||||||
|
messages) which is a no-cost improvement. The bigger
|
||||||
|
restructure is still worth doing; next attempt should:
|
||||||
|
- keep current widths usable (don't crunch container
|
||||||
|
rows < ~36em — they have a lot inline)
|
||||||
|
- default the heavy-but-rare sections (kept-state, meta-
|
||||||
|
inputs, msg-flow history) into a collapsed `<details>`
|
||||||
|
so they don't dominate when empty
|
||||||
|
- drop the per-section banner divider lines in favour of
|
||||||
|
something quieter (a single border-top on the h2?)
|
||||||
|
- try a *masonry-ish* layout (CSS `grid-template-rows:
|
||||||
|
masonry` once browsers support it; or just two columns
|
||||||
|
where messages floats on the right at wide viewports
|
||||||
|
while the rest stacks left). avoid sticky headers — they
|
||||||
|
fought the page banner last time.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- **Web UI for config repos + meta deploy log.** Browse
|
- **Web UI for config repos + meta deploy log.** Browse
|
||||||
per-agent proposed / applied tags
|
per-agent proposed / applied tags
|
||||||
(`proposal/* / approved/* / building/* / deployed/* /
|
(`proposal/* / approved/* / building/* / deployed/* /
|
||||||
|
|
|
||||||
|
|
@ -606,6 +606,23 @@
|
||||||
const root = $('approvals-section');
|
const root = $('approvals-section');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
|
// Spawn request form: submitting it queues a Spawn approval that
|
||||||
|
// lands in this same list, so the form belongs here rather than on
|
||||||
|
// the containers list (the agent doesn't exist yet).
|
||||||
|
const spawn = el('form', {
|
||||||
|
method: 'POST', action: '/request-spawn',
|
||||||
|
class: 'spawnform', 'data-async': '',
|
||||||
|
});
|
||||||
|
spawn.append(
|
||||||
|
el('input', {
|
||||||
|
name: 'name',
|
||||||
|
placeholder: 'new agent name (≤9 chars)',
|
||||||
|
maxlength: '9', required: '', autocomplete: 'off',
|
||||||
|
}),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-spawn' }, '◆ R3QU3ST SP4WN'),
|
||||||
|
);
|
||||||
|
root.append(spawn);
|
||||||
|
|
||||||
const history = s.approval_history || [];
|
const history = s.approval_history || [];
|
||||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||||||
const tabs = el('div', { class: 'approval-tabs' });
|
const tabs = el('div', { class: 'approval-tabs' });
|
||||||
|
|
|
||||||
|
|
@ -18,50 +18,11 @@ body {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||||
max-width: 110em;
|
max-width: 70em;
|
||||||
margin: 1.5em auto;
|
margin: 1.5em auto;
|
||||||
padding: 0 1.5em;
|
padding: 0 1.5em;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
.columns {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.4em;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
|
||||||
@media (min-width: 1400px) {
|
|
||||||
.columns {
|
|
||||||
grid-template-columns: 1.1fr 1fr 1fr;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.dash-col {
|
|
||||||
min-width: 0; /* lets grid children shrink instead of overflowing */
|
|
||||||
}
|
|
||||||
.col-head {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
background: rgba(30, 30, 46, 0.92);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
margin: 0 0 0.6em;
|
|
||||||
padding: 0.3em 0.5em;
|
|
||||||
font-size: 1em;
|
|
||||||
border-bottom: 1px solid var(--purple-dim);
|
|
||||||
}
|
|
||||||
.sub-head {
|
|
||||||
color: var(--cyan);
|
|
||||||
font-size: 0.85em;
|
|
||||||
letter-spacing: 0.12em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin: 1.6em 0 0.4em;
|
|
||||||
padding-bottom: 0.15em;
|
|
||||||
border-bottom: 1px dashed var(--border, var(--purple-dim));
|
|
||||||
text-shadow: 0 0 6px rgba(137, 220, 235, 0.35);
|
|
||||||
}
|
|
||||||
.dash-col .sub-head:first-of-type {
|
|
||||||
margin-top: 0.4em;
|
|
||||||
}
|
|
||||||
.banner {
|
.banner {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 0 0 1em 0;
|
margin: 0 0 1em 0;
|
||||||
|
|
@ -91,7 +52,7 @@ h1, h2 {
|
||||||
color: var(--purple);
|
color: var(--purple);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
margin-top: 1em;
|
margin-top: 2em;
|
||||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||||
}
|
}
|
||||||
.divider {
|
.divider {
|
||||||
|
|
|
||||||
|
|
@ -17,83 +17,57 @@
|
||||||
<span id="notif-status" class="meta" hidden></span>
|
<span id="notif-status" class="meta" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns">
|
<!-- swarm: live containers, dormant state, meta input bumps that
|
||||||
<!-- ── SW4RM ─────────────────────────────────────────────────
|
affect the whole swarm. -->
|
||||||
Live + dormant agents, plus the operator-driven knobs that
|
<h2>◆ C0NTAINERS ◆</h2>
|
||||||
change the swarm itself: spawn-new-agent form, meta flake
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
input bumps.
|
<div id="containers-section">
|
||||||
───────────────────────────────────────────────────────────── -->
|
<p class="meta">loading…</p>
|
||||||
<section class="dash-col" aria-labelledby="col-swarm">
|
</div>
|
||||||
<h2 id="col-swarm" class="col-head">◆ SW4RM ◆</h2>
|
|
||||||
|
|
||||||
<h3 class="sub-head">containers</h3>
|
<h2>◆ K3PT ST4T3 ◆</h2>
|
||||||
<div id="containers-section">
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">loading…</p>
|
<div id="tombstones-section">
|
||||||
</div>
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="sub-head">kept state</h3>
|
<h2>◆ M3T4 1NPUTS ◆</h2>
|
||||||
<div id="tombstones-section">
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; manager learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
||||||
</div>
|
<div id="meta-inputs-section">
|
||||||
|
<p class="meta">loading…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="sub-head">spawn agent</h3>
|
<!-- operator decisions: things waiting on you. -->
|
||||||
<form method="POST" action="/request-spawn" class="spawnform" data-async>
|
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
|
||||||
<input name="name"
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
placeholder="new agent name (≤9 chars)"
|
<div id="questions-section">
|
||||||
maxlength="9" required autocomplete="off">
|
<p class="meta">loading…</p>
|
||||||
<button type="submit" class="btn btn-spawn">◆ R3QU3ST SP4WN</button>
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<h3 class="sub-head">update meta inputs</h3>
|
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||||
<p class="meta">checkbox per input; submitting runs <code>nix flake update</code> in <code>/meta/</code> and rebuilds affected agents (sequentially).</p>
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<div id="meta-inputs-section">
|
<div id="approvals-section">
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">loading…</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── 0PER4T0R 1N ───────────────────────────────────────────
|
<!-- messages: broker traffic + the compose box that produces it. -->
|
||||||
Things waiting on the operator to decide — questions the
|
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
|
||||||
manager surfaced via ask_operator and config-change
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
proposals from the manager's request_apply_commit / spawn.
|
<div id="inbox-section">
|
||||||
───────────────────────────────────────────────────────────── -->
|
<p class="meta">loading…</p>
|
||||||
<section class="dash-col" aria-labelledby="col-operator-in">
|
</div>
|
||||||
<h2 id="col-operator-in" class="col-head">◆ 0PER4T0R 1N ◆</h2>
|
|
||||||
|
|
||||||
<h3 class="sub-head">m1nd has questions</h3>
|
<h2>◆ MESS4GE FL0W ◆</h2>
|
||||||
<div id="questions-section">
|
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||||
<p class="meta">loading…</p>
|
<p class="meta">live tail — newest at the top. tap on every <code>send</code> / <code>recv</code> through the broker. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
|
||||||
</div>
|
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
|
||||||
|
<div id="op-compose" class="op-compose">
|
||||||
<h3 class="sub-head">pending approvals</h3>
|
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||||
<div id="approvals-section">
|
<textarea id="op-compose-input" class="op-compose-input"
|
||||||
<p class="meta">loading…</p>
|
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||||
</div>
|
rows="1" autocomplete="off"></textarea>
|
||||||
</section>
|
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||||
|
|
||||||
<!-- ── M3SS4G3S ──────────────────────────────────────────────
|
|
||||||
Broker traffic: the operator's inbox (messages addressed
|
|
||||||
to `operator`) and the live message flow tail. Compose
|
|
||||||
box at the bottom — @name picks the recipient, sticky.
|
|
||||||
───────────────────────────────────────────────────────────── -->
|
|
||||||
<section class="dash-col" aria-labelledby="col-messages">
|
|
||||||
<h2 id="col-messages" class="col-head">◆ M3SS4G3S ◆</h2>
|
|
||||||
|
|
||||||
<h3 class="sub-head">operator inbox</h3>
|
|
||||||
<div id="inbox-section">
|
|
||||||
<p class="meta">loading…</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="sub-head">message flow</h3>
|
|
||||||
<p class="meta">live tail — newest at the top; each row is one broker event. compose below: <code>@name</code> picks the recipient (sticky until you @ someone else); <code>tab</code> completes.</p>
|
|
||||||
<div id="msgflow" class="msgflow"><span class="meta">connecting…</span></div>
|
|
||||||
<div id="op-compose" class="op-compose">
|
|
||||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
|
||||||
<textarea id="op-compose-input" class="op-compose-input"
|
|
||||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
|
||||||
rows="1" autocomplete="off"></textarea>
|
|
||||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,25 @@ struct ApprovalView {
|
||||||
diff_html: Option<String>,
|
diff_html: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace silent `.unwrap_or_default()` on the data sources behind
|
||||||
|
/// `/api/state` so that whichever query degrades surfaces in journald
|
||||||
|
/// instead of leaving the operator staring at an empty list. The
|
||||||
|
/// dashboard still degrades to a sensible default value; the warn
|
||||||
|
/// is just the diagnostic breadcrumb the old code swallowed.
|
||||||
|
fn log_default<T, E>(what: &str, result: std::result::Result<T, E>) -> T
|
||||||
|
where
|
||||||
|
T: Default,
|
||||||
|
E: std::fmt::Debug,
|
||||||
|
{
|
||||||
|
match result {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(target: "api_state", source = %what, error = ?e, "snapshot source failed; using default");
|
||||||
|
T::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
let host = headers
|
let host = headers
|
||||||
.get("host")
|
.get("host")
|
||||||
|
|
@ -246,35 +265,36 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
.unwrap_or("localhost");
|
.unwrap_or("localhost");
|
||||||
let hostname = host.split(':').next().unwrap_or(host).to_owned();
|
let hostname = host.split(':').next().unwrap_or(host).to_owned();
|
||||||
|
|
||||||
let raw_containers = lifecycle::list().await.unwrap_or_default();
|
let raw_containers = log_default("nixos-container list", lifecycle::list().await);
|
||||||
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
|
let current_rev = crate::auto_update::current_flake_rev(&state.coord.hyperhive_flake);
|
||||||
let transient_snapshot = state.coord.transient_snapshot();
|
let transient_snapshot = state.coord.transient_snapshot();
|
||||||
let pending_approvals = gc_orphans(
|
let pending_approvals = gc_orphans(
|
||||||
&state.coord,
|
&state.coord,
|
||||||
state.coord.approvals.pending().unwrap_or_default(),
|
log_default("approvals.pending", state.coord.approvals.pending()),
|
||||||
);
|
);
|
||||||
|
|
||||||
let (containers, any_stale) =
|
let (containers, any_stale) =
|
||||||
build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await;
|
build_container_views(&raw_containers, current_rev.as_deref(), &transient_snapshot).await;
|
||||||
let transients = build_transient_views(&raw_containers, &transient_snapshot);
|
let transients = build_transient_views(&raw_containers, &transient_snapshot);
|
||||||
let approvals = build_approval_views(pending_approvals).await;
|
let approvals = build_approval_views(pending_approvals).await;
|
||||||
let approval_history = state
|
let approval_history = log_default(
|
||||||
.coord
|
"approvals.recent_resolved",
|
||||||
.approvals
|
state.coord.approvals.recent_resolved(30),
|
||||||
.recent_resolved(30)
|
)
|
||||||
.unwrap_or_default()
|
.into_iter()
|
||||||
.into_iter()
|
.map(history_view)
|
||||||
.map(history_view)
|
.collect();
|
||||||
.collect();
|
|
||||||
let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot);
|
let tombstones = build_tombstone_views(&state.coord, &containers, &transient_snapshot);
|
||||||
let port_conflicts = build_port_conflicts(&containers);
|
let port_conflicts = build_port_conflicts(&containers);
|
||||||
|
|
||||||
let operator_inbox = state
|
let operator_inbox = log_default(
|
||||||
.coord
|
"broker.recent_for(operator)",
|
||||||
.broker
|
state
|
||||||
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50)
|
.coord
|
||||||
.unwrap_or_default();
|
.broker
|
||||||
let questions = state.coord.questions.pending().unwrap_or_default();
|
.recent_for(hive_sh4re::OPERATOR_RECIPIENT, 50),
|
||||||
|
);
|
||||||
|
let questions = log_default("questions.pending", state.coord.questions.pending());
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
hostname,
|
hostname,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue