Every sub-page tabbar (logs.html, credentials.html, core.html,
builds.html) hand-wrote the same <nav class="hive-tabbar"><a
class="hive-tab">...</a></nav> boilerplate and then called
createTabStrip() on it after the fact. Add <hive-tab-strip>, a
markup-owning custom element (same reuse-boundary pattern as
<hive-menu>/<hive-side-panel>) that renders that markup from a
declarative tabs list, then wires the existing createTabStrip()
behaviour over what it just rendered — no behaviour duplication.
Convert all four sites to use it: each page's JS now calls
`.configure({ tabs, defaultId, onShow })` on the tabbar element instead
of `createTabStrip(el, opts)`, and configure() returns the identical
{ show, active } shape so nothing downstream changes. builds.js's
rebuild-queue count pill (builds-tab-count-rebuild) is expressed as a
tab's `badgeId` and renders nested in the same spot.
The dashboard's own tabbar and the two no-pane stats time-range
pickers are a different markup/behaviour shape and are intentionally
left alone.
522 lines
20 KiB
JavaScript
522 lines
20 KiB
JavaScript
// CR3D3NTIALS page entry (/credentials.html).
|
|
//
|
|
// Operator surface to provision per-agent credentials without editing the
|
|
// agent's config repo. Two sub-tabs, sharing one agent picker:
|
|
// MATRIX — external matrix account login (carried over verbatim from the
|
|
// old /matrix-accounts.html — see matrix_accounts.rs backend doc
|
|
// comments for the account/status contract + endpoint shapes).
|
|
// GITHUB — single-account PAT paste against /api/github-account
|
|
// (GET -> {present}, POST form-encoded {agent, token} ->
|
|
// {ok:true}; same error_response shape as matrix-account-login).
|
|
// No account name / homeserver / login mode, and no
|
|
// live/heartbeat concept for a static PAT — just present/absent.
|
|
// FORGES — external forge accounts, entirely dashboard-provisioned (no
|
|
// host-side config): GET /api/extra-forges?agent= lists the
|
|
// agent's stored {label, base_url} pairs, POST
|
|
// /api/extra-forge-account (form agent/label/base_url/token/
|
|
// action=add|remove) stores or removes one. No remote account
|
|
// creation — the operator makes the token on the external forge
|
|
// themselves and pastes it in, same trust model as GITHUB.
|
|
// Per-tab detail comments live next to their section below.
|
|
|
|
import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
|
|
import { el } from '@hive/shared/dom.js';
|
|
import '@hive/shared/hive-tab-strip.js';
|
|
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
|
|
|
let agents = [];
|
|
// agent name → container running (bool), from /api/state. Cross-referenced by
|
|
// the live dot: a `live: true` account whose container is DOWN is definitively
|
|
// stale (the daemon can't be up if the container isn't), so we flag it rather
|
|
// than show a lying green. `undefined` (agent not in the map) = unknown → we
|
|
// don't flag stale.
|
|
const containerRunning = new Map();
|
|
|
|
async function loadState() {
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) return;
|
|
const s = await resp.json();
|
|
renderServerWarnings(s.server_warnings);
|
|
// `/api/state` exposes the live roster under `containers` (each entry an
|
|
// object carrying `.name` + `.running`); there is no top-level `agents`
|
|
// field, so the picker stays compatible with both string + object shapes.
|
|
const containers = (s.containers || [])
|
|
.map((a) => (typeof a === 'string' ? { name: a } : a))
|
|
.filter((c) => c && c.name);
|
|
agents = containers.map((c) => c.name).sort();
|
|
containerRunning.clear();
|
|
for (const c of containers) containerRunning.set(c.name, !!c.running);
|
|
} catch {
|
|
// best-effort: on a failed state read the picker renders empty
|
|
// ("— no agents —") and the submit guard blocks until an agent is
|
|
// selected, rather than guessing a roster.
|
|
}
|
|
}
|
|
|
|
function renderAgentPicker() {
|
|
const sel = $('ma-agent');
|
|
sel.replaceChildren();
|
|
if (!agents.length) {
|
|
sel.append(el('option', { value: '' }, '— no agents —'));
|
|
return;
|
|
}
|
|
sel.append(el('option', { value: '' }, '— select agent —'));
|
|
for (const a of agents) sel.append(el('option', { value: a }, a));
|
|
}
|
|
|
|
// ─── shape-agnostic error-body parsing (shared by both tabs' submit
|
|
// handlers) ───────────────────────────────────────────────────────────────
|
|
// The BE error-body shape is in transition: today hive-c0re's
|
|
// error_response sends a bare plain-text body; the RFC 9457 rework moves it
|
|
// to application/problem+json ({ type, title, detail, … }). Read shape-
|
|
// agnostically: pull the body once as text, and if it parses as JSON
|
|
// surface `detail` (problem+json) → `error`/`title` fallback, else use the
|
|
// raw text. A bare HTTP code is the last resort.
|
|
async function readErrorBody(resp) {
|
|
try {
|
|
const raw = (await resp.text()).trim();
|
|
if (raw && (raw[0] === '{' || raw[0] === '[')) {
|
|
try {
|
|
const body = JSON.parse(raw);
|
|
return body.detail || body.error || body.title || raw;
|
|
} catch { /* not JSON after all — keep the raw text */ }
|
|
}
|
|
return raw;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
// ─── MATRIX tab ────────────────────────────────────────────────────────────
|
|
// Live status dot — the daemon heartbeats every ~30s (advances as_of_unix),
|
|
// so a stalled as_of = daemon dead, not just stale snapshot:
|
|
// green live + running + fresh = online
|
|
// dim green live but as_of stale > ~90s = heartbeat stopped
|
|
// amber live + container DOWN = definitively stale
|
|
// amber token_present + !live = provisioned but offline
|
|
// grey no token = not provisioned
|
|
// Container state takes precedence; as_of_unix is tooltipped for freshness.
|
|
// v1 backend (no `live` field) falls back to token-present rendering.
|
|
|
|
async function loadAccounts(agent) {
|
|
const list = $('ma-list');
|
|
if (!agent) {
|
|
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
|
|
return;
|
|
}
|
|
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
|
let data;
|
|
try {
|
|
const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent));
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
data = await resp.json();
|
|
} catch (err) {
|
|
list.replaceChildren(el('p', { class: 'err' },
|
|
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
|
|
return;
|
|
}
|
|
const accounts = data.accounts || [];
|
|
const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null;
|
|
// `false` only when the container is explicitly down; `undefined` (unknown,
|
|
// e.g. a failed /api/state read) is treated as not-down so we never flag a
|
|
// false stale.
|
|
const running = containerRunning.get(agent);
|
|
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
|
|
// while it's alive — this is a heartbeat, and a stalled value is meaningful.
|
|
const ageSecs = asOf != null
|
|
? Math.max(0, Math.floor(Date.now() / 1000) - asOf)
|
|
: null;
|
|
const asOfText = asOf != null
|
|
? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago'
|
|
: 'no daemon snapshot yet';
|
|
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
|
|
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
|
|
const STALE_AGE_SECS = 90;
|
|
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
|
|
list.replaceChildren();
|
|
if (!accounts.length) {
|
|
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.'));
|
|
return;
|
|
}
|
|
const ul = el('ul', { class: 'ma-accounts' });
|
|
for (const acc of accounts) {
|
|
const present = !!acc.token_present;
|
|
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
|
|
// undefined, fall back to the v1 token-present rendering so the page
|
|
// degrades cleanly before the snapshot backend deploys.
|
|
let cls; let statusText; let dotTitle;
|
|
if (acc.live === undefined) {
|
|
cls = present ? 'ok' : 'absent';
|
|
statusText = present ? 'token stored ✓' : 'no token';
|
|
dotTitle = present ? 'token stored' : 'no token yet';
|
|
} else if (acc.live && running === false) {
|
|
// container down ⟹ daemon down ⟹ a "live" snapshot is stale.
|
|
cls = 'stale';
|
|
statusText = 'container stopped';
|
|
dotTitle = 'container is stopped — live status is stale. ' + asOfText;
|
|
} else if (acc.live && staleByAge) {
|
|
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
|
|
// advanced in > ~90s while the container is NOT down — the daemon stopped
|
|
// publishing, so the "live" is no longer trustworthy. Keep the green
|
|
// family but dim it (distinct from the amber container-down 'stale').
|
|
cls = 'live stale-age';
|
|
statusText = 'online · no heartbeat';
|
|
dotTitle = 'snapshot says live but the daemon heartbeat stalled '
|
|
+ fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. '
|
|
+ asOfText;
|
|
} else if (acc.live) {
|
|
cls = 'live';
|
|
statusText = 'online ✓';
|
|
dotTitle = asOfText;
|
|
} else if (present) {
|
|
cls = 'offline';
|
|
statusText = 'token stored · offline';
|
|
dotTitle = 'provisioned but not live. ' + asOfText;
|
|
} else {
|
|
cls = 'absent';
|
|
statusText = 'no token';
|
|
dotTitle = 'no token yet';
|
|
}
|
|
ul.append(el('li', { class: 'ma-account' },
|
|
el('span', { class: 'ma-dot ' + cls, title: dotTitle }),
|
|
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'),
|
|
acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null,
|
|
el('span', { class: 'ma-hs' }, acc.homeserver || '—'),
|
|
el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText),
|
|
));
|
|
}
|
|
list.append(ul);
|
|
}
|
|
|
|
// Show only the fields for the selected login method, and DISABLE the
|
|
// hidden section's inputs so they don't ride along in the FormData (both
|
|
// sections carry a `user_id` field, so without this the wrong one — or
|
|
// both — would be submitted).
|
|
function toggleModeFields() {
|
|
const mode = document.querySelector('input[name="mode"]:checked');
|
|
const value = mode ? mode.value : 'password';
|
|
const pw = $('ma-pw-fields');
|
|
const tok = $('ma-token-fields');
|
|
pw.hidden = value !== 'password';
|
|
tok.hidden = value !== 'token';
|
|
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; });
|
|
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; });
|
|
}
|
|
|
|
function clearSecrets(formEl) {
|
|
formEl.querySelectorAll('input[type="password"], input[name="token"]')
|
|
.forEach((i) => { i.value = ''; });
|
|
}
|
|
|
|
async function submitLogin(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $('ma-result');
|
|
out.className = 'ma-result';
|
|
out.textContent = '';
|
|
|
|
const agent = $('ma-agent').value;
|
|
if (!agent) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = 'select an agent first.';
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData(formEl);
|
|
fd.set('agent', agent);
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'logging in…';
|
|
|
|
try {
|
|
const resp = await fetch('/api/matrix-account-login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
// Success is 200 + JSON { ok, user_id }.
|
|
let body = {};
|
|
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
|
|
if (body.ok) {
|
|
out.className = 'ma-result ok';
|
|
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.';
|
|
clearSecrets(formEl);
|
|
loadAccounts(agent);
|
|
} else {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ login failed (unexpected response).';
|
|
clearSecrets(formEl);
|
|
}
|
|
} else {
|
|
const msg = await readErrorBody(resp);
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
|
|
clearSecrets(formEl);
|
|
}
|
|
} catch (err) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
|
|
|
async function loadGithubStatus(agent) {
|
|
const status = $('gh-status');
|
|
if (!agent) {
|
|
status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.'));
|
|
return;
|
|
}
|
|
status.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
|
let data;
|
|
try {
|
|
const resp = await fetch('/api/github-account?agent=' + encodeURIComponent(agent));
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
data = await resp.json();
|
|
} catch (err) {
|
|
status.replaceChildren(el('p', { class: 'err' },
|
|
'could not load status: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
|
|
return;
|
|
}
|
|
const present = !!data.present;
|
|
status.replaceChildren(el('div', { class: 'gh-status-line' },
|
|
el('span', { class: 'gh-dot ' + (present ? 'present' : 'absent') }),
|
|
el('span', { class: 'gh-status-text ' + (present ? 'present' : 'absent') },
|
|
present ? 'token stored ✓' : 'not set'),
|
|
));
|
|
}
|
|
|
|
async function submitGithub(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $('gh-result');
|
|
out.className = 'ma-result';
|
|
out.textContent = '';
|
|
|
|
const agent = $('ma-agent').value;
|
|
if (!agent) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = 'select an agent first.';
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData(formEl);
|
|
fd.set('agent', agent);
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'storing…';
|
|
|
|
try {
|
|
const resp = await fetch('/api/github-account', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
let body = {};
|
|
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
|
|
if (body.ok) {
|
|
out.className = 'ma-result ok';
|
|
out.textContent = '✓ token stored.';
|
|
clearSecrets(formEl);
|
|
loadGithubStatus(agent);
|
|
} else {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ store failed (unexpected response).';
|
|
clearSecrets(formEl);
|
|
}
|
|
} else {
|
|
const msg = await readErrorBody(resp);
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
|
clearSecrets(formEl);
|
|
}
|
|
} catch (err) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ─── FORGES tab ─────────────────────────────────────────────────────────
|
|
// Entirely dashboard-provisioned, no host-side nix config: per-agent list
|
|
// (GET /api/extra-forges?agent=, derived from the agent's own
|
|
// forge-<label>-token files) + an add form (POST /api/extra-forge-account,
|
|
// form label/base_url/token, action=add) and a remove button per row
|
|
// (same POST, action=remove). No remote account creation — purely local
|
|
// bookkeeping for a token the operator already created on the external
|
|
// forge themselves.
|
|
|
|
async function loadForgeAccounts(agent) {
|
|
const list = $('ef-list');
|
|
if (!agent) {
|
|
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.'));
|
|
return;
|
|
}
|
|
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
|
let forges;
|
|
try {
|
|
const resp = await fetch('/api/extra-forges?agent=' + encodeURIComponent(agent));
|
|
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
|
forges = (await resp.json()).forges || [];
|
|
} catch (err) {
|
|
list.replaceChildren(el('p', { class: 'err' },
|
|
'could not load forge accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
|
|
return;
|
|
}
|
|
|
|
list.replaceChildren();
|
|
if (!forges.length) {
|
|
list.replaceChildren(el('p', { class: 'meta' }, 'no forge accounts stored for this agent.'));
|
|
return;
|
|
}
|
|
const ul = el('ul', { class: 'ma-accounts' });
|
|
for (const forge of forges) {
|
|
const btn = el('button', { class: 'btn', type: 'button' }, 'remove');
|
|
btn.addEventListener('click', () => onForgeRemoveClick(agent, forge, btn));
|
|
ul.append(el('li', { class: 'ma-account' },
|
|
el('span', { class: 'ma-dot ok' }),
|
|
el('span', { class: 'ma-name' }, forge.label),
|
|
el('span', { class: 'ma-hs' }, forge.base_url || '—'),
|
|
el('span', { class: 'ma-status ok' }, 'token stored ✓'),
|
|
btn,
|
|
));
|
|
}
|
|
list.append(ul);
|
|
}
|
|
|
|
async function onForgeRemoveClick(agent, forge, btn) {
|
|
const r = await themedConfirm({
|
|
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
|
|
danger: true,
|
|
confirmLabel: '⊘ remove',
|
|
});
|
|
if (!r) return;
|
|
|
|
btn.disabled = true;
|
|
const orig = btn.textContent;
|
|
btn.textContent = 'removing…';
|
|
try {
|
|
const resp = await fetch('/api/extra-forge-account', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({ agent, label: forge.label, action: 'remove' }),
|
|
});
|
|
if (resp.ok) {
|
|
loadForgeAccounts(agent);
|
|
return;
|
|
}
|
|
const msg = await readErrorBody(resp);
|
|
btn.textContent = orig;
|
|
btn.disabled = false;
|
|
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
|
|
} catch (err) {
|
|
btn.textContent = orig;
|
|
btn.disabled = false;
|
|
themedToast('✗ request failed: ' + String(err), { type: 'error' });
|
|
}
|
|
}
|
|
|
|
async function submitForgeAccount(e) {
|
|
e.preventDefault();
|
|
const formEl = e.target;
|
|
const out = $('ef-result');
|
|
out.className = 'ma-result';
|
|
out.textContent = '';
|
|
|
|
const agent = $('ma-agent').value;
|
|
if (!agent) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = 'select an agent first.';
|
|
return;
|
|
}
|
|
|
|
const fd = new FormData(formEl);
|
|
fd.set('agent', agent);
|
|
fd.set('action', 'add');
|
|
|
|
const btn = formEl.querySelector('button[type="submit"]');
|
|
const orig = btn.textContent;
|
|
btn.disabled = true;
|
|
btn.textContent = 'storing…';
|
|
|
|
try {
|
|
const resp = await fetch('/api/extra-forge-account', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams(fd),
|
|
});
|
|
|
|
if (resp.ok) {
|
|
let body = {};
|
|
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
|
|
if (body.ok) {
|
|
out.className = 'ma-result ok';
|
|
out.textContent = '✓ forge account stored.';
|
|
clearSecrets(formEl);
|
|
loadForgeAccounts(agent);
|
|
} else {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ store failed (unexpected response).';
|
|
clearSecrets(formEl);
|
|
}
|
|
} else {
|
|
const msg = await readErrorBody(resp);
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
|
clearSecrets(formEl);
|
|
}
|
|
} catch (err) {
|
|
out.className = 'ma-result err';
|
|
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = orig;
|
|
}
|
|
}
|
|
|
|
// ─── init ─────────────────────────────────────────────────────────────
|
|
|
|
async function onAgentChange(agent) {
|
|
loadAccounts(agent);
|
|
loadGithubStatus(agent);
|
|
loadForgeAccounts(agent);
|
|
}
|
|
|
|
async function init() {
|
|
await loadState();
|
|
renderAgentPicker();
|
|
$('ma-agent').addEventListener('change', (e) => onAgentChange(e.target.value));
|
|
document.querySelectorAll('input[name="mode"]')
|
|
.forEach((r) => r.addEventListener('change', toggleModeFields));
|
|
toggleModeFields();
|
|
$('ma-form').addEventListener('submit', submitLogin);
|
|
$('gh-form').addEventListener('submit', submitGithub);
|
|
$('ef-form').addEventListener('submit', submitForgeAccount);
|
|
|
|
document.getElementById('cred-tabbar').configure({
|
|
tabs: [
|
|
{ id: 'matrix', label: 'MATRIX' },
|
|
{ id: 'github', label: 'GITHUB' },
|
|
{ id: 'forges', label: 'FORGES' },
|
|
],
|
|
defaultId: 'matrix',
|
|
});
|
|
|
|
onAgentChange('');
|
|
}
|
|
|
|
init();
|