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:
parent
003b36c4a0
commit
37d99ed118
6 changed files with 70 additions and 27 deletions
|
|
@ -143,6 +143,36 @@ Both bind their listeners with `SO_REUSEADDR` via
|
|||
exponential backoff capped at 2s) so an nspawn restart that races
|
||||
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
|
||||
|
||||
The dashboard (`/`) has a fixed chrome header at the top and a
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ window.marked = marked;
|
|||
}),
|
||||
);
|
||||
const start = el('form', {
|
||||
action: '/login/start', method: 'POST', 'data-async': '',
|
||||
action: 'login/start', method: 'POST', 'data-async': '',
|
||||
});
|
||||
start.append(
|
||||
el('button', { type: 'submit', class: 'btn btn-login' }, '◆ ST4RT L0G1N'),
|
||||
|
|
@ -358,7 +358,7 @@ window.marked = marked;
|
|||
}
|
||||
if (!s.finished) {
|
||||
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
|
||||
// type="password" so a shoulder-surfer / screenshot doesn't
|
||||
|
|
@ -397,7 +397,7 @@ window.marked = marked;
|
|||
root.append(code);
|
||||
}
|
||||
const cancel = el('form', {
|
||||
action: '/login/cancel', method: 'POST', 'data-async': '',
|
||||
action: 'login/cancel', method: 'POST', 'data-async': '',
|
||||
style: 'margin-top: 0.4em;',
|
||||
});
|
||||
cancel.append(el('button', { type: 'submit', class: 'btn btn-cancel' }, 'cancel + kill'));
|
||||
|
|
@ -437,7 +437,7 @@ window.marked = marked;
|
|||
|
||||
async function postModel(name) {
|
||||
try {
|
||||
const resp = await fetch('/api/model', {
|
||||
const resp = await fetch('api/model', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ model: name }),
|
||||
|
|
@ -469,10 +469,12 @@ window.marked = marked;
|
|||
if (termAPI) termAPI.row('turn-end-fail', '✗ ' + label + ' failed: ' + err);
|
||||
}
|
||||
}
|
||||
const postCancelTurn = () => postSimple('/api/cancel', '/cancel');
|
||||
const postCompact = () => postSimple('/api/compact', '/compact');
|
||||
const postNewSession = () => postSimple('/api/new-session', '/new-session');
|
||||
const postLogout = () => postSimple('/api/logout', '/logout');
|
||||
// First arg is the URL path (relative to document base, #14);
|
||||
// second is the slash-command label rendered in the local note.
|
||||
const postCancelTurn = () => postSimple('api/cancel', '/cancel');
|
||||
const postCompact = () => postSimple('api/compact', '/compact');
|
||||
const postNewSession = () => postSimple('api/new-session', '/new-session');
|
||||
const postLogout = () => postSimple('api/logout', '/logout');
|
||||
|
||||
function handleSlashCommand(line) {
|
||||
if (!termAPI) return false;
|
||||
|
|
@ -548,7 +550,7 @@ window.marked = marked;
|
|||
if (!termInputRendered) {
|
||||
slot.innerHTML = '';
|
||||
const form = el('form', {
|
||||
action: '/send', method: 'POST',
|
||||
action: 'send', method: 'POST',
|
||||
class: 'sendform-term', 'data-async': '',
|
||||
});
|
||||
const ta = el('textarea', {
|
||||
|
|
@ -668,7 +670,7 @@ window.marked = marked;
|
|||
// keeps the pill count at zero rather than surfacing a stale chrome.
|
||||
async function refreshLooseEnds() {
|
||||
try {
|
||||
const resp = await fetch('/api/loose-ends');
|
||||
const resp = await fetch('api/loose-ends');
|
||||
if (!resp.ok) {
|
||||
renderLooseEnds([]);
|
||||
return;
|
||||
|
|
@ -1084,7 +1086,7 @@ window.marked = marked;
|
|||
|
||||
async function refreshState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch('api/state');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const s = await resp.json();
|
||||
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
|
||||
// `.terminal-wrap` both `inset: 0` fill the same area.
|
||||
pillAnchor: $('agent-main'),
|
||||
historyUrl: '/events/history',
|
||||
streamUrl: '/events/stream',
|
||||
// Path-relative so the page mounted under a nginx prefix
|
||||
// (e.g. /agent/<name>/) still hits the right SSE upstream (#14).
|
||||
historyUrl: 'events/history',
|
||||
streamUrl: 'events/stream',
|
||||
renderers: {
|
||||
turn_start(ev, api) {
|
||||
if (api.fromHistory) openTurnsFromHistory += 1;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
<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">
|
||||
<link rel="icon" type="image/svg+xml" href="icon">
|
||||
<link rel="stylesheet" href="static/agent.css">
|
||||
</head>
|
||||
<body class="agent-shell">
|
||||
|
||||
|
|
@ -14,9 +14,12 @@
|
|||
height on the left as the identity anchor; flyout pills + an
|
||||
overflow menu trigger sit on the right. Frosted glass over the
|
||||
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">
|
||||
<img class="agent-icon" src="/icon" alt="">
|
||||
<img class="agent-icon" src="icon" alt="">
|
||||
|
||||
<div class="agent-header-main">
|
||||
<div class="agent-header-row agent-header-title-row">
|
||||
|
|
@ -104,6 +107,6 @@
|
|||
|
||||
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<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">
|
||||
<link rel="icon" type="image/svg+xml" href="icon">
|
||||
<style>
|
||||
/* Catppuccin Mocha palette (mirrors base.css) */
|
||||
:root {
|
||||
|
|
@ -82,7 +82,7 @@ canvas { display: block; cursor: default; }
|
|||
<body>
|
||||
<div id="toolbar">
|
||||
<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="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>
|
||||
|
|
@ -206,8 +206,14 @@ canvas { display: block; cursor: default; }
|
|||
}
|
||||
|
||||
// --- 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 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.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
<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">
|
||||
<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; }
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
<body>
|
||||
<pre class="banner">░▒▓█▓▒░ … ░▒▓█▓▒░ hyperhive ag3nt · stats ░▒▓█▓▒░</pre>
|
||||
<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>
|
||||
<h2 id="title" style="margin: 0;">◆ … ◆</h2>
|
||||
</div>
|
||||
|
|
@ -93,6 +93,6 @@
|
|||
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
|
||||
chart.js@4.4.4), so the page works offline / on operator
|
||||
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>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -306,7 +306,7 @@ window.Chart = Chart;
|
|||
|
||||
async function loadStats() {
|
||||
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);
|
||||
const snap = await resp.json();
|
||||
render(snap);
|
||||
|
|
@ -317,7 +317,7 @@ window.Chart = Chart;
|
|||
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch('api/state');
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
document.title = 'stats · ' + s.label;
|
||||
|
|
|
|||
Loading…
Reference in a new issue