frontend: cut over Rust binaries to ServeDir; delete legacy assets
Phase 4 of #273 — the actual switch. Both axum routers now serve their static surface via `tower_http::services::ServeDir` mounted as a fallback service, reading the dist path from `HIVE_STATIC_DIR` (set by Phase 3's NixOS module wiring). Deletes: - `hive-c0re/assets/{index.html, app.js, dashboard.css}` - `hive-ag3nt/assets/{index.html, app.js, agent.css, stats.html, stats.js, screen.html}` - The whole `hive-fr0nt/` crate (workspace member dropped, both hive-c0re and hive-ag3nt drop their `hive-fr0nt.workspace = true` dep). Its contents now live as `@hive/shared` under `frontend/packages/shared/`. Rust changes: - `hive-c0re/src/dashboard.rs`: remove `serve_index`, `serve_css`, `serve_app_js`, `serve_shared_js`, `serve_marked_js`, `serve_favicon` (all six `include_str!` handlers); replace their routes with a single `.fallback_service(ServeDir::new(static_dir))` on the router. Fail closed (anyhow::bail) if `HIVE_STATIC_DIR` is unset or not a directory at startup. - `hive-ag3nt/src/web_ui.rs`: remove `serve_index`, `serve_css`, `serve_app_js`, `serve_shared_js`, `serve_marked_js`, `serve_stats`, `serve_stats_js`, `serve_screen`; same `fallback_service` pattern. `serve_icon` stays (consumes `/etc/hyperhive/icon.svg` + `branding/hyperhive.svg` fallback, neither of which lives under the frontend dist). - `AgentLink` URLs for stats/screen switched from `/stats` / `/screen` to `/stats.html` / `/screen.html` since ServeDir doesn't auto- append the extension and the on-disk filename is the natural URL post-cutover. - `Cargo.toml` (workspace): drop `hive-fr0nt` member + workspace dep, add `tower-http = { version = "0.6", features = ["fs"] }`. - `hive-c0re/Cargo.toml` + `hive-ag3nt/Cargo.toml`: drop the `hive-fr0nt.workspace = true` dep, add `tower-http.workspace = true`. Docs updated: - `CLAUDE.md`: file map reflects `frontend/` (was `hive-fr0nt/` + `assets/`) and the ServeDir/HIVE_STATIC_DIR shape. - `docs/web-ui.md` 'Shape (shared by both)' section: describes the ServeDir fallback + bundled-by-esbuild surface, no more `include_str!` references. - `docs/terminal-rendering.md`: src paths point at `frontend/packages/{agent,shared}/src/`; marked is the npm dep, not vendored UMD. Validation: - `cargo check --workspace` — clean (5 warnings, all pre-existing in `rebuild_queue.rs`, none on changed files). - `cargo clippy --workspace --all-targets` — clean (11 warnings, same pre-existing source). - `cd frontend && npm run build` from the prior commit's lockfile produces the dist directories the new routers consume: dashboard: `dist/{index.html, static/{app.js, dashboard.css}}` agent: `dist/{index.html, stats.html, screen.html, static/{app.js, stats.js, agent.css}}` (favicon.svg lands in dashboard/ during the nix build — `nix/frontend.nix` install phase copies `branding/hyperhive.svg` there, since it's outside the npm tree.) Refs #273.
This commit is contained in:
parent
2ecf15bb6f
commit
229c4292e9
24 changed files with 143 additions and 10122 deletions
|
|
@ -12,7 +12,6 @@ axum.workspace = true
|
|||
reqwest.workspace = true
|
||||
futures-util = "0.3"
|
||||
clap.workspace = true
|
||||
hive-fr0nt.workspace = true
|
||||
hive-sh4re.workspace = true
|
||||
rmcp.workspace = true
|
||||
rusqlite.workspace = true
|
||||
|
|
@ -21,6 +20,7 @@ serde.workspace = true
|
|||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1,386 +0,0 @@
|
|||
/* Palette + base body typography live in hive-fr0nt::BASE_CSS, prepended
|
||||
to this stylesheet by `serve_css` at runtime. */
|
||||
body {
|
||||
max-width: 110em;
|
||||
margin: 1.5em auto;
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
.banner {
|
||||
text-align: center;
|
||||
margin: 0 0 1em 0;
|
||||
font-size: 0.95em;
|
||||
overflow-x: auto;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--purple-dim) 0%,
|
||||
var(--purple) 50%,
|
||||
var(--purple-dim) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-position: 50% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 6px rgba(203, 166, 247, 0.45));
|
||||
}
|
||||
.banner.active {
|
||||
animation: banner-shimmer 1.8s linear infinite;
|
||||
}
|
||||
@keyframes banner-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -100% 0; }
|
||||
}
|
||||
h2, h3 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
text-shadow: 0 0 8px rgba(203, 166, 247, 0.4);
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.title-row h2 { margin: 0; }
|
||||
.agent-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 0.85em; }
|
||||
.status-online { color: var(--green); text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); }
|
||||
code { background: rgba(203, 166, 247, 0.12); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 4px rgba(137, 220, 235, 0.5);
|
||||
}
|
||||
a:hover { color: var(--fg); text-shadow: 0 0 12px rgba(137, 220, 235, 0.9); }
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--purple);
|
||||
color: var(--purple);
|
||||
padding: 0.25em 0.8em;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
.btn {
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: rgba(205, 214, 244, 0.06);
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-login { color: var(--amber); border-color: var(--amber); }
|
||||
.btn-cancel { color: var(--red); border-color: var(--red); 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(250, 179, 135, 0.1); }
|
||||
.btn-send { color: var(--green); border-color: var(--green); }
|
||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.sendform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
flex: 1;
|
||||
}
|
||||
.sendform input:focus { outline: 1px solid var(--purple); }
|
||||
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.loginform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
flex: 1;
|
||||
}
|
||||
.loginform input:focus { outline: 1px solid var(--purple); }
|
||||
pre.diff {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.6em 0.8em;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 30em;
|
||||
}
|
||||
#state-row {
|
||||
margin: 0.4em 0 0.2em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6em;
|
||||
}
|
||||
/* Per-agent inbox section — collapsible, dim, lives between the
|
||||
state row and the terminal so the operator can peek at what
|
||||
landed without scrolling through the live tail. */
|
||||
.agent-inbox {
|
||||
margin: 0.4em 0;
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.agent-inbox > summary {
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.05em;
|
||||
list-style: none;
|
||||
}
|
||||
.agent-inbox > summary::marker { content: ''; }
|
||||
.agent-inbox[open] > summary > span::before { content: ''; }
|
||||
.agent-inbox ul {
|
||||
list-style: none;
|
||||
padding: 0.4em 0.8em;
|
||||
margin: 0.3em 0 0;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-left: 2px solid var(--purple-dim);
|
||||
max-height: 16em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.agent-inbox li {
|
||||
padding: 0.15em 0;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; }
|
||||
.agent-inbox .inbox-from { color: var(--amber); }
|
||||
.agent-inbox .inbox-sep { color: var(--muted); }
|
||||
.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||
.agent-inbox li.inbox-reply {
|
||||
padding-left: 1em;
|
||||
border-left: 2px solid var(--border);
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
|
||||
|
||||
.agent-inbox .answer-form {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
align-items: flex-start;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
.agent-inbox .answer-form textarea {
|
||||
flex: 1;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.3em;
|
||||
resize: vertical;
|
||||
}
|
||||
.agent-inbox .answer-form button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0.3em 0.7em;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.agent-inbox .answer-form button:hover:not(:disabled) {
|
||||
border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
}
|
||||
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
||||
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
||||
|
||||
.last-turn {
|
||||
color: var(--muted);
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.model-chip {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.6em;
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 999px;
|
||||
color: var(--cyan);
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
|
||||
chip — single primary number (total prompt tokens in use), full
|
||||
breakdown on hover. Sized/coloured like a peer of model-chip so
|
||||
the state row reads as one row of chrome. */
|
||||
.ctx-badge {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.6em;
|
||||
border: 1px solid var(--purple-dim);
|
||||
border-radius: 999px;
|
||||
color: var(--green);
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.04em;
|
||||
cursor: default;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Harness reachability badge. Same chip shape + sizing as
|
||||
`.state-badge` / `.model-chip` so the state row stays visually
|
||||
uniform; colour communicates the actual reachability state. */
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.8em;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
|
||||
.status-badge.status-online { color: var(--green); border-color: var(--green);
|
||||
text-shadow: 0 0 6px rgba(166, 227, 161, 0.55); }
|
||||
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
|
||||
text-shadow: 0 0 6px rgba(243, 139, 168, 0.55); }
|
||||
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
|
||||
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
||||
.btn-dashlink {
|
||||
color: var(--cyan);
|
||||
border: 1px solid var(--cyan);
|
||||
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;
|
||||
}
|
||||
.btn-dashlink:hover {
|
||||
background: rgba(137, 220, 235, 0.1);
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-cancel-turn {
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.08em;
|
||||
background: transparent;
|
||||
color: var(--red);
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 999px;
|
||||
padding: 0.2em 0.8em;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.btn-cancel-turn:hover {
|
||||
background: rgba(243, 139, 168, 0.1);
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-new-session {
|
||||
font-family: inherit;
|
||||
font-size: 0.8em;
|
||||
letter-spacing: 0.08em;
|
||||
background: transparent;
|
||||
color: var(--amber);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 999px;
|
||||
padding: 0.2em 0.8em;
|
||||
cursor: pointer;
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.btn-new-session:hover {
|
||||
background: rgba(250, 179, 135, 0.1);
|
||||
box-shadow: 0 0 10px -2px currentColor;
|
||||
}
|
||||
.btn-new-session:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: progress;
|
||||
}
|
||||
.state-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25em 0.8em;
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85em;
|
||||
letter-spacing: 0.05em;
|
||||
transition: color 280ms ease, border-color 280ms ease,
|
||||
box-shadow 280ms ease, background 280ms ease;
|
||||
}
|
||||
.state-badge.state-loading {
|
||||
color: var(--muted); border-color: var(--purple-dim);
|
||||
}
|
||||
.state-badge.state-offline {
|
||||
color: var(--muted); border-color: var(--muted);
|
||||
}
|
||||
.state-badge.state-idle {
|
||||
color: var(--cyan); border-color: var(--cyan);
|
||||
text-shadow: 0 0 6px rgba(137, 220, 235, 0.55);
|
||||
}
|
||||
.state-badge.state-thinking {
|
||||
color: var(--amber); border-color: var(--amber);
|
||||
text-shadow: 0 0 6px rgba(250, 179, 135, 0.65);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.state-badge.state-compacting {
|
||||
color: var(--purple); border-color: var(--purple);
|
||||
text-shadow: 0 0 6px rgba(203, 166, 247, 0.65);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.state-badge.state-just-changed {
|
||||
animation: state-flash 600ms ease-out;
|
||||
}
|
||||
@keyframes state-flash {
|
||||
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
|
||||
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
|
||||
}
|
||||
/* `.terminal-wrap`, `.live`, `.live.terminal`, row + pill + details
|
||||
styling all live in hive-fr0nt::TERMINAL_CSS (prepended by serve_css).
|
||||
What stays here is the composer chrome that sits inside the wrap. */
|
||||
.term-input { padding: 0.4em 1em 0.8em; }
|
||||
.term-input .sendform-term {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5em;
|
||||
border-top: 1px dashed var(--purple-dim);
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
.term-input .prompt, .term-input .submit-hint {
|
||||
padding-top: 0.25em;
|
||||
}
|
||||
.term-input .prompt {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 6px rgba(166, 227, 161, 0.6);
|
||||
user-select: none;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.term-input textarea {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
padding: 0.2em 0;
|
||||
caret-color: var(--green);
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
line-height: 1.4;
|
||||
min-height: 1.4em;
|
||||
}
|
||||
.term-input textarea::placeholder { color: var(--muted); }
|
||||
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
|
||||
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
|
||||
.term-input.disabled textarea { color: var(--muted); }
|
||||
/* Row + pill + details styling moved to hive-fr0nt::TERMINAL_CSS. */
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive agent</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<link rel="stylesheet" href="/static/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<pre class="banner" id="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt ░▒▓█▓▒░</pre>
|
||||
<div class="title-row">
|
||||
<img class="agent-icon" src="/icon" alt="">
|
||||
<h2 id="title">◆ … ◆</h2>
|
||||
</div>
|
||||
<p class="meta" id="meta-links"></p>
|
||||
|
||||
<div id="status">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<div id="state-row">
|
||||
<span id="alive-badge" class="status-badge status-loading" title="harness reachability">…</span>
|
||||
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||
<span id="model-chip" class="model-chip" hidden></span>
|
||||
<span id="ctx-badge" class="ctx-badge" hidden title="tokens used in the current context window"></span>
|
||||
<span id="cost-badge" class="ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
|
||||
<span id="last-turn" class="last-turn" hidden></span>
|
||||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||
<button type="button" id="new-session-btn" class="btn-new-session"
|
||||
title="next turn runs without --continue, starting a fresh claude session">↻ new session</button>
|
||||
</div>
|
||||
|
||||
<details id="inbox-section" class="agent-inbox" hidden>
|
||||
<summary>▸ <span id="inbox-summary">inbox</span></summary>
|
||||
<ul id="inbox-list"></ul>
|
||||
</details>
|
||||
|
||||
<details id="loose-ends-section" class="agent-inbox" hidden>
|
||||
<summary>▸ <span id="loose-ends-summary">loose ends</span></summary>
|
||||
<ul id="loose-ends-list"></ul>
|
||||
</details>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
<div id="term-input" class="term-input"></div>
|
||||
</div>
|
||||
|
||||
<script src="/static/marked.js" defer></script>
|
||||
<script src="/static/hive-fr0nt.js" defer></script>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,770 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>screen</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<style>
|
||||
/* Catppuccin Mocha palette (mirrors base.css) */
|
||||
:root {
|
||||
--base: #1e1e2e;
|
||||
--mantle: #181825;
|
||||
--crust: #11111b;
|
||||
--text: #cdd6f4;
|
||||
--subtext0:#a6adc8;
|
||||
--surface0:#313244;
|
||||
--surface1:#45475a;
|
||||
--blue: #89b4fa;
|
||||
--red: #f38ba8;
|
||||
--green: #a6e3a1;
|
||||
--yellow: #f9e2af;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body { height: 100%; background: var(--base); color: var(--text);
|
||||
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
|
||||
font-size: 14px; }
|
||||
#toolbar {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.4rem 0.75rem; background: var(--mantle);
|
||||
border-bottom: 1px solid var(--surface0);
|
||||
}
|
||||
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
|
||||
#toolbar a:hover { text-decoration: underline; }
|
||||
.tbtn {
|
||||
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
|
||||
background: var(--surface0); color: var(--subtext0);
|
||||
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
|
||||
}
|
||||
.tbtn.active { color: var(--green); border-color: var(--green); }
|
||||
.tbtn:disabled { opacity: 0.4; cursor: default; }
|
||||
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
|
||||
#status.connected { color: var(--green); }
|
||||
#status.error { color: var(--red); }
|
||||
#debug-log {
|
||||
position: fixed; bottom: 0; left: 0; right: 0; max-height: 40vh;
|
||||
overflow-y: auto; background: rgba(17,17,27,0.95);
|
||||
border-top: 1px solid var(--surface1);
|
||||
font-size: 0.72rem; font-family: ui-monospace, monospace;
|
||||
padding: 0.4rem 0.6rem; z-index: 100;
|
||||
display: none; /* hidden by default; toggled by toolbar button */
|
||||
}
|
||||
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
|
||||
#debug-log .dbg-line.err { color: var(--red); }
|
||||
#debug-log .dbg-line.ok { color: var(--green); }
|
||||
#debug-log .dbg-line.send { color: var(--blue); }
|
||||
#canvas-wrap {
|
||||
display: flex; justify-content: center; align-items: flex-start;
|
||||
width: 100%; height: calc(100% - 36px); overflow: auto;
|
||||
background: var(--crust);
|
||||
}
|
||||
/* Fit mode: centre the canvas (relayoutCanvas() scales it in JS to
|
||||
fit the wrap) and clip any sub-pixel rounding overflow. */
|
||||
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
||||
canvas { display: block; cursor: default; }
|
||||
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
|
||||
The canvas is a flex item, and flex items default to
|
||||
min-width/min-height: auto — which resolves to the canvas's intrinsic
|
||||
framebuffer resolution and clamps the JS-set size straight back up,
|
||||
defeating the downscale (the bug behind #133 round 1). Pin the canvas
|
||||
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
|
||||
flex: none stops flex grow/shrink from fighting it. */
|
||||
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
||||
#msg {
|
||||
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
background: var(--surface0); color: var(--yellow); border-radius: 6px;
|
||||
padding: 0.4rem 0.9rem; font-size: 0.8rem;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<strong>🖥 screen</strong>
|
||||
<a href="/" title="back to agent page">← agent</a>
|
||||
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
|
||||
<button id="match-toggle" class="tbtn" title="Resize the remote desktop to fit this window" disabled>⤡ match size</button>
|
||||
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
|
||||
<span id="status">connecting…</span>
|
||||
</div>
|
||||
<div id="canvas-wrap"><canvas id="c"></canvas></div>
|
||||
<div id="msg"></div>
|
||||
<div id="debug-log"></div>
|
||||
|
||||
<script>
|
||||
// Minimal RFB-over-WebSocket renderer.
|
||||
// Connects to /screen/ws on the same host; the harness relays raw
|
||||
// RFB bytes to the VNC server running inside the container.
|
||||
//
|
||||
// This is a deliberately thin implementation — enough to display the
|
||||
// desktop and forward pointer + keyboard events. For a production-grade
|
||||
// viewer, replace with noVNC (issue #52 vendors the full bundle).
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('status');
|
||||
const msg = document.getElementById('msg');
|
||||
const debugLog = document.getElementById('debug-log');
|
||||
const debugBtn = document.getElementById('debug-toggle');
|
||||
const fitBtn = document.getElementById('fit-toggle');
|
||||
const matchBtn = document.getElementById('match-toggle');
|
||||
const canvasWrap = document.getElementById('canvas-wrap');
|
||||
|
||||
// --- Debug log ---
|
||||
let debugVisible = false;
|
||||
debugBtn.addEventListener('click', () => {
|
||||
debugVisible = !debugVisible;
|
||||
debugLog.style.display = debugVisible ? 'block' : 'none';
|
||||
debugBtn.classList.toggle('active', debugVisible);
|
||||
});
|
||||
|
||||
// --- Fit-to-window toggle ---
|
||||
// Scales the canvas down so the whole desktop is visible without
|
||||
// scrolling. The canvas's intrinsic resolution (width/height attrs)
|
||||
// is untouched — only its CSS display size changes, set explicitly
|
||||
// by relayoutCanvas(). Pointer coordinates are rescaled in
|
||||
// sendPointer to stay accurate. Persisted in localStorage; default
|
||||
// is fit-on.
|
||||
let fitMode = localStorage.getItem('screen-fit') !== 'off';
|
||||
// Size the canvas. In fit mode, scale down (never up) to the wrap,
|
||||
// preserving aspect ratio. Explicit px sizing rather than CSS
|
||||
// max-width/max-height: on a flex item those are overridden by the
|
||||
// automatic minimum size, so fit mode was a silent no-op — the
|
||||
// oversized canvas just got centred and clipped (issue #133).
|
||||
function relayoutCanvas() {
|
||||
if (fitMode && canvas.width && canvas.height
|
||||
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
|
||||
const scale = Math.min(
|
||||
canvasWrap.clientWidth / canvas.width,
|
||||
canvasWrap.clientHeight / canvas.height,
|
||||
1,
|
||||
);
|
||||
canvas.style.width = (canvas.width * scale) + 'px';
|
||||
canvas.style.height = (canvas.height * scale) + 'px';
|
||||
} else if (!fitMode) {
|
||||
canvas.style.width = '';
|
||||
canvas.style.height = '';
|
||||
}
|
||||
}
|
||||
function applyFitMode() {
|
||||
canvasWrap.classList.toggle('fit', fitMode);
|
||||
fitBtn.classList.toggle('active', fitMode);
|
||||
relayoutCanvas();
|
||||
}
|
||||
fitBtn.addEventListener('click', () => {
|
||||
fitMode = !fitMode;
|
||||
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
|
||||
applyFitMode();
|
||||
});
|
||||
window.addEventListener('resize', relayoutCanvas);
|
||||
applyFitMode();
|
||||
|
||||
// --- Match-size: resize the remote desktop to this window ---
|
||||
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
||||
// changes its actual output resolution to match the browser
|
||||
// viewport — sharper than fit-mode's CSS downscale. The button is
|
||||
// enabled only once the server has advertised the ExtendedDesktopSize
|
||||
// pseudo-encoding (a -308 rect). (issue #133)
|
||||
let extDesktopSupported = false;
|
||||
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
||||
matchBtn.addEventListener('click', () => {
|
||||
if (!extDesktopSupported) return;
|
||||
// Even dimensions — some servers reject odd ones.
|
||||
const w = Math.max(2, canvasWrap.clientWidth & ~1);
|
||||
const h = Math.max(2, canvasWrap.clientHeight & ~1);
|
||||
dbg('→ request desktop resize to ' + w + 'x' + h, 'send');
|
||||
sendSetDesktopSize(w, h);
|
||||
});
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
|
||||
}
|
||||
|
||||
function dbg(text, cls) {
|
||||
console.log('[rfb]', text);
|
||||
const line = document.createElement('div');
|
||||
line.className = 'dbg-line' + (cls ? ' ' + cls : '');
|
||||
line.textContent = text;
|
||||
debugLog.appendChild(line);
|
||||
debugLog.scrollTop = debugLog.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, cls) {
|
||||
status.textContent = text;
|
||||
status.className = cls || '';
|
||||
if (cls === 'error') dbg('ERROR: ' + text, 'err');
|
||||
}
|
||||
|
||||
function flash(text) {
|
||||
msg.textContent = text;
|
||||
msg.style.opacity = '1';
|
||||
setTimeout(() => { msg.style.opacity = '0'; }, 2500);
|
||||
}
|
||||
|
||||
// --- WebSocket connection ---
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
|
||||
ws.onerror = () => setStatus('connection error', 'error');
|
||||
ws.onclose = (e) => {
|
||||
setStatus(`disconnected (${e.code})`, 'error');
|
||||
flash('VNC disconnected — reload to reconnect');
|
||||
};
|
||||
|
||||
// Accumulate received bytes in a simple ring queue
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
chunks.push(new Uint8Array(ev.data));
|
||||
totalBytes += ev.data.byteLength;
|
||||
processRfb();
|
||||
};
|
||||
|
||||
// --- Minimal RFB state machine ---
|
||||
// We implement just enough to handshake and receive FramebufferUpdate
|
||||
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
|
||||
// Keyboard and pointer events are forwarded.
|
||||
|
||||
let state = 'version';
|
||||
let fbW = 0, fbH = 0;
|
||||
let pixelFormat = null; // set after ServerInit
|
||||
let updateRects = 0;
|
||||
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
|
||||
// value the rect-header encoding field is read as.
|
||||
const EXT_DESKTOP_SIZE_U32 = (-308) >>> 0;
|
||||
|
||||
// Drain bytes from the queue into a flat buffer view
|
||||
function drainTo(n) {
|
||||
if (totalBytes < n) return null;
|
||||
const out = new Uint8Array(n);
|
||||
let off = 0;
|
||||
while (off < n) {
|
||||
const c = chunks[0];
|
||||
const take = Math.min(c.length, n - off);
|
||||
out.set(c.subarray(0, take), off);
|
||||
off += take;
|
||||
if (take === c.length) {
|
||||
chunks.shift();
|
||||
} else {
|
||||
chunks[0] = c.subarray(take);
|
||||
}
|
||||
}
|
||||
totalBytes -= n;
|
||||
return out;
|
||||
}
|
||||
|
||||
function send(data) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
dbg('→ send [' + arr.length + 'b]: ' + hex(arr.slice(0, 32)) + (arr.length > 32 ? '…' : ''), 'send');
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; }
|
||||
function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; }
|
||||
|
||||
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
|
||||
// Protocol (from neatvnc apple-dh.c):
|
||||
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
|
||||
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
|
||||
// After SecurityResult=0: normal plaintext VNC (no session encryption)
|
||||
//
|
||||
// BigInt mod-pow — handles 2048-bit DH arithmetic.
|
||||
function modpow(base, exp, mod) {
|
||||
let r = 1n;
|
||||
base = base % mod;
|
||||
while (exp > 0n) {
|
||||
if (exp & 1n) r = r * base % mod;
|
||||
exp >>= 1n;
|
||||
base = base * base % mod;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
function bytesToBigInt(b) {
|
||||
let n = 0n;
|
||||
for (const byte of b) n = (n << 8n) | BigInt(byte);
|
||||
return n;
|
||||
}
|
||||
function bigIntToBytes(n, len) {
|
||||
const out = new Uint8Array(len);
|
||||
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
|
||||
// Based on the RFC 1321 reference implementation, minified.
|
||||
function md5(data) {
|
||||
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||||
const len = b.length;
|
||||
// pad message
|
||||
const padLen = ((len + 8) >>> 6 << 4) + 16;
|
||||
const m = new Uint32Array(padLen);
|
||||
for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8);
|
||||
m[len>>2] |= 0x80 << ((len&3)*8);
|
||||
m[padLen-2] = len*8;
|
||||
const T = new Int32Array(64);
|
||||
for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0;
|
||||
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
||||
const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21];
|
||||
function add(x,y){return (x+y)|0;}
|
||||
function r(v,s){return (v<<s)|(v>>>(32-s));}
|
||||
for (let i = 0; i < padLen; i += 16) {
|
||||
let [aa,bb,cc,dd] = [a,b2,c,d];
|
||||
for (let j = 0; j < 64; j++) {
|
||||
let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j]
|
||||
: j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16]
|
||||
: j<48 ? [bb^cc^dd, (3*j+5)%16]
|
||||
: [cc^(bb|(~dd)), (7*j)%16];
|
||||
f = add(add(aa, f), add(m[i+g], T[j]));
|
||||
// Rotation amount: round = j>>4 (changes every 16 steps),
|
||||
// position-in-round = j%4. S is laid out as 4 rounds × 4.
|
||||
[aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>4)*4)]))];
|
||||
}
|
||||
[a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)];
|
||||
}
|
||||
const out = new Uint8Array(16);
|
||||
[a,b2,c,d].forEach((x,i) => {
|
||||
out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff;
|
||||
out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
|
||||
async function aes128ecb(key16, data128) {
|
||||
const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']);
|
||||
const out = new Uint8Array(128);
|
||||
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16));
|
||||
out.set(new Uint8Array(enc,0,16), i*16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Apple-DH state — stored between async continuations.
|
||||
let appleDhState = null;
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function processRfb() {
|
||||
while (true) {
|
||||
if (!tryStep()) break;
|
||||
}
|
||||
}
|
||||
|
||||
function tryStep() {
|
||||
switch (state) {
|
||||
case 'version': {
|
||||
const b = drainTo(12);
|
||||
if (!b) return false;
|
||||
dbg('← server version: ' + new TextDecoder().decode(b).replace('\n','\\n'));
|
||||
// Send back same version (RFB 003.008)
|
||||
send(new TextEncoder().encode('RFB 003.008\n'));
|
||||
state = 'security-types';
|
||||
return true;
|
||||
}
|
||||
case 'security-types': {
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
const n = b[0];
|
||||
dbg('← security-types: count=' + n + (n === 0 ? ' (server error!)' : ''));
|
||||
if (n === 0) { setStatus('server sent 0 security types', 'error'); return false; }
|
||||
const types = drainTo(n);
|
||||
if (!types) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
dbg('← security-types offered: [' + Array.from(types).join(', ') + ']');
|
||||
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
|
||||
// even with --disable-transport-layer-security), else first offered.
|
||||
let prefer;
|
||||
if (types.indexOf(1) !== -1) prefer = 1; // plain None
|
||||
else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt
|
||||
else prefer = types[0];
|
||||
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
|
||||
if (types.indexOf(1) !== -1) prefer = 1;
|
||||
else if (types.indexOf(19) !== -1) prefer = 19;
|
||||
else if (types.indexOf(30) !== -1) prefer = 30;
|
||||
else {
|
||||
dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err');
|
||||
setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error');
|
||||
ws.close();
|
||||
return false;
|
||||
}
|
||||
dbg('→ choosing security type ' + prefer +
|
||||
(prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)'));
|
||||
send(new Uint8Array([prefer]));
|
||||
if (prefer === 1) state = 'security-result';
|
||||
else if (prefer === 19) state = 'vencrypt-version';
|
||||
else state = 'apple-dh-params';
|
||||
return true;
|
||||
}
|
||||
case 'security-vnc-challenge': {
|
||||
// VNC auth (type 2): we don't have the password, so send zeros.
|
||||
// This will fail for password-protected servers; fine for our
|
||||
// weston VNC which uses None via VeNCrypt.
|
||||
const b = drainTo(16);
|
||||
if (!b) return false;
|
||||
dbg('← vnc-challenge (16 bytes): ' + hex(b));
|
||||
send(new Uint8Array(16));
|
||||
state = 'security-result';
|
||||
return true;
|
||||
}
|
||||
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
|
||||
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
|
||||
// with --disable-transport-layer-security, offering sub-type 1 (None).
|
||||
case 'vencrypt-version': {
|
||||
// Server sends: major (u8), minor (u8) — e.g. 0, 2
|
||||
const b = drainTo(2);
|
||||
if (!b) return false;
|
||||
dbg('← VeNCrypt version: ' + b[0] + '.' + b[1]);
|
||||
// Echo same version back
|
||||
send(new Uint8Array([b[0], b[1]]));
|
||||
state = 'vencrypt-subtypes';
|
||||
return true;
|
||||
}
|
||||
case 'vencrypt-subtypes': {
|
||||
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
|
||||
const nb = drainTo(1);
|
||||
if (!nb) return false;
|
||||
const nSub = nb[0];
|
||||
dbg('← VeNCrypt nSubtypes=' + nSub);
|
||||
const raw = drainTo(nSub * 4);
|
||||
if (!raw) { chunks.unshift(nb); totalBytes += 1; return false; }
|
||||
// Build sub-type array from big-endian u32s
|
||||
const subs = [];
|
||||
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
|
||||
dbg('← VeNCrypt sub-types: [' + subs.join(', ') + ']');
|
||||
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
|
||||
// Fall back to first offered.
|
||||
const sub = subs.includes(1) ? 1 : subs[0];
|
||||
dbg('→ choosing VeNCrypt sub-type ' + sub);
|
||||
// Send chosen sub-type as big-endian u32
|
||||
send(new Uint8Array([sub>>>24, (sub>>>16)&0xff, (sub>>>8)&0xff, sub&0xff]));
|
||||
state = 'vencrypt-accept';
|
||||
return true;
|
||||
}
|
||||
case 'vencrypt-accept': {
|
||||
// Server sends 1 byte: 1=accepted, 0=refused
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
dbg('← VeNCrypt accept byte: ' + b[0] + (b[0] === 1 ? ' (ok)' : ' (REFUSED)'));
|
||||
if (b[0] !== 1) { setStatus('VeNCrypt sub-type refused', 'error'); return false; }
|
||||
// Sub-type 1 (None): proceed to SecurityResult
|
||||
state = 'security-result';
|
||||
return true;
|
||||
}
|
||||
// ── Apple-DH (type 30) ────────────────────────────────────────────
|
||||
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
|
||||
// server_pub[key_size]
|
||||
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
|
||||
// No session encryption after auth — plain RFB follows.
|
||||
case 'apple-dh-params': {
|
||||
const hdr = drainTo(4);
|
||||
if (!hdr) return false;
|
||||
const generator = u16be(hdr, 0);
|
||||
const keySize = u16be(hdr, 2);
|
||||
dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize);
|
||||
const rest = drainTo(keySize * 2);
|
||||
if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; }
|
||||
const prime = rest.slice(0, keySize);
|
||||
const serverPub = rest.slice(keySize);
|
||||
dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) +
|
||||
' server_pub[0:4]=' + hex(serverPub.slice(0,4)));
|
||||
|
||||
// Async DH computation — pause state machine, resume when done.
|
||||
appleDhState = { generator, keySize, prime, serverPub };
|
||||
state = 'apple-dh-wait';
|
||||
(async () => {
|
||||
try {
|
||||
const p = bytesToBigInt(appleDhState.prime);
|
||||
const g = BigInt(appleDhState.generator);
|
||||
const ks = appleDhState.keySize;
|
||||
|
||||
// Generate client private key: random ks bytes, then mod p
|
||||
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
|
||||
const priv = bytesToBigInt(privBytes) % p;
|
||||
|
||||
// Client public key = g ^ priv mod p
|
||||
const clientPub = modpow(g, priv, p);
|
||||
const clientPubBytes = bigIntToBytes(clientPub, ks);
|
||||
|
||||
// Shared secret = server_pub ^ priv mod p
|
||||
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
|
||||
const shared = modpow(serverPubInt, priv, p);
|
||||
const sharedBytes = bigIntToBytes(shared, ks);
|
||||
|
||||
// AES key = MD5(shared_secret)
|
||||
const aesKey = md5(sharedBytes);
|
||||
dbg('Apple-DH: shared MD5 key=' + hex(aesKey));
|
||||
|
||||
// Credentials: 64 bytes username + 64 bytes password.
|
||||
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
|
||||
// getpwnam(username) and requires pw_uid == weston's own uid
|
||||
// BEFORE PAM is ever consulted — an empty/garbage username is
|
||||
// rejected outright. weston runs as root, so the username must
|
||||
// be "root". The password stays empty; pam_permit.so on the
|
||||
// weston-remote-access PAM service accepts it.
|
||||
const creds = new Uint8Array(128);
|
||||
creds.set(new TextEncoder().encode('root'), 0);
|
||||
const encCreds = await aes128ecb(aesKey, creds);
|
||||
|
||||
// Send: encrypted_creds + client_pub
|
||||
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
|
||||
// at offset 0 and public_key at offset 128 (flexible array after).
|
||||
const response = new Uint8Array(ks + 128);
|
||||
response.set(encCreds, 0);
|
||||
response.set(clientPubBytes, 128);
|
||||
send(response);
|
||||
dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok');
|
||||
|
||||
state = 'security-result';
|
||||
processRfb(); // resume state machine
|
||||
} catch(e) {
|
||||
setStatus('Apple-DH error: ' + e.message, 'error');
|
||||
}
|
||||
})();
|
||||
return false; // pause — async resumes
|
||||
}
|
||||
case 'apple-dh-wait':
|
||||
// Async handshake in progress — don't consume bytes
|
||||
return false;
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
case 'security-result': {
|
||||
const b = drainTo(4);
|
||||
if (!b) return false;
|
||||
const code = u32be(b, 0);
|
||||
dbg('← security-result: ' + code + ' (bytes: ' + hex(b) + ')' + (code === 0 ? ' ✓' : ' FAIL'), code === 0 ? 'ok' : 'err');
|
||||
if (code !== 0) { setStatus('auth failed (code ' + code + ')', 'error'); return false; }
|
||||
// ClientInit: shared flag = 1
|
||||
send(new Uint8Array([1]));
|
||||
state = 'server-init';
|
||||
return true;
|
||||
}
|
||||
case 'server-init': {
|
||||
const b = drainTo(24);
|
||||
if (!b) return false;
|
||||
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
|
||||
fbW = u16be(b, 0); fbH = u16be(b, 2);
|
||||
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
|
||||
// red/green/blue max/shift at b[8..17]
|
||||
pixelFormat = {
|
||||
bpp: b[4], depth: b[5], bigEndian: b[6], trueColour: b[7],
|
||||
redMax: u16be(b, 8), greenMax: u16be(b, 10), blueMax: u16be(b, 12),
|
||||
redShift: b[14], greenShift: b[15], blueShift: b[16],
|
||||
bytesPerPixel: b[4] / 8,
|
||||
};
|
||||
const nameLen = u32be(b, 20);
|
||||
const nameBytes = drainTo(nameLen);
|
||||
if (!nameBytes) { chunks.unshift(b); totalBytes += 24; return false; }
|
||||
dbg('← server-init: ' + fbW + 'x' + fbH + ' bpp=' + pixelFormat.bpp, 'ok');
|
||||
canvas.width = fbW;
|
||||
canvas.height = fbH;
|
||||
relayoutCanvas();
|
||||
setStatus('connected', 'connected');
|
||||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
|
||||
// server reports (and accepts) desktop-size changes. (issue #133)
|
||||
sendSetEncodings([0, -308]);
|
||||
// Request full framebuffer update
|
||||
requestUpdate(0, 0, 0, fbW, fbH);
|
||||
state = 'normal';
|
||||
return true;
|
||||
}
|
||||
case 'normal': {
|
||||
const b = drainTo(1);
|
||||
if (!b) return false;
|
||||
const msgType = b[0];
|
||||
if (msgType === 0) {
|
||||
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
|
||||
// byte is already consumed above; hdr covers padding + nRects.
|
||||
const hdr = drainTo(3);
|
||||
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
updateRects = u16be(hdr, 1);
|
||||
state = 'rect-header';
|
||||
} else if (msgType === 2) {
|
||||
// Bell: ignore
|
||||
} else if (msgType === 3) {
|
||||
// ServerCutText
|
||||
const hdr = drainTo(7);
|
||||
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
|
||||
const len = u32be(hdr, 3);
|
||||
const text = drainTo(len);
|
||||
if (!text) { chunks.unshift(b); totalBytes += 1 + 7; return false; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case 'rect-header': {
|
||||
if (updateRects === 0) { state = 'normal'; requestUpdate(1, 0, 0, fbW, fbH); return true; }
|
||||
const b = drainTo(12);
|
||||
if (!b) return false;
|
||||
const x = u16be(b, 0), y = u16be(b, 2), w = u16be(b, 4), h = u16be(b, 6);
|
||||
const enc = (b[8]<<24|b[9]<<16|b[10]<<8|b[11])>>>0;
|
||||
if (enc === 0 && pixelFormat) {
|
||||
const bytes = w * h * pixelFormat.bytesPerPixel;
|
||||
const pixels = drainTo(bytes);
|
||||
if (!pixels) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
drawRaw(x, y, w, h, pixels);
|
||||
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
||||
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
||||
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
|
||||
// header's x = change reason, y = request status. (issue #133)
|
||||
const nScreens = peekByte();
|
||||
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
const body = drainTo(4 + nScreens * 16);
|
||||
if (!body) { chunks.unshift(b); totalBytes += 12; return false; }
|
||||
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
|
||||
if (!extDesktopSupported) {
|
||||
extDesktopSupported = true;
|
||||
matchBtn.disabled = false;
|
||||
}
|
||||
if (w && h && (w !== fbW || h !== fbH)) {
|
||||
dbg('← desktop resized to ' + w + 'x' + h
|
||||
+ ' (reason ' + x + ', status ' + y + ')', 'ok');
|
||||
fbW = w; fbH = h;
|
||||
canvas.width = w; canvas.height = h;
|
||||
relayoutCanvas();
|
||||
requestUpdate(0, 0, 0, fbW, fbH);
|
||||
}
|
||||
}
|
||||
updateRects--;
|
||||
return true;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function drawRaw(x, y, w, h, data) {
|
||||
if (!pixelFormat || w === 0 || h === 0) return;
|
||||
const bpp = pixelFormat.bytesPerPixel;
|
||||
const img = ctx.createImageData(w, h);
|
||||
const d = img.data;
|
||||
const rs = pixelFormat.redShift, gs = pixelFormat.greenShift, bs = pixelFormat.blueShift;
|
||||
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
|
||||
let px = 0;
|
||||
if (bpp === 4) px = pixelFormat.bigEndian
|
||||
? (data[o]<<24|data[o+1]<<16|data[o+2]<<8|data[o+3])>>>0
|
||||
: (data[o+3]<<24|data[o+2]<<16|data[o+1]<<8|data[o])>>>0;
|
||||
else if (bpp === 2) px = pixelFormat.bigEndian
|
||||
? (data[o]<<8|data[o+1])>>>0 : (data[o+1]<<8|data[o])>>>0;
|
||||
else px = data[o];
|
||||
d[i*4] = (px >> rs) & pixelFormat.redMax;
|
||||
d[i*4+1] = (px >> gs) & pixelFormat.greenMax;
|
||||
d[i*4+2] = (px >> bs) & pixelFormat.blueMax;
|
||||
d[i*4+3] = 255;
|
||||
}
|
||||
ctx.putImageData(img, x, y);
|
||||
}
|
||||
|
||||
function requestUpdate(incremental, x, y, w, h) {
|
||||
const b = new Uint8Array(10);
|
||||
b[0] = 3; b[1] = incremental;
|
||||
b[2] = x>>8; b[3] = x&0xff;
|
||||
b[4] = y>>8; b[5] = y&0xff;
|
||||
b[6] = w>>8; b[7] = w&0xff;
|
||||
b[8] = h>>8; b[9] = h&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
// SetEncodings (msg type 2): advertise the encodings we understand.
|
||||
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
|
||||
function sendSetEncodings(encs) {
|
||||
const b = new Uint8Array(4 + encs.length * 4);
|
||||
b[0] = 2; // message-type
|
||||
b[1] = 0; // padding
|
||||
b[2] = encs.length >> 8; b[3] = encs.length & 0xff;
|
||||
let o = 4;
|
||||
for (const e of encs) {
|
||||
const v = e >>> 0; // two's-complement for negatives
|
||||
b[o++] = (v>>24)&0xff; b[o++] = (v>>16)&0xff;
|
||||
b[o++] = (v>>8)&0xff; b[o++] = v&0xff;
|
||||
}
|
||||
send(b);
|
||||
}
|
||||
|
||||
// SetDesktopSize (msg type 251): ask the server to change the desktop
|
||||
// resolution. One screen at the origin, sized to the request. (#133)
|
||||
function sendSetDesktopSize(w, h) {
|
||||
const b = new Uint8Array(24);
|
||||
b[0] = 251; b[1] = 0; // message-type + padding
|
||||
b[2] = w>>8; b[3] = w&0xff;
|
||||
b[4] = h>>8; b[5] = h&0xff;
|
||||
b[6] = 1; b[7] = 0; // number-of-screens + padding
|
||||
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
|
||||
b[8] = (screenId>>>24)&0xff; b[9] = (screenId>>>16)&0xff;
|
||||
b[10] = (screenId>>>8)&0xff; b[11] = screenId&0xff;
|
||||
b[12] = 0; b[13] = 0; // x-position
|
||||
b[14] = 0; b[15] = 0; // y-position
|
||||
b[16] = w>>8; b[17] = w&0xff;
|
||||
b[18] = h>>8; b[19] = h&0xff;
|
||||
b[20] = 0; b[21] = 0; b[22] = 0; b[23] = 0; // flags
|
||||
send(b);
|
||||
}
|
||||
|
||||
// Peek the first unconsumed byte without draining it. -1 when empty.
|
||||
function peekByte() {
|
||||
for (const c of chunks) { if (c.length) return c[0]; }
|
||||
return -1;
|
||||
}
|
||||
|
||||
// --- Input forwarding ---
|
||||
canvas.addEventListener('mousemove', sendPointer);
|
||||
canvas.addEventListener('mousedown', sendPointer);
|
||||
canvas.addEventListener('mouseup', sendPointer);
|
||||
|
||||
function sendPointer(ev) {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
|
||||
// from the intrinsic resolution — map client coords back to fb pixels.
|
||||
const sx = r.width ? canvas.width / r.width : 1;
|
||||
const sy = r.height ? canvas.height / r.height : 1;
|
||||
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
|
||||
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
|
||||
let mask = 0;
|
||||
if (ev.buttons & 1) mask |= 1;
|
||||
if (ev.buttons & 4) mask |= 2;
|
||||
if (ev.buttons & 2) mask |= 4;
|
||||
const b = new Uint8Array(6);
|
||||
b[0] = 5; b[1] = mask;
|
||||
b[2] = x>>8; b[3] = x&0xff;
|
||||
b[4] = y>>8; b[5] = y&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (ev) => sendKey(ev, 1));
|
||||
document.addEventListener('keyup', (ev) => sendKey(ev, 0));
|
||||
|
||||
function sendKey(ev, down) {
|
||||
ev.preventDefault();
|
||||
const key = rfbKeysym(ev);
|
||||
const b = new Uint8Array(8);
|
||||
b[0] = 4; b[1] = down; b[2] = 0; b[3] = 0;
|
||||
b[4] = key>>24; b[5] = (key>>16)&0xff; b[6] = (key>>8)&0xff; b[7] = key&0xff;
|
||||
send(b);
|
||||
}
|
||||
|
||||
function rfbKeysym(ev) {
|
||||
// Map common keys to X11 keysym values
|
||||
const map = {
|
||||
'BackSpace': 0xff08, 'Tab': 0xff09, 'Enter': 0xff0d, 'Escape': 0xff1b,
|
||||
'Delete': 0xffff, 'Home': 0xff50, 'End': 0xff57, 'PageUp': 0xff55,
|
||||
'PageDown': 0xff56, 'ArrowLeft': 0xff51, 'ArrowUp': 0xff52,
|
||||
'ArrowRight': 0xff53, 'ArrowDown': 0xff54,
|
||||
'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffe7,
|
||||
'F1': 0xffbe, 'F2': 0xffbf, 'F3': 0xffc0, 'F4': 0xffc1,
|
||||
'F5': 0xffc2, 'F6': 0xffc3, 'F7': 0xffc4, 'F8': 0xffc5,
|
||||
'F9': 0xffc6, 'F10': 0xffc7, 'F11': 0xffc8, 'F12': 0xffc9,
|
||||
};
|
||||
if (map[ev.key]) return map[ev.key];
|
||||
if (ev.key.length === 1) return ev.key.codePointAt(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>hyperhive agent — stats</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">
|
||||
<link rel="stylesheet" href="/static/agent.css">
|
||||
<style>
|
||||
.stats-nav { display: flex; gap: 0.75rem; align-items: baseline; margin-bottom: 0.5rem; }
|
||||
.stats-nav a { color: var(--cyan); text-decoration: none; }
|
||||
.stats-nav a:hover { text-decoration: underline; }
|
||||
.window-tabs { display: flex; gap: 0.4rem; margin: 0.5rem 0 1rem; }
|
||||
.window-tabs button {
|
||||
background: var(--bg-elev); color: var(--fg);
|
||||
border: 1px solid var(--border); padding: 0.3rem 0.8rem;
|
||||
font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.window-tabs button.active { background: var(--purple-dim); border-color: var(--purple); color: var(--purple); }
|
||||
.summary { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 1rem; }
|
||||
.summary .chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
min-width: 9rem;
|
||||
height: 3.4rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.summary .chip .label {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.summary .chip .value {
|
||||
color: var(--cyan);
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.card h3 { margin: 0 0 0.5rem; color: var(--purple); font-size: 0.95rem; font-weight: normal; }
|
||||
.card .chart-wrap { position: relative; height: 220px; }
|
||||
.card.wide { grid-column: 1 / -1; }
|
||||
.card.wide .chart-wrap { height: 260px; }
|
||||
.empty-note { color: var(--muted); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
|
||||
<div class="stats-nav">
|
||||
<a id="back-link" href="/">← live</a>
|
||||
<a id="dashboard-link" href="#">dashboard</a>
|
||||
<h2 id="title" style="margin: 0;">◆ … ◆</h2>
|
||||
</div>
|
||||
|
||||
<div class="window-tabs" id="window-tabs">
|
||||
<button data-w="1h">last 1h</button>
|
||||
<button data-w="4h">last 4h</button>
|
||||
<button data-w="24h" class="active">last 24h</button>
|
||||
<button data-w="3d">last 3d</button>
|
||||
<button data-w="7d">last 7d</button>
|
||||
<button data-w="30d">last 30d</button>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
|
||||
<div class="card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
|
||||
<div class="card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
|
||||
<div class="card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
|
||||
<div class="card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
|
||||
<div class="card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
|
||||
<div class="card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
|
||||
<div class="card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Chart.js pinned to a fixed version from jsDelivr. SRI hash is
|
||||
not set yet — add an integrity="sha384-..." attribute when we
|
||||
have a way to compute it deterministically in the build. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="/static/stats.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,338 +0,0 @@
|
|||
// Per-agent stats page. Fetches /api/state for the title + dashboard link
|
||||
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
||||
// when the operator clicks a window tab.
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
const palette = {
|
||||
bg: cssVar('--bg'),
|
||||
bgElev: cssVar('--bg-elev'),
|
||||
fg: cssVar('--fg'),
|
||||
muted: cssVar('--muted'),
|
||||
purple: cssVar('--purple'),
|
||||
cyan: cssVar('--cyan'),
|
||||
pink: cssVar('--pink'),
|
||||
amber: cssVar('--amber'),
|
||||
green: cssVar('--green'),
|
||||
red: cssVar('--red'),
|
||||
border: cssVar('--border'),
|
||||
};
|
||||
// Distinct hues for categorical charts (top tools / wake mix / result mix).
|
||||
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
|
||||
palette.green, palette.red, '#94e2d5', '#f9e2af',
|
||||
'#74c7ec', '#b4befe'];
|
||||
|
||||
// Apply Catppuccin defaults globally so each Chart inherits without per-call
|
||||
// overrides. Chart.js v4 reads these on chart construction.
|
||||
Chart.defaults.color = palette.fg;
|
||||
Chart.defaults.borderColor = palette.border;
|
||||
Chart.defaults.font.family = '"JetBrains Mono", "Fira Code", monospace';
|
||||
Chart.defaults.font.size = 11;
|
||||
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
||||
|
||||
const charts = {};
|
||||
let currentWindow = '24h';
|
||||
|
||||
function fmtMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '0';
|
||||
if (ms < 1000) return ms.toFixed(0) + 'ms';
|
||||
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return new Intl.NumberFormat().format(Math.round(n));
|
||||
}
|
||||
|
||||
function bucketLabel(ts, bucketSecs) {
|
||||
const d = new Date(ts * 1000);
|
||||
if (bucketSecs >= 86400) {
|
||||
return d.toISOString().slice(5, 10); // MM-DD
|
||||
}
|
||||
return d.toISOString().slice(11, 16); // HH:MM
|
||||
}
|
||||
|
||||
function destroy(name) {
|
||||
if (charts[name]) {
|
||||
charts[name].destroy();
|
||||
delete charts[name];
|
||||
}
|
||||
}
|
||||
|
||||
function paintEmpty(canvasId, msg) {
|
||||
destroy(canvasId);
|
||||
const cv = document.getElementById(canvasId);
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.fillStyle = palette.muted;
|
||||
ctx.font = '12px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
||||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const root = document.getElementById('summary');
|
||||
root.replaceChildren();
|
||||
const chips = [
|
||||
['turns', fmtInt(s.turn_count)],
|
||||
['avg duration', fmtMs(s.duration_summary.avg_ms)],
|
||||
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
|
||||
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
|
||||
['window', s.window],
|
||||
];
|
||||
for (const [label, value] of chips) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
const l = document.createElement('span');
|
||||
l.className = 'label';
|
||||
l.textContent = label;
|
||||
const v = document.createElement('span');
|
||||
v.className = 'value';
|
||||
v.textContent = value;
|
||||
chip.append(l, v);
|
||||
root.append(chip);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTurnsChart(s) {
|
||||
const id = 'chart-turns';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const data = s.buckets.map((b) => b.turn_count);
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'turns',
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDurationChart(s) {
|
||||
const id = 'chart-duration';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const ds = (label, color, key) => ({
|
||||
label, data: s.buckets.map((b) => b[key]),
|
||||
borderColor: color, backgroundColor: color + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
});
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
ds('p50', palette.cyan, 'p50_duration_ms'),
|
||||
ds('p95', palette.pink, 'p95_duration_ms'),
|
||||
ds('avg', palette.amber, 'avg_duration_ms'),
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtMs(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCtxChart(s) {
|
||||
const id = 'chart-ctx';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'avg ctx',
|
||||
data: s.buckets.map((b) => b.avg_ctx_tokens),
|
||||
borderColor: palette.cyan,
|
||||
backgroundColor: palette.cyan + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: 'max ctx',
|
||||
data: s.buckets.map((b) => b.max_ctx_tokens),
|
||||
borderColor: palette.amber,
|
||||
backgroundColor: palette.amber + '33',
|
||||
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostChart(s) {
|
||||
const id = 'chart-cost';
|
||||
destroy(id);
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
|
||||
// Highlights "what's actually getting billed at full rate" vs cache hits.
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
|
||||
backgroundColor: palette.muted },
|
||||
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
|
||||
backgroundColor: palette.cyan },
|
||||
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
|
||||
backgroundColor: palette.amber },
|
||||
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
|
||||
backgroundColor: palette.pink },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelChart(s) {
|
||||
const id = 'chart-model';
|
||||
destroy(id);
|
||||
const models = s.models || [];
|
||||
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
// One stacked series per model. Model choice drives token cost,
|
||||
// so this lines up against the cost chart above it.
|
||||
const datasets = models.map((m, i) => ({
|
||||
label: m,
|
||||
data: s.buckets.map((b) => (b.model_counts && b.model_counts[m]) || 0),
|
||||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
scales: {
|
||||
x: { stacked: true, grid: { color: palette.border } },
|
||||
y: { stacked: true, beginAtZero: true,
|
||||
grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyCount(canvasId, items, emptyMsg) {
|
||||
destroy(canvasId);
|
||||
if (!items || items.length === 0) {
|
||||
paintEmpty(canvasId, emptyMsg);
|
||||
return;
|
||||
}
|
||||
const labels = items.map((kc) => kc.key);
|
||||
const data = items.map((kc) => kc.count);
|
||||
const colors = items.map((_, i) => wheel[i % wheel.length]);
|
||||
charts[canvasId] = new Chart(document.getElementById(canvasId), {
|
||||
type: 'doughnut',
|
||||
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderSummary(s);
|
||||
if (s.turn_count === 0) {
|
||||
paintEmpty('chart-turns', 'no turns in window');
|
||||
paintEmpty('chart-duration', 'no turns in window');
|
||||
paintEmpty('chart-ctx', 'no turns in window');
|
||||
paintEmpty('chart-cost', 'no turns in window');
|
||||
paintEmpty('chart-model', 'no turns in window');
|
||||
paintEmpty('chart-tools', 'no tool calls');
|
||||
paintEmpty('chart-wake', 'no wakes');
|
||||
paintEmpty('chart-result', 'no results');
|
||||
return;
|
||||
}
|
||||
renderTurnsChart(s);
|
||||
renderDurationChart(s);
|
||||
renderCtxChart(s);
|
||||
renderCostChart(s);
|
||||
renderModelChart(s);
|
||||
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
|
||||
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
|
||||
renderKeyCount('chart-result', s.result_mix, 'no results');
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow));
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const snap = await resp.json();
|
||||
render(snap);
|
||||
} catch (e) {
|
||||
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
document.title = 'stats · ' + s.label;
|
||||
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
||||
const dl = document.getElementById('dashboard-link');
|
||||
dl.href = 'http://' + window.location.hostname + ':' + s.dashboard_port + '/';
|
||||
} catch (_) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
function bindTabs() {
|
||||
const tabs = document.getElementById('window-tabs');
|
||||
tabs.addEventListener('click', (ev) => {
|
||||
const btn = ev.target.closest('button[data-w]');
|
||||
if (!btn) return;
|
||||
currentWindow = btn.dataset.w;
|
||||
for (const b of tabs.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
||||
loadStats();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
bindTabs();
|
||||
loadIdentity();
|
||||
loadStats();
|
||||
});
|
||||
})();
|
||||
|
|
@ -24,6 +24,7 @@ use axum::{
|
|||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio_stream::{Stream, StreamExt, wrappers::BroadcastStream};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use crate::client;
|
||||
use crate::events::Bus;
|
||||
|
|
@ -88,6 +89,19 @@ pub async fn serve(
|
|||
turn_lock: TurnLock,
|
||||
) -> Result<()> {
|
||||
let gui_vnc_port = read_gui_json();
|
||||
let static_dir: PathBuf = std::env::var_os("HIVE_STATIC_DIR")
|
||||
.map(PathBuf::from)
|
||||
.context(
|
||||
"HIVE_STATIC_DIR env var not set — point it at the merged \
|
||||
per-agent dist (see hyperhive.frontend.mergedDist in nix)",
|
||||
)?;
|
||||
if !static_dir.is_dir() {
|
||||
anyhow::bail!(
|
||||
"HIVE_STATIC_DIR ({}) is not a directory",
|
||||
static_dir.display()
|
||||
);
|
||||
}
|
||||
tracing::info!(static_dir = %static_dir.display(), "web UI static dir resolved");
|
||||
let state = AppState {
|
||||
label,
|
||||
login,
|
||||
|
|
@ -99,11 +113,6 @@ pub async fn serve(
|
|||
gui_vnc_port,
|
||||
};
|
||||
let app = Router::new()
|
||||
.route("/", get(serve_index))
|
||||
.route("/static/agent.css", get(serve_css))
|
||||
.route("/static/app.js", get(serve_app_js))
|
||||
.route("/static/hive-fr0nt.js", get(serve_shared_js))
|
||||
.route("/static/marked.js", get(serve_marked_js))
|
||||
.route("/api/state", get(api_state))
|
||||
.route("/events/stream", get(events_stream))
|
||||
.route("/events/history", get(events_history))
|
||||
|
|
@ -116,12 +125,17 @@ pub async fn serve(
|
|||
.route("/api/model", post(post_set_model))
|
||||
.route("/api/new-session", post(post_new_session))
|
||||
.route("/api/loose-ends", get(api_loose_ends))
|
||||
.route("/stats", get(serve_stats))
|
||||
.route("/static/stats.js", get(serve_stats_js))
|
||||
.route("/api/stats", get(api_stats))
|
||||
.route("/screen", get(serve_screen))
|
||||
.route("/screen/ws", get(screen_ws))
|
||||
.route("/icon", get(serve_icon))
|
||||
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||
// falls through to the merged dist. ServeDir auto-appends
|
||||
// `.html` when the URL is a bare path that matches a file
|
||||
// (so `/stats` → `dist/stats.html`, `/screen` → `dist/
|
||||
// screen.html`). Per-agent `extraFiles` additions are
|
||||
// already layered into this same directory (see
|
||||
// hyperhive.frontend.mergedDist in nix).
|
||||
.fallback_service(ServeDir::new(&static_dir))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
|
|
@ -201,68 +215,6 @@ fn try_bind(addr: SocketAddr) -> std::io::Result<tokio::net::TcpListener> {
|
|||
sock.listen(1024)
|
||||
}
|
||||
|
||||
async fn serve_index() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "text/html; charset=utf-8")],
|
||||
include_str!("../assets/index.html"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_css() -> impl IntoResponse {
|
||||
// Prepend the shared palette/typography so per-page styles only need
|
||||
// to declare what's actually page-specific. One HTTP request, no
|
||||
// per-asset cache to invalidate.
|
||||
let body = format!(
|
||||
"{}\n{}\n{}",
|
||||
hive_fr0nt::BASE_CSS,
|
||||
hive_fr0nt::TERMINAL_CSS,
|
||||
include_str!("../assets/agent.css"),
|
||||
);
|
||||
([("content-type", "text/css")], body)
|
||||
}
|
||||
|
||||
async fn serve_app_js() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "application/javascript")],
|
||||
include_str!("../assets/app.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_shared_js() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "application/javascript")],
|
||||
hive_fr0nt::TERMINAL_JS,
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_marked_js() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "application/javascript")],
|
||||
hive_fr0nt::MARKED_JS,
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_stats() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "text/html; charset=utf-8")],
|
||||
include_str!("../assets/stats.html"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_stats_js() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "application/javascript")],
|
||||
include_str!("../assets/stats.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn serve_screen() -> impl IntoResponse {
|
||||
(
|
||||
[("content-type", "text/html; charset=utf-8")],
|
||||
include_str!("../assets/screen.html"),
|
||||
)
|
||||
}
|
||||
|
||||
/// This agent's icon. Serves the operator-configured SVG from
|
||||
/// `/etc/hyperhive/icon.svg` (set via the `hyperhive.icon` agent.nix
|
||||
/// option) when present, otherwise the bundled default hyperhive logo.
|
||||
|
|
@ -585,8 +537,13 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
||||
let mut links = Vec::new();
|
||||
|
||||
// Note: the URLs are the actual HTML files served out of the
|
||||
// frontend dist (`stats.html` / `screen.html`); after the #273
|
||||
// backend/frontend split the harness serves these as static
|
||||
// files via ServeDir rather than via Rust routes, so the URL
|
||||
// has to be the on-disk filename.
|
||||
links.push(AgentLink {
|
||||
url: "/stats".to_owned(),
|
||||
url: "/stats.html".to_owned(),
|
||||
icon: "📊".to_owned(),
|
||||
label: "stats".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
|
|
@ -594,7 +551,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
|
|||
|
||||
if gui_enabled {
|
||||
links.push(AgentLink {
|
||||
url: "/screen".to_owned(),
|
||||
url: "/screen.html".to_owned(),
|
||||
icon: "🖥".to_owned(),
|
||||
label: "screen".to_owned(),
|
||||
kind: AgentLinkKind::Container,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue