agent UI: relative paths for all assets/api/ws so the page works under any nginx prefix (#14)

Per mara on #14: 'make agent page not assume root path, links / api
calls need to be relative'. atlas's nginx side (#15) will mount the
per-agent UI at a prefix like /agent/<name>/ instead of its own
port; for the page to keep working under that prefix, every
in-page reference needs to resolve document-relative rather than
root-anchored.

Converted in this pass:
- HTML <link>/<script>/<img>/<a> hrefs in index.html, stats.html,
  screen.html: '/icon' → 'icon', '/static/agent.css' →
  'static/agent.css', back links '/' → './'.
- app.js fetch() targets ('/api/state' → 'api/state', /api/cancel,
  /api/loose-ends, etc.), form actions ('/login/start', '/send'),
  EventSource urls ('/events/stream', '/events/history').
- stats.js fetch() targets.
- screen.html WebSocket URL: was hardcoded as
  ws(s)://host/screen/ws; now derived from document.baseURI via
  new URL('screen/ws', document.baseURI) so the gateway prefix
  flows through.

Slash-command labels (/cancel, /compact, …) and the dashboard-port
link (different port, intentionally absolute) intentionally
untouched.

Added a new 'Per-agent relative paths' section to docs/web-ui.md
covering the rationale + the trailing-slash gotcha (sub-pages like
/stats must NOT have a trailing slash, or 'static/app.js' resolves
under /stats/ instead of replacing the segment).

Functional code unchanged; build clean. Damocles + atlas can
proceed with the backend / nginx side without depending on this
landing first, but once both ship the agent page works under the
gateway-prefixed URL without further changes.

refs #14
This commit is contained in:
iris 2026-05-31 12:10:56 +02:00
commit 37d99ed118
6 changed files with 70 additions and 27 deletions

View file

@ -143,6 +143,36 @@ Both bind their listeners with `SO_REUSEADDR` via
exponential backoff capped at 2s) so an nspawn restart that races exponential backoff capped at 2s) so an nspawn restart that races
the previous process's socket release resolves itself. the previous process's socket release resolves itself.
### Per-agent relative paths
The per-agent UI uses **document-relative paths everywhere** for
assets, API calls, form actions, and the screen WebSocket. Bare
references like `static/app.js`, `api/state`, `events/stream`,
`screen/ws` resolve against `document.baseURI` — the page's URL
without its last path segment.
That makes the page work under any prefix the agent ends up mounted
at without rebuilding the dist. The cases that matter:
| served at | `api/state` resolves to |
|---|---|
| `/` (own port, today's shape) | `/api/state` |
| `/agent/iris/` (gateway-prefixed) | `/agent/iris/api/state` |
| `/agent/iris/stats` (subpage, no trailing slash) | `/agent/iris/api/state` |
The gateway upstream config strips the prefix before forwarding to
the per-agent server, so the agent's Rust routes (`api/state`,
`events/stream`, `screen/ws`, `login/start`, …) keep their absolute
paths server-side. Only the browser-facing URLs are gated on the
mount prefix.
Subpages (`stats`, `screen`) are served without a trailing slash so
the relative-path resolution stays correct: `static/app.js` from
`/stats` becomes `/static/app.js` (last segment `stats` gets
replaced), not `/stats/static/app.js`. Adding a trailing slash to
those routes would break the resolution; either keep them
slash-less or use `<base href>` injection at serve time.
## Dashboard layout ## Dashboard layout
The dashboard (`/`) has a fixed chrome header at the top and a The dashboard (`/`) has a fixed chrome header at the top and a

View file

@ -330,7 +330,7 @@ window.marked = marked;
}), }),
); );
const start = el('form', { const start = el('form', {
action: '/login/start', method: 'POST', 'data-async': '', action: 'login/start', method: 'POST', 'data-async': '',
}); });
start.append( start.append(
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'), el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
@ -358,7 +358,7 @@ window.marked = marked;
} }
if (!s.finished) { if (!s.finished) {
const code = el('form', { const code = el('form', {
action: '/login/code', method: 'POST', class: 'loginform', 'data-async': '', action: 'login/code', method: 'POST', class: 'loginform', 'data-async': '',
}); });
// #568: OAuth code is a sensitive secret — mask the input with // #568: OAuth code is a sensitive secret — mask the input with
// type="password" so a shoulder-surfer / screenshot doesn't // type="password" so a shoulder-surfer / screenshot doesn't
@ -397,7 +397,7 @@ window.marked = marked;
root.append(code); root.append(code);
} }
const cancel = el('form', { const cancel = el('form', {
action: '/login/cancel', method: 'POST', 'data-async': '', action: 'login/cancel', method: 'POST', 'data-async': '',
style: 'margin-top: 0.4em;', style: 'margin-top: 0.4em;',
}); });
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill')); cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
@ -437,7 +437,7 @@ window.marked = marked;
async function postModel(name) { async function postModel(name) {
try { try {
const resp = await fetch('/api/model', { const resp = await fetch('api/model', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ model: name }), body: new URLSearchParams({ model: name }),
@ -469,10 +469,12 @@ window.marked = marked;
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err); if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
} }
} }
const postCancelTurn = () => postSimple('/api/cancel', '/cancel'); // First arg is the URL path (relative to document base, #14);
const postCompact = () => postSimple('/api/compact', '/compact'); // second is the slash-command label rendered in the local note.
const postNewSession = () => postSimple('/api/new-session', '/new-session'); const postCancelTurn = () => postSimple('api/cancel', '/cancel');
const postLogout = () => postSimple('/api/logout', '/logout'); const postCompact = () => postSimple('api/compact', '/compact');
const postNewSession = () => postSimple('api/new-session', '/new-session');
const postLogout = () => postSimple('api/logout', '/logout');
function handleSlashCommand(line) { function handleSlashCommand(line) {
if (!termAPI) return false; if (!termAPI) return false;
@ -548,7 +550,7 @@ window.marked = marked;
if (!termInputRendered) { if (!termInputRendered) {
slot.innerHTML = ''; slot.innerHTML = '';
const form = el('form', { const form = el('form', {
action: '/send', method: 'POST', action: 'send', method: 'POST',
class: 'sendform-term', 'data-async': '', class: 'sendform-term', 'data-async': '',
}); });
const ta = el('textarea', { const ta = el('textarea', {
@ -668,7 +670,7 @@ window.marked = marked;
// keeps the pill count at zero rather than surfacing a stale chrome. // keeps the pill count at zero rather than surfacing a stale chrome.
async function refreshLooseEnds() { async function refreshLooseEnds() {
try { try {
const resp = await fetch('/api/loose-ends'); const resp = await fetch('api/loose-ends');
if (!resp.ok) { if (!resp.ok) {
renderLooseEnds([]); renderLooseEnds([]);
return; return;
@ -1084,7 +1086,7 @@ window.marked = marked;
async function refreshState() { async function refreshState() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('api/state');
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json(); const s = await resp.json();
if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; } if (!headerSet) { setHeader(s.label, s.qualified_label, s.dashboard_port); headerSet = true; }
@ -1516,8 +1518,10 @@ window.marked = marked;
// the composer. Geometry unchanged — `.agent-main` and // the composer. Geometry unchanged — `.agent-main` and
// `.terminal-wrap` both `inset: 0` fill the same area. // `.terminal-wrap` both `inset: 0` fill the same area.
pillAnchor: $('agent-main'), pillAnchor: $('agent-main'),
historyUrl: '/events/history', // Path-relative so the page mounted under a nginx prefix
streamUrl: '/events/stream', // (e.g. /agent/<name>/) still hits the right SSE upstream (#14).
historyUrl: 'events/history',
streamUrl: 'events/stream',
renderers: { renderers: {
turn_start(ev, api) { turn_start(ev, api) {
if (api.fromHistory) openTurnsFromHistory += 1; if (api.fromHistory) openTurnsFromHistory += 1;

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>hyperhive agent</title> <title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="/icon"> <link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="/static/agent.css"> <link rel="stylesheet" href="static/agent.css">
</head> </head>
<body class="agent-shell"> <body class="agent-shell">
@ -14,9 +14,12 @@
height on the left as the identity anchor; flyout pills + an height on the left as the identity anchor; flyout pills + an
overflow menu trigger sit on the right. Frosted glass over the overflow menu trigger sit on the right. Frosted glass over the
terminal — backdrop-filter blur shows the scrolled terminal terminal — backdrop-filter blur shows the scrolled terminal
text behind. --> text behind.
All asset / API hrefs are relative — see docs/web-ui.md::Per-agent
relative paths for why (#14). -->
<header class="agent-header" id="agent-header"> <header class="agent-header" id="agent-header">
<img class="agent-icon" src="/icon" alt=""> <img class="agent-icon" src="icon" alt="">
<div class="agent-header-main"> <div class="agent-header-main">
<div class="agent-header-row agent-header-title-row"> <div class="agent-header-row agent-header-title-row">
@ -104,6 +107,6 @@
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and <!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js. --> the marked npm package into app.js. -->
<script type="module" src="/static/app.js" defer></script> <script type="module" src="static/app.js" defer></script>
</body> </body>
</html> </html>

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen</title> <title>screen</title>
<link rel="icon" type="image/svg+xml" href="/icon"> <link rel="icon" type="image/svg+xml" href="icon">
<style> <style>
/* Catppuccin Mocha palette (mirrors base.css) */ /* Catppuccin Mocha palette (mirrors base.css) */
:root { :root {
@ -82,7 +82,7 @@ canvas { display: block; cursor: default; }
<body> <body>
<div id="toolbar"> <div id="toolbar">
<strong>🖥 screen</strong> <strong>🖥 screen</strong>
<a href="/" title="back to agent page">← agent</a> <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="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="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> <button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
@ -206,8 +206,14 @@ canvas { display: block; cursor: default; }
} }
// --- WebSocket connection --- // --- WebSocket connection ---
// Path-relative so the agent page mounted under a prefix
// (e.g. /agent/<name>/screen via nginx, #14) still hits the right
// upstream. `document.baseURI` resolves against the page's URL;
// swapping protocol on top gives ws(s)://host/<prefix>/screen/ws.
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`); const wsUrl = new URL('screen/ws', document.baseURI);
wsUrl.protocol = proto + ':';
const ws = new WebSocket(wsUrl.toString());
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); }; ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };

View file

@ -3,8 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>hyperhive agent — stats</title> <title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="/icon"> <link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="/static/agent.css"> <link rel="stylesheet" href="static/agent.css">
<style> <style>
.stats-nav { display: flex; gap: 0.75rem; align-items: baseline; margin-bottom: 0.5rem; } .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 { color: var(--cyan); text-decoration: none; }
@ -63,7 +63,7 @@
<body> <body>
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre> <pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
<div class="stats-nav"> <div class="stats-nav">
<a id="back-link" href="/">← live</a> <a id="back-link" href="./">← live</a>
<a id="dashboard-link" href="#">dashboard</a> <a id="dashboard-link" href="#">dashboard</a>
<h2 id="title" style="margin: 0;">◆ … ◆</h2> <h2 id="title" style="margin: 0;">◆ … ◆</h2>
</div> </div>
@ -93,6 +93,6 @@
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep <!-- Chart.js is now bundled into stats.js by esbuild (npm dep
chart.js@4.4.4), so the page works offline / on operator chart.js@4.4.4), so the page works offline / on operator
machines without internet egress. No SRI hash to maintain. --> machines without internet egress. No SRI hash to maintain. -->
<script type="module" src="/static/stats.js" defer></script> <script type="module" src="static/stats.js" defer></script>
</body> </body>
</html> </html>

View file

@ -306,7 +306,7 @@ window.Chart = Chart;
async function loadStats() { async function loadStats() {
try { try {
const resp = await fetch('/api/stats?window=' + encodeURIComponent(currentWindow)); const resp = await fetch('api/stats?window=' + encodeURIComponent(currentWindow));
if (!resp.ok) throw new Error('http ' + resp.status); if (!resp.ok) throw new Error('http ' + resp.status);
const snap = await resp.json(); const snap = await resp.json();
render(snap); render(snap);
@ -317,7 +317,7 @@ window.Chart = Chart;
async function loadIdentity() { async function loadIdentity() {
try { try {
const resp = await fetch('/api/state'); const resp = await fetch('api/state');
if (!resp.ok) return; if (!resp.ok) return;
const s = await resp.json(); const s = await resp.json();
document.title = 'stats · ' + s.label; document.title = 'stats · ' + s.label;