treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
This commit is contained in:
parent
5d24bedd60
commit
39b95c2ede
203 changed files with 10090 additions and 6085 deletions
|
|
@ -21,33 +21,33 @@
|
|||
// `tower_http::ServeDir` fallback; the layout above keeps every URL
|
||||
// the HTML references reachable without rewriting paths.
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p) => resolve(here, 'src', p);
|
||||
const dist = (p) => resolve(here, 'dist', p);
|
||||
const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||
const src = (p) => resolve(here, "src", p);
|
||||
const dist = (p) => resolve(here, "dist", p);
|
||||
const staticDir = (p) => resolve(here, "dist", "static", p);
|
||||
|
||||
rmSync(dist(''), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(''), { recursive: true });
|
||||
rmSync(dist(""), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(""), { recursive: true });
|
||||
|
||||
// The stats page's own bundle — chart.js/auto, its own deps.
|
||||
await build({
|
||||
entryPoints: [src('stats.js')],
|
||||
outdir: staticDir(''),
|
||||
entryPoints: [src("stats.js")],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
logLevel: "info",
|
||||
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
|
||||
// text (see dashboard/build.mjs's matching comment) — same reasoning
|
||||
// applies here.
|
||||
loader: { '.css': 'text' },
|
||||
loader: { ".css": "text" },
|
||||
});
|
||||
|
||||
// The main agent page (hyperhive#3685's Preact rewrite — app.js,
|
||||
|
|
@ -58,17 +58,17 @@ await build({
|
|||
// modal.js`) and JSX transpilation, same split swarm-ui's own build
|
||||
// already makes.
|
||||
await build({
|
||||
entryPoints: [src('main.tsx')],
|
||||
outfile: staticDir('main.js'),
|
||||
entryPoints: [src("main.tsx")],
|
||||
outfile: staticDir("main.js"),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: "info",
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "preact",
|
||||
loader: { ".css": "css" },
|
||||
});
|
||||
|
||||
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
|
||||
|
|
@ -76,18 +76,18 @@ await build({
|
|||
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
|
||||
// `agent.css`'s @import lines pull in shared base.css + terminal.css from
|
||||
// the @hive/shared workspace dep.
|
||||
for (const entry of ['colors.css', 'theme.css', 'agent.css']) {
|
||||
for (const entry of ["colors.css", "theme.css", "agent.css"]) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
bundle: true,
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: 'info',
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
});
|
||||
}
|
||||
|
||||
for (const html of ['index.html', 'stats.html', 'screen.html']) {
|
||||
for (const html of ["index.html", "stats.html", "screen.html"]) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
console.log('agent build ok →', dist(''));
|
||||
console.log("agent build ok →", dist(""));
|
||||
|
|
|
|||
|
|
@ -4,55 +4,61 @@
|
|||
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
|
||||
// no credentials has no other web-UI path back online, so this isn't
|
||||
// optional polish.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { BadgeTone } from '@hive/shared/badge.js';
|
||||
import { SettingsMenu } from '@hive/shared/settings-menu.js';
|
||||
import { useApplyThemeOverride } from '@hive/shared/theme-apply.js';
|
||||
import { useApplyMotionOverride } from '@hive/shared/motion-apply.js';
|
||||
import { Header } from './components/Header.js';
|
||||
import { MetaNav } from './components/MetaNav.js';
|
||||
import { ExpandDetailsSetting } from './components/ExpandDetailsSetting.js';
|
||||
import { StatusChips } from './components/StatusChips.js';
|
||||
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
|
||||
import { HeaderPill } from './components/HeaderPill.js';
|
||||
import { SidePanel } from './components/SidePanel.js';
|
||||
import { InboxPanel } from './components/InboxPanel.js';
|
||||
import { TodosPanel } from './components/TodosPanel.js';
|
||||
import { TermInput } from './components/TermInput.js';
|
||||
import { LoginFlow } from './components/LoginFlow.js';
|
||||
import { useAgentState } from './hooks/useAgentState.js';
|
||||
import { useTodos } from './hooks/useTodos.js';
|
||||
import { fmtAge, fmtTokens } from './lib/format.js';
|
||||
import { resolveDashboardBase } from './lib/dashboardBase.js';
|
||||
import { submitPauseResume } from './lib/pauseAction.js';
|
||||
import { postModel, postEffort } from './lib/modelEffort.js';
|
||||
import { postCancelTurn } from './lib/termActions.js';
|
||||
import type { TokenUsage } from './types.js';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { BadgeTone } from "@hive/shared/badge.js";
|
||||
import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||
import { Header } from "./components/Header.js";
|
||||
import { MetaNav } from "./components/MetaNav.js";
|
||||
import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js";
|
||||
import { StatusChips } from "./components/StatusChips.js";
|
||||
import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js";
|
||||
import { HeaderPill } from "./components/HeaderPill.js";
|
||||
import { SidePanel } from "./components/SidePanel.js";
|
||||
import { InboxPanel } from "./components/InboxPanel.js";
|
||||
import { TodosPanel } from "./components/TodosPanel.js";
|
||||
import { TermInput } from "./components/TermInput.js";
|
||||
import { LoginFlow } from "./components/LoginFlow.js";
|
||||
import { useAgentState } from "./hooks/useAgentState.js";
|
||||
import { useTodos } from "./hooks/useTodos.js";
|
||||
import { fmtAge, fmtTokens } from "./lib/format.js";
|
||||
import { resolveDashboardBase } from "./lib/dashboardBase.js";
|
||||
import { submitPauseResume } from "./lib/pauseAction.js";
|
||||
import { postModel, postEffort } from "./lib/modelEffort.js";
|
||||
import { postCancelTurn } from "./lib/termActions.js";
|
||||
import type { TokenUsage } from "./types.js";
|
||||
|
||||
type OpenPanel = 'inbox' | 'todos' | null;
|
||||
type OpenPanel = "inbox" | "todos" | null;
|
||||
|
||||
// Storage keys this page owns for `@hive/shared`'s settings mechanism —
|
||||
// kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride`
|
||||
// (mounted once below) and `<SettingsMenu>` (in `pills`, further down)
|
||||
// always agree on which browser-local key they're reading/writing.
|
||||
const THEME_KEY = 'agent:theme-override';
|
||||
const MOTION_KEY = 'agent:motion-override';
|
||||
const THEME_KEY = "agent:theme-override";
|
||||
const MOTION_KEY = "agent:motion-override";
|
||||
|
||||
const ALIVE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
||||
online: { glyph: '●', text: 'alive', tone: 'positive' },
|
||||
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' },
|
||||
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' },
|
||||
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' },
|
||||
const ALIVE_LABELS: Record<
|
||||
string,
|
||||
{ glyph: string; text: string; tone: BadgeTone }
|
||||
> = {
|
||||
online: { glyph: "●", text: "alive", tone: "positive" },
|
||||
rate_limited: { glyph: "⊘", text: "rate limited", tone: "warning" },
|
||||
needs_login_idle: { glyph: "◌", text: "needs login", tone: "warning" },
|
||||
needs_login_in_progress: { glyph: "◌", text: "logging in", tone: "warning" },
|
||||
};
|
||||
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
|
||||
idle: { glyph: '💤', text: 'idle', tone: 'neutral' },
|
||||
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' },
|
||||
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' },
|
||||
const STATE_LABELS: Record<
|
||||
string,
|
||||
{ glyph: string; text: string; tone: BadgeTone }
|
||||
> = {
|
||||
idle: { glyph: "💤", text: "idle", tone: "neutral" },
|
||||
thinking: { glyph: "🧠", text: "thinking", tone: "accent" },
|
||||
compacting: { glyph: "📦", text: "compacting", tone: "warning" },
|
||||
};
|
||||
const STATE_TOOLTIPS: Record<string, string> = {
|
||||
offline: 'harness unreachable or claude not logged in',
|
||||
idle: 'turn loop running, no claude invocation in flight',
|
||||
thinking: 'claude is executing the current turn',
|
||||
offline: "harness unreachable or claude not logged in",
|
||||
idle: "turn loop running, no claude invocation in flight",
|
||||
thinking: "claude is executing the current turn",
|
||||
compacting: "operator-triggered /compact running on the persistent session",
|
||||
};
|
||||
|
||||
|
|
@ -61,7 +67,11 @@ const STATE_TOOLTIPS: Record<string, string> = {
|
|||
// title breakdown, not folded into the headline number).
|
||||
function tokenTotal(u: TokenUsage | null): number | null {
|
||||
if (!u) return null;
|
||||
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
||||
return (
|
||||
(u.input_tokens ?? 0) +
|
||||
(u.cache_read_input_tokens ?? 0) +
|
||||
(u.cache_creation_input_tokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function Root() {
|
||||
|
|
@ -96,13 +106,15 @@ export function Root() {
|
|||
state.qualified_label && state.qualified_label !== state.label
|
||||
? state.qualified_label
|
||||
: state.label;
|
||||
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`;
|
||||
document.title = state.hive_name
|
||||
? `${state.label} // ${state.hive_name}`
|
||||
: `${tab} // hyperhive`;
|
||||
}, [state?.label, state?.qualified_label, state?.hive_name]);
|
||||
const termInput = (
|
||||
<footer class="agent-composer">
|
||||
<TermInput
|
||||
label={state?.label ?? '…'}
|
||||
online={state?.status === 'online'}
|
||||
label={state?.label ?? "…"}
|
||||
online={state?.status === "online"}
|
||||
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
|
||||
onClear={() => liveStreamRef.current?.clear()}
|
||||
/>
|
||||
|
|
@ -111,8 +123,20 @@ export function Root() {
|
|||
|
||||
const pills = (
|
||||
<>
|
||||
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
|
||||
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
|
||||
<HeaderPill
|
||||
kind="inbox"
|
||||
icon="📬"
|
||||
label="inbox"
|
||||
count={state?.inbox.length ?? 0}
|
||||
onClick={() => setOpenPanel("inbox")}
|
||||
/>
|
||||
<HeaderPill
|
||||
kind="todos"
|
||||
icon="📋"
|
||||
label="todos"
|
||||
count={todos.length}
|
||||
onClick={() => setOpenPanel("todos")}
|
||||
/>
|
||||
{/* Needs a loaded snapshot for its data (links/forge_public_url,
|
||||
* dashboard_port) — omitted until the first /api/state resolves,
|
||||
* same as the old page's populateOverflowMenu (now deleted —
|
||||
|
|
@ -135,17 +159,28 @@ export function Root() {
|
|||
// component — SidePanel's slide/fade CSS transitions only fire on a
|
||||
// class change after mount, not on an already-open initial render, so
|
||||
// mount-on-open would skip both the open AND the close animation.
|
||||
const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : '';
|
||||
const panelTitle =
|
||||
openPanel === "inbox"
|
||||
? `inbox · ${state?.inbox.length ?? 0}`
|
||||
: openPanel === "todos"
|
||||
? `todos · ${todos.length}`
|
||||
: "";
|
||||
const panel = (
|
||||
<SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}>
|
||||
{openPanel === 'inbox' ? (
|
||||
<SidePanel
|
||||
open={openPanel !== null}
|
||||
title={panelTitle}
|
||||
onClose={() => setOpenPanel(null)}
|
||||
>
|
||||
{openPanel === "inbox" ? (
|
||||
<InboxPanel
|
||||
rows={state?.inbox ?? []}
|
||||
label={state?.label ?? ''}
|
||||
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''}
|
||||
label={state?.label ?? ""}
|
||||
dashboardBase={
|
||||
state ? resolveDashboardBase(state.dashboard_port) : ""
|
||||
}
|
||||
onCleared={refresh}
|
||||
/>
|
||||
) : openPanel === 'todos' ? (
|
||||
) : openPanel === "todos" ? (
|
||||
<TodosPanel todos={todos} onCleared={refreshTodos} />
|
||||
) : null}
|
||||
</SidePanel>
|
||||
|
|
@ -179,13 +214,22 @@ export function Root() {
|
|||
);
|
||||
}
|
||||
|
||||
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
||||
const alive = ALIVE_LABELS[state.status] ?? {
|
||||
glyph: "○",
|
||||
text: "offline",
|
||||
tone: "negative" as BadgeTone,
|
||||
};
|
||||
// Mirrors app.js's refreshState: any non-'online' status forces the
|
||||
// turn-state badge to 'offline' regardless of the server's last-known
|
||||
// turn_state (login/rate-limit takes visual priority over a stale
|
||||
// idle/thinking reading from before the harness went offline).
|
||||
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
|
||||
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
|
||||
const effectiveTurnState =
|
||||
state.status === "online" ? state.turn_state : "offline";
|
||||
const turnDef = STATE_LABELS[effectiveTurnState] ?? {
|
||||
glyph: "○",
|
||||
text: "offline",
|
||||
tone: "negative" as BadgeTone,
|
||||
};
|
||||
const stateAge = fmtAge(now - state.turn_state_since * 1000);
|
||||
// Consolidated status badge (mara: "why do we have separate alive
|
||||
// badge? ... one badge that shows thinking / idle / paused").
|
||||
|
|
@ -195,10 +239,20 @@ export function Root() {
|
|||
// thinking regardless of its last turn_state. Online + running →
|
||||
// turn-state + age, same as before.
|
||||
const primaryStatus =
|
||||
state.status !== 'online'
|
||||
? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined }
|
||||
state.status !== "online"
|
||||
? {
|
||||
glyph: alive.glyph,
|
||||
text: alive.text,
|
||||
tone: alive.tone,
|
||||
tooltip: undefined as string | undefined,
|
||||
}
|
||||
: state.paused
|
||||
? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined }
|
||||
? {
|
||||
glyph: "⏸",
|
||||
text: "paused",
|
||||
tone: "warning" as BadgeTone,
|
||||
tooltip: undefined,
|
||||
}
|
||||
: {
|
||||
glyph: turnDef.glyph,
|
||||
text: `${turnDef.text} · ${stateAge}`,
|
||||
|
|
@ -212,7 +266,10 @@ export function Root() {
|
|||
<>
|
||||
<Header
|
||||
label={state.label}
|
||||
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
|
||||
hiveLabel={
|
||||
[state.swarm_name, state.hive_name].filter(Boolean).join(" / ") ||
|
||||
null
|
||||
}
|
||||
pills={pills}
|
||||
>
|
||||
<StatusChips
|
||||
|
|
@ -234,22 +291,32 @@ export function Root() {
|
|||
costLabel={cost !== null ? fmtTokens(cost) : undefined}
|
||||
paused={state.paused}
|
||||
onTogglePause={() => {
|
||||
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause').then(
|
||||
() => refresh(),
|
||||
);
|
||||
submitPauseResume(
|
||||
resolveDashboardBase(state.dashboard_port),
|
||||
state.label,
|
||||
state.paused ? "resume" : "pause",
|
||||
).then(() => refresh());
|
||||
}}
|
||||
thinking={effectiveTurnState === 'thinking'}
|
||||
thinking={effectiveTurnState === "thinking"}
|
||||
onCancelTurn={() =>
|
||||
postCancelTurn().then((r) => {
|
||||
if (!r.ok) liveStreamRef.current?.pushNote(`✗ /cancel failed${r.detail ? ': ' + r.detail : ''}`);
|
||||
if (!r.ok)
|
||||
liveStreamRef.current?.pushNote(
|
||||
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
|
||||
);
|
||||
refresh();
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Header>
|
||||
<main className="agent-main">
|
||||
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
|
||||
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
|
||||
{state.status === "needs_login_idle" ||
|
||||
state.status === "needs_login_in_progress" ? (
|
||||
<LoginFlow
|
||||
status={state.status}
|
||||
session={state.session}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
) : null}
|
||||
<LiveStream ref={liveStreamRef} />
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@
|
|||
--agent-frost-blur: blur(12px) saturate(140%);
|
||||
}
|
||||
|
||||
html, body { height: 100%; margin: 0; }
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Legacy in-page layout retained for the sibling stats page
|
||||
(`stats.html`) which doesn't apply `body.agent-shell` and stays
|
||||
|
|
@ -58,7 +62,9 @@ body:not(.agent-shell):not(.screen-shell) {
|
|||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
|
||||
filter: drop-shadow(
|
||||
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
body.agent-shell {
|
||||
|
|
@ -69,9 +75,11 @@ body.agent-shell {
|
|||
/* Subtle radial accent to give the otherwise-flat full-screen
|
||||
surface some depth and reinforce the vibec0re mood. */
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
|
||||
transparent 60%),
|
||||
radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +166,8 @@ body.agent-shell {
|
|||
align-self: center;
|
||||
}
|
||||
|
||||
h2, h3 {
|
||||
h2,
|
||||
h3 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
|
|
@ -190,7 +199,10 @@ h2, h3 {
|
|||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 40%, transparent);
|
||||
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
text-shadow 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.agent-nav-link:hover {
|
||||
color: var(--fg);
|
||||
|
|
@ -230,7 +242,9 @@ h2, h3 {
|
|||
width: calc(100% - 3em);
|
||||
z-index: 10;
|
||||
}
|
||||
.agent-status-overlay:empty { display: none; }
|
||||
.agent-status-overlay:empty {
|
||||
display: none;
|
||||
}
|
||||
.agent-status-overlay > * {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--purple-dim);
|
||||
|
|
@ -263,15 +277,31 @@ h2, h3 {
|
|||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.meta { color: var(--muted); font-size: 0.85em; }
|
||||
.status-online { color: var(--green); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); }
|
||||
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
|
||||
code { background: color-mix(in srgb, var(--purple) 12%, transparent); padding: 0.05em 0.3em; border-radius: 2px; }
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.status-online {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
|
||||
}
|
||||
.status-needs-login {
|
||||
color: var(--amber);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
|
||||
}
|
||||
code {
|
||||
background: color-mix(in srgb, var(--purple) 12%, transparent);
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: 2px;
|
||||
}
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 50%, transparent);
|
||||
}
|
||||
a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent); }
|
||||
a:hover {
|
||||
color: var(--fg);
|
||||
text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent);
|
||||
}
|
||||
.btn {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
|
|
@ -284,15 +314,25 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
|
|||
}
|
||||
.btn {
|
||||
text-shadow: 0 0 4px currentColor;
|
||||
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
text-shadow 0.15s ease;
|
||||
}
|
||||
.btn:hover {
|
||||
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
||||
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-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;
|
||||
}
|
||||
/* Orphaned rules — left here as a tombstone so a search for the
|
||||
class name finds them. Live consumers gone:
|
||||
- `.btn-rebuild` was a per-agent header chip, then briefly an
|
||||
|
|
@ -301,26 +341,41 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
|
|||
outright now, no replacement.
|
||||
- `.btn-send` was a green send-button variant — the dashboard's
|
||||
compose form that used it is retired. */
|
||||
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
|
||||
.sendform {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.sendform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
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; align-items: stretch; }
|
||||
.sendform input:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.loginform {
|
||||
display: flex;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.5em;
|
||||
align-items: stretch;
|
||||
}
|
||||
.loginform input {
|
||||
font-family: inherit; font-size: 1em;
|
||||
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); }
|
||||
.loginform input:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
|
||||
/* Show / hide toggle for the masked OAuth-code input. Quiet by
|
||||
default (muted border + transparent bg), lights amber on hover
|
||||
|
|
@ -338,7 +393,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.loginform-reveal:hover,
|
||||
.loginform-reveal:focus-visible {
|
||||
|
|
@ -373,8 +431,12 @@ pre.diff {
|
|||
letter-spacing: 0.05em;
|
||||
list-style: none;
|
||||
}
|
||||
.agent-inbox > summary::marker { content: ''; }
|
||||
.agent-inbox[open] > summary > span::before { content: ''; }
|
||||
.agent-inbox > summary::marker {
|
||||
content: "";
|
||||
}
|
||||
.agent-inbox[open] > summary > span::before {
|
||||
content: "";
|
||||
}
|
||||
.agent-inbox ul {
|
||||
list-style: none;
|
||||
padding: 0.4em 0.8em;
|
||||
|
|
@ -392,9 +454,18 @@ pre.diff {
|
|||
padding: 0.4em 0;
|
||||
display: block;
|
||||
}
|
||||
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
|
||||
.agent-inbox .inbox-from { color: var(--amber); }
|
||||
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
|
||||
.agent-inbox .inbox-ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
.agent-inbox .inbox-from {
|
||||
color: var(--amber);
|
||||
}
|
||||
.agent-inbox .inbox-sep {
|
||||
color: var(--muted);
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
/* Todos flyout per-row checkbox (bulk mark-done) — sits inline before the
|
||||
existing `.inbox-from` label, same row. */
|
||||
.agent-inbox .todo-cb {
|
||||
|
|
@ -411,9 +482,16 @@ pre.diff {
|
|||
border-left: 2px solid var(--purple-dim);
|
||||
}
|
||||
/* Running bash-tasks flyout rows (buildBashTasksList). */
|
||||
.agent-inbox .bash-task-status { font-weight: bold; font-size: 0.9em; }
|
||||
.agent-inbox .bash-task-running { color: var(--green); }
|
||||
.agent-inbox .bash-task-pending { color: var(--blue); }
|
||||
.agent-inbox .bash-task-status {
|
||||
font-weight: bold;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.agent-inbox .bash-task-running {
|
||||
color: var(--green);
|
||||
}
|
||||
.agent-inbox .bash-task-pending {
|
||||
color: var(--blue);
|
||||
}
|
||||
.agent-inbox .bash-task-cmd {
|
||||
font-family: monospace;
|
||||
font-size: 0.92em;
|
||||
|
|
@ -423,7 +501,10 @@ pre.diff {
|
|||
border-left: 2px solid var(--border);
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
|
||||
.agent-inbox .inbox-reply-tag {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Bulk-action header row: "mark all read" above the recent-messages
|
||||
list in the inbox flyout, and "select all / select none / mark
|
||||
|
|
@ -473,8 +554,12 @@ pre.diff {
|
|||
.effort-chip {
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
.model-chip { color: var(--cyan); }
|
||||
.effort-chip { color: var(--amber); }
|
||||
.model-chip {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.effort-chip {
|
||||
color: var(--amber);
|
||||
}
|
||||
/* 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
|
||||
|
|
@ -489,13 +574,28 @@ pre.diff {
|
|||
/* Harness reachability badge. Markup carries `hive-pill` alongside
|
||||
`status-badge` (shared pill.css supplies the shape); colour
|
||||
communicates the actual reachability state. */
|
||||
.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 color-mix(in srgb, var(--green) 55%, transparent); }
|
||||
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
|
||||
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
|
||||
.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 color-mix(in srgb, var(--green) 55%, transparent);
|
||||
}
|
||||
.status-badge.status-rate-limited {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent);
|
||||
}
|
||||
.status-badge.status-needs-login {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.status-badge.status-offline {
|
||||
color: var(--muted);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
/* Orphaned tombstone — `.btn-dashlink` chip that lived beside the
|
||||
title moved into the overflow menu, then into `MetaNav`'s `🔗`
|
||||
popover (the `↑ dashboard` item) once the overflow menu itself was
|
||||
|
|
@ -512,26 +612,34 @@ pre.diff {
|
|||
supplies the shape); this rule is only the transition + state-
|
||||
specific colour below. */
|
||||
.state-badge {
|
||||
transition: color 280ms ease, border-color 280ms ease,
|
||||
box-shadow 280ms ease, background 280ms ease;
|
||||
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);
|
||||
color: var(--muted);
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
.state-badge.state-offline {
|
||||
color: var(--muted); border-color: var(--muted);
|
||||
color: var(--muted);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
.state-badge.state-idle {
|
||||
color: var(--cyan); border-color: var(--cyan);
|
||||
color: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--cyan) 55%, transparent);
|
||||
}
|
||||
.state-badge.state-thinking {
|
||||
color: var(--amber); border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 65%, transparent);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
.state-badge.state-compacting {
|
||||
color: var(--purple); border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 65%, transparent);
|
||||
animation: badge-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
|
@ -539,9 +647,21 @@ pre.diff {
|
|||
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; }
|
||||
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;
|
||||
}
|
||||
}
|
||||
/* Full-screen overrides for the shared terminal rules. The base
|
||||
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
|
||||
|
|
@ -571,7 +691,9 @@ pre.diff {
|
|||
focus restore) clear of the floats too. */
|
||||
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
||||
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
|
||||
scroll-padding-top: calc(
|
||||
var(--agent-header-real-h, var(--agent-header-h)) + 0.8em
|
||||
);
|
||||
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
@ -635,10 +757,21 @@ pre.diff {
|
|||
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); }
|
||||
.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);
|
||||
}
|
||||
|
||||
/* ─── side panel (singleton drawer) ────────────────────────────────
|
||||
Inbox + loose-ends details open here instead of expanding inline. The
|
||||
|
|
@ -658,7 +791,9 @@ pre.diff {
|
|||
body.side-panel-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||
body.side-panel-resizing * {
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
/* Inbox / loose-ends lists rendered into the side-panel body: strip
|
||||
the inbox-only chrome (background, border-left) here and let the
|
||||
panel body's own padding own the framing. */
|
||||
|
|
@ -774,9 +909,16 @@ hive-side-panel .agent-inbox ul {
|
|||
font-size: 0.95rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
.stats-card .chart-wrap { position: relative; height: 220px; }
|
||||
.stats-card.wide { grid-column: 1 / -1; }
|
||||
.stats-card.wide .chart-wrap { height: 260px; }
|
||||
.stats-card .chart-wrap {
|
||||
position: relative;
|
||||
height: 220px;
|
||||
}
|
||||
.stats-card.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.stats-card.wide .chart-wrap {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
/* ─── /screen page ─── */
|
||||
/* Full-screen VNC canvas viewer (screen.html). All rules scoped to
|
||||
|
|
@ -802,8 +944,14 @@ body.screen-shell {
|
|||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tbtn.active { color: var(--green); border-color: var(--green); }
|
||||
.tbtn:disabled { opacity: 0.4; cursor: default; }
|
||||
.tbtn.active {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.tbtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Connection status chip at the right end of the screen page header. */
|
||||
#status {
|
||||
|
|
@ -811,13 +959,19 @@ body.screen-shell {
|
|||
font-size: 0.75rem;
|
||||
color: var(--subtext0);
|
||||
}
|
||||
#status.connected { color: var(--green); }
|
||||
#status.error { color: var(--red); }
|
||||
#status.connected {
|
||||
color: var(--green);
|
||||
}
|
||||
#status.error {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* RFB debug log — fixed panel at the bottom, hidden by default. */
|
||||
#debug-log {
|
||||
position: fixed;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in srgb, var(--crust) 95%, transparent);
|
||||
|
|
@ -828,10 +982,20 @@ body.screen-shell {
|
|||
z-index: 100;
|
||||
display: none; /* toggled by the debug 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); }
|
||||
#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 container — centres the VNC framebuffer. */
|
||||
#canvas-wrap {
|
||||
|
|
@ -844,11 +1008,21 @@ body.screen-shell {
|
|||
background: var(--crust);
|
||||
}
|
||||
/* Fit mode: canvas scaled in JS to fill the container. */
|
||||
#canvas-wrap.fit { align-items: center; overflow: hidden; }
|
||||
#canvas-wrap canvas { display: block; cursor: default; }
|
||||
#canvas-wrap.fit {
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
#canvas-wrap canvas {
|
||||
display: block;
|
||||
cursor: default;
|
||||
}
|
||||
/* Pin the canvas to the JS-calculated size; prevents flex-item
|
||||
min-width:auto from silently expanding beyond the scaled size. */
|
||||
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
|
||||
#canvas-wrap.fit canvas {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Transient flash message (copy-text, disconnect, etc.). */
|
||||
#msg {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,11 @@
|
|||
// will keep accumulating cruft in the shared component." This file owns
|
||||
// its own state/storage entirely; `SettingsMenu` just renders it as a
|
||||
// child, no different from any other consumer's markup.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { getExpandDetailsPref, setExpandDetailsPref } from '@hive/shared/prefs.js';
|
||||
import { useState } from "preact/hooks";
|
||||
import {
|
||||
getExpandDetailsPref,
|
||||
setExpandDetailsPref,
|
||||
} from "@hive/shared/prefs.js";
|
||||
|
||||
export function ExpandDetailsSetting() {
|
||||
// Plain localStorage read, not a hook-managed override like
|
||||
|
|
@ -17,7 +20,9 @@ export function ExpandDetailsSetting() {
|
|||
// (`@hive/shared/prefs.js`) are the existing shared-storage-key pair
|
||||
// the terminal itself already reads live (no round-trip needed here
|
||||
// beyond re-rendering this row's own checkbox state on toggle).
|
||||
const [expandDetails, setExpandDetailsState] = useState(() => getExpandDetailsPref());
|
||||
const [expandDetails, setExpandDetailsState] = useState(() =>
|
||||
getExpandDetailsPref(),
|
||||
);
|
||||
return (
|
||||
<label class="settings-menu-row">
|
||||
<span>expand tool output</span>
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@
|
|||
// tick, a real ResizeObserver feedback loop (confirmed live on the
|
||||
// first pass of this fix). `--agent-header-h` stays the static floor;
|
||||
// `--agent-header-real-h` is output-only.
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
|
||||
// Icon 404s when the agent has no `hyperhive.icon` override (no
|
||||
// bundled server-side default any more — see
|
||||
|
|
@ -34,8 +34,8 @@ import type { ComponentChildren } from 'preact';
|
|||
function handleIconError(e: Event) {
|
||||
const img = e.currentTarget as HTMLImageElement;
|
||||
if (img.dataset.fallback) return;
|
||||
img.dataset.fallback = '1';
|
||||
img.src = '/favicon.svg';
|
||||
img.dataset.fallback = "1";
|
||||
img.src = "/favicon.svg";
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
|
|
@ -62,13 +62,16 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
|||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || typeof ResizeObserver === 'undefined') return;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
|
||||
// we want the full visual box padding+border included, the thing
|
||||
// content below the fixed header actually needs to clear, not the
|
||||
// content-box-only measurement ResizeObserver's entry defaults to.
|
||||
const ro = new ResizeObserver(() => {
|
||||
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
|
||||
document.documentElement.style.setProperty(
|
||||
"--agent-header-real-h",
|
||||
`${el.getBoundingClientRect().height}px`,
|
||||
);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
|
|
@ -81,7 +84,9 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
|
|||
<div class="agent-header-row agent-header-title-row">
|
||||
<h2 class="agent-header-title">◆ {label} ◆</h2>
|
||||
</div>
|
||||
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
|
||||
{hiveLabel ? (
|
||||
<div class="agent-header-row agent-hive-row">{hiveLabel}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children || pills ? (
|
||||
<div class="agent-header-pills">
|
||||
|
|
|
|||
|
|
@ -8,22 +8,28 @@
|
|||
// count === 0` in the old page) and picking the inbox/todos tone so
|
||||
// the count reads amber/green like it always has (`Badge`'s `tone`
|
||||
// colours the value, which here is the count).
|
||||
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
|
||||
export interface HeaderPillProps {
|
||||
kind: 'inbox' | 'todos';
|
||||
kind: "inbox" | "todos";
|
||||
icon: string;
|
||||
label: string;
|
||||
count: number;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const TONE: Record<HeaderPillProps['kind'], BadgeTone> = {
|
||||
inbox: 'warning',
|
||||
todos: 'positive',
|
||||
const TONE: Record<HeaderPillProps["kind"], BadgeTone> = {
|
||||
inbox: "warning",
|
||||
todos: "positive",
|
||||
};
|
||||
|
||||
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) {
|
||||
export function HeaderPill({
|
||||
kind,
|
||||
icon,
|
||||
label,
|
||||
count,
|
||||
onClick,
|
||||
}: HeaderPillProps) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<Badge
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
// JSON, not a redirect; in the common gateway-fronted deployment
|
||||
// `dashboardBase` already resolves to `location.origin` anyway (see
|
||||
// dashboardBase.ts), so it's same-origin in practice.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useConfirmClick } from '../lib/useConfirmClick.js';
|
||||
import type { InboxRow } from '../types.js';
|
||||
import { useState } from "preact/hooks";
|
||||
import { useConfirmClick } from "../lib/useConfirmClick.js";
|
||||
import type { InboxRow } from "../types.js";
|
||||
|
||||
export interface InboxPanelProps {
|
||||
rows: InboxRow[];
|
||||
|
|
@ -19,24 +19,35 @@ export interface InboxPanelProps {
|
|||
}
|
||||
|
||||
function fmtTs(unixSeconds: number): string {
|
||||
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
|
||||
return new Date(unixSeconds * 1000)
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.slice(5, 19);
|
||||
}
|
||||
|
||||
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
|
||||
const [status, setStatus] = useState('');
|
||||
export function InboxPanel({
|
||||
rows,
|
||||
label,
|
||||
dashboardBase,
|
||||
onCleared,
|
||||
}: InboxPanelProps) {
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function markAllRead() {
|
||||
if (!dashboardBase || !label) {
|
||||
setStatus('dashboard url / label unknown');
|
||||
setStatus("dashboard url / label unknown");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setStatus('clearing…');
|
||||
setStatus("clearing…");
|
||||
try {
|
||||
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const resp = await fetch(
|
||||
`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
const n = Number((data as { marked?: number }).marked) || 0;
|
||||
|
|
@ -68,16 +79,23 @@ export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanel
|
|||
disabled={busy}
|
||||
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
|
||||
>
|
||||
{armed ? 'sure? click again' : '✓ mark all read'}
|
||||
{armed ? "sure? click again" : "✓ mark all read"}
|
||||
</button>
|
||||
<span class="inbox-mark-status">{status}</span>
|
||||
</div>
|
||||
<ul>
|
||||
{rows.map((m) => (
|
||||
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
|
||||
{m.in_reply_to != null && <span class="inbox-reply-tag">↳ reply · </span>}
|
||||
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
|
||||
<span class="inbox-sep">→</span> <span class="inbox-body">{m.body}</span>
|
||||
<li
|
||||
key={m.id}
|
||||
class={m.in_reply_to != null ? "inbox-reply" : undefined}
|
||||
>
|
||||
{m.in_reply_to != null && (
|
||||
<span class="inbox-reply-tag">↳ reply · </span>
|
||||
)}
|
||||
<span class="inbox-ts">{fmtTs(m.at)}</span>{" "}
|
||||
<span class="inbox-from">{m.from}</span>{" "}
|
||||
<span class="inbox-sep">→</span>{" "}
|
||||
<span class="inbox-body">{m.body}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -20,10 +20,15 @@
|
|||
// unseen for an append. The old code inferred this from DOM
|
||||
// mutation shape after the fact; here the caller already knows
|
||||
// which one it did, so there's nothing to infer.
|
||||
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
|
||||
import { forwardRef } from 'preact/compat';
|
||||
import { useLiveStream } from '../hooks/useLiveStream.js';
|
||||
import { Row } from './Row.js';
|
||||
import {
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
} from "preact/hooks";
|
||||
import { forwardRef } from "preact/compat";
|
||||
import { useLiveStream } from "../hooks/useLiveStream.js";
|
||||
import { Row } from "./Row.js";
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
const LOAD_MORE_SCROLL_PX = 80;
|
||||
|
|
@ -37,82 +42,99 @@ export interface LiveStreamHandle {
|
|||
clear: () => void;
|
||||
}
|
||||
|
||||
export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) {
|
||||
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream();
|
||||
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [unseen, setUnseen] = useState(0);
|
||||
const prevRowCount = useRef(0);
|
||||
const prependingRef = useRef(false);
|
||||
const preScrollHeightRef = useRef(0);
|
||||
export const LiveStream = forwardRef<LiveStreamHandle, object>(
|
||||
function LiveStream(_props, ref) {
|
||||
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
|
||||
useLiveStream();
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({ pushNote: pushLocalNote, clear: clearLocal }),
|
||||
[pushLocalNote, clearLocal],
|
||||
);
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
const [unseen, setUnseen] = useState(0);
|
||||
const prevRowCount = useRef(0);
|
||||
const prependingRef = useRef(false);
|
||||
const preScrollHeightRef = useRef(0);
|
||||
|
||||
function isNearBottom(el: HTMLDivElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = isNearBottom(el);
|
||||
setStickToBottom(nearBottom);
|
||||
if (nearBottom) setUnseen(0);
|
||||
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
|
||||
}
|
||||
|
||||
function startLoadMore() {
|
||||
const el = logRef.current;
|
||||
if (!el || loadingMore || !hasMore) return;
|
||||
prependingRef.current = true;
|
||||
preScrollHeightRef.current = el.scrollHeight;
|
||||
loadMore();
|
||||
}
|
||||
|
||||
function jumpToBottom() {
|
||||
const el = logRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
setStickToBottom(true);
|
||||
setUnseen(0);
|
||||
}
|
||||
|
||||
// Runs after every commit that changed `rows` — i.e. after the browser
|
||||
// has already laid out the new/coalesced content, so `scrollHeight`
|
||||
// below is always current.
|
||||
useLayoutEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const added = rows.length - prevRowCount.current;
|
||||
prevRowCount.current = rows.length;
|
||||
|
||||
if (prependingRef.current) {
|
||||
prependingRef.current = false;
|
||||
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
|
||||
return;
|
||||
function isNearBottom(el: HTMLDivElement): boolean {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
|
||||
}
|
||||
if (stickToBottom) {
|
||||
el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
} else if (added > 0) {
|
||||
setUnseen((n) => n + added);
|
||||
}
|
||||
}, [rows, stickToBottom]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrap">
|
||||
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||
{hasMore && (
|
||||
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
|
||||
{loadingMore ? '↑ loading…' : '↑ load older'}
|
||||
function handleScroll() {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = isNearBottom(el);
|
||||
setStickToBottom(nearBottom);
|
||||
if (nearBottom) setUnseen(0);
|
||||
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore)
|
||||
startLoadMore();
|
||||
}
|
||||
|
||||
function startLoadMore() {
|
||||
const el = logRef.current;
|
||||
if (!el || loadingMore || !hasMore) return;
|
||||
prependingRef.current = true;
|
||||
preScrollHeightRef.current = el.scrollHeight;
|
||||
loadMore();
|
||||
}
|
||||
|
||||
function jumpToBottom() {
|
||||
const el = logRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
setStickToBottom(true);
|
||||
setUnseen(0);
|
||||
}
|
||||
|
||||
// Runs after every commit that changed `rows` — i.e. after the browser
|
||||
// has already laid out the new/coalesced content, so `scrollHeight`
|
||||
// below is always current.
|
||||
useLayoutEffect(() => {
|
||||
const el = logRef.current;
|
||||
if (!el) return;
|
||||
const added = rows.length - prevRowCount.current;
|
||||
prevRowCount.current = rows.length;
|
||||
|
||||
if (prependingRef.current) {
|
||||
prependingRef.current = false;
|
||||
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
|
||||
return;
|
||||
}
|
||||
if (stickToBottom) {
|
||||
el.scrollTop = el.scrollHeight - el.clientHeight;
|
||||
} else if (added > 0) {
|
||||
setUnseen((n) => n + added);
|
||||
}
|
||||
}, [rows, stickToBottom]);
|
||||
|
||||
return (
|
||||
<div className="terminal-wrap">
|
||||
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
className="load-more-pill"
|
||||
disabled={loadingMore}
|
||||
onClick={startLoadMore}
|
||||
>
|
||||
{loadingMore ? "↑ loading…" : "↑ load older"}
|
||||
</button>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.key} row={row} />
|
||||
))}
|
||||
</div>
|
||||
{unseen > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="tail-pill visible"
|
||||
onClick={jumpToBottom}
|
||||
>
|
||||
↓ {unseen} new
|
||||
</button>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.key} row={row} />
|
||||
))}
|
||||
</div>
|
||||
{unseen > 0 && (
|
||||
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
|
||||
↓ {unseen} new
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -16,10 +16,14 @@
|
|||
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
|
||||
// render is menu-item-shaped, not a generic content container — not a
|
||||
// fit for a login form + output pane).
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { SessionView } from '../types.js';
|
||||
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
|
||||
import './LoginFlow.css';
|
||||
import { useState } from "preact/hooks";
|
||||
import type { SessionView } from "../types.js";
|
||||
import {
|
||||
postLoginStart,
|
||||
postLoginCode,
|
||||
postLoginCancel,
|
||||
} from "../lib/loginAction.js";
|
||||
import "./LoginFlow.css";
|
||||
|
||||
function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
|
@ -32,7 +36,7 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
|||
postLoginStart().then((r) => {
|
||||
setBusy(false);
|
||||
if (r.ok) onStarted();
|
||||
else setError(r.detail ?? 'failed');
|
||||
else setError(r.detail ?? "failed");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -40,8 +44,8 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
|||
<div class="login-card">
|
||||
<p class="status-needs-login">◌ NEEDS L0G1N</p>
|
||||
<p>
|
||||
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
|
||||
paused until you log in.
|
||||
No Claude session in <code>~/.claude/</code>. The harness is up but the
|
||||
turn loop is paused until you log in.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
<button type="submit" class="btn btn-login" disabled={busy}>
|
||||
|
|
@ -50,15 +54,22 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
|
|||
</form>
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<p class="meta">
|
||||
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
|
||||
here when claude emits it; paste the resulting code back into the form below.
|
||||
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth
|
||||
URL will appear here when claude emits it; paste the resulting code back
|
||||
into the form below.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
|
||||
const [code, setCode] = useState('');
|
||||
function LoginInProgress({
|
||||
session,
|
||||
onDone,
|
||||
}: {
|
||||
session: SessionView | null;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [reveal, setReveal] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
|
@ -75,7 +86,7 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
|
|||
// nothing local to update here either way.
|
||||
postLoginCode(code).then((r) => {
|
||||
setBusy(false);
|
||||
if (!r.ok) setError(r.detail ?? 'failed');
|
||||
if (!r.ok) setError(r.detail ?? "failed");
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -94,23 +105,26 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
|
|||
{session?.url ? (
|
||||
<>
|
||||
<p class="login-url">
|
||||
▶{' '}
|
||||
▶{" "}
|
||||
<a href={session.url} target="_blank" rel="noreferrer">
|
||||
{session.url}
|
||||
</a>
|
||||
</p>
|
||||
<p class="meta">
|
||||
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
|
||||
open this URL in a browser, complete the OAuth flow, paste the
|
||||
resulting code below.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p>
|
||||
<p class="meta">
|
||||
waiting for claude to emit an OAuth URL on stdout… (output below)
|
||||
</p>
|
||||
)}
|
||||
{!finished ? (
|
||||
<form class="loginform" onSubmit={submitCode}>
|
||||
<input
|
||||
name="code"
|
||||
type={reveal ? 'text' : 'password'}
|
||||
type={reveal ? "text" : "password"}
|
||||
placeholder="paste OAuth code here (hidden)"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
|
|
@ -133,19 +147,20 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
|
|||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
|
||||
<form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
|
||||
<button type="submit" class="btn btn-cancel" disabled={busy}>
|
||||
cancel + kill
|
||||
</button>
|
||||
</form>
|
||||
{finished ? (
|
||||
<p class="status-needs-login">
|
||||
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
|
||||
claude process exited: {session?.exit_note || "exited"}. Start over if
|
||||
needed.
|
||||
</p>
|
||||
) : null}
|
||||
{error ? <p class="meta">error: {error}</p> : null}
|
||||
<h3>output</h3>
|
||||
<pre class="diff">{session?.output || ''}</pre>
|
||||
<pre class="diff">{session?.output || ""}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -155,13 +170,13 @@ export function LoginFlow({
|
|||
session,
|
||||
onRefresh,
|
||||
}: {
|
||||
status: 'needs_login_idle' | 'needs_login_in_progress';
|
||||
status: "needs_login_idle" | "needs_login_in_progress";
|
||||
session: SessionView | null;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div class="agent-status-overlay">
|
||||
{status === 'needs_login_idle' ? (
|
||||
{status === "needs_login_idle" ? (
|
||||
<LoginIdle onStarted={onRefresh} />
|
||||
) : (
|
||||
<LoginInProgress session={session} onDone={onRefresh} />
|
||||
|
|
|
|||
|
|
@ -21,11 +21,11 @@
|
|||
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
|
||||
// the same "reuse the values, not the component" call `LoginFlow`'s
|
||||
// `.login-card` makes for the same reason.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Badge } from '@hive/shared/badge.js';
|
||||
import { LinkIcon } from '@hive/shared/icons.js';
|
||||
import type { AgentLink } from '../types.js';
|
||||
import './MetaNav.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import type { AgentLink } from "../types.js";
|
||||
import "./MetaNav.css";
|
||||
|
||||
export interface MetaNavProps {
|
||||
links: AgentLink[];
|
||||
|
|
@ -35,7 +35,11 @@ export interface MetaNavProps {
|
|||
dashboardBase: string;
|
||||
}
|
||||
|
||||
export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps) {
|
||||
export function MetaNav({
|
||||
links,
|
||||
forgePublicUrl,
|
||||
dashboardBase,
|
||||
}: MetaNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -43,16 +47,21 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
if (
|
||||
rootRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!rootRef.current.contains(e.target)
|
||||
)
|
||||
setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -60,7 +69,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
|
|||
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
|
||||
// entirely (never guessed from `<host>:3000`); `external` is already
|
||||
// absolute; `container` is a same-origin path.
|
||||
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
|
||||
const visible = links.filter((lnk) => lnk.kind !== "forge" || forgePublicUrl);
|
||||
// No early-return-on-empty: the dashboard link below is always
|
||||
// present, so the trigger always has at least one item.
|
||||
|
||||
|
|
@ -86,7 +95,8 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
|
|||
🧭 dashboard
|
||||
</a>
|
||||
{visible.map((lnk) => {
|
||||
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
||||
const href =
|
||||
lnk.kind === "forge" ? `${forgePublicUrl}${lnk.url}` : lnk.url;
|
||||
return (
|
||||
<a
|
||||
key={lnk.url}
|
||||
|
|
@ -97,9 +107,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
|
|||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{lnk.icon ? (
|
||||
<span aria-hidden="true">{lnk.icon}</span>
|
||||
) : null}
|
||||
{lnk.icon ? <span aria-hidden="true">{lnk.icon}</span> : null}
|
||||
{lnk.label}
|
||||
</a>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
// a body is an expandable details row gated by the operator's
|
||||
// expand-tool-output preference. No separate classification step —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { TermRow } from '../lib/termMsg.js';
|
||||
import { linkifyToNodes } from '../lib/linkify.js';
|
||||
import { renderMarkdown } from '../lib/markdown.js';
|
||||
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { TermRow } from "../lib/termMsg.js";
|
||||
import { linkifyToNodes } from "../lib/linkify.js";
|
||||
import { renderMarkdown } from "../lib/markdown.js";
|
||||
import { getExpandDetailsPref } from "@hive/shared/prefs.js";
|
||||
|
||||
function MarkdownBody({ text }: { text: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -18,25 +18,31 @@ function MarkdownBody({ text }: { text: string }) {
|
|||
useEffect(() => {
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates the terminal away.
|
||||
ref.current?.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener noreferrer');
|
||||
ref.current?.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}, [html]);
|
||||
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
||||
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
return (
|
||||
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBody({ text }: { text: string }) {
|
||||
const lines = String(text).split('\n');
|
||||
const lines = String(text).split("\n");
|
||||
return (
|
||||
<pre className="tool-body diff-body">
|
||||
{lines.map((line, i) => {
|
||||
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
|
||||
const cls = line.startsWith("+ ")
|
||||
? "diff-add"
|
||||
: line.startsWith("- ")
|
||||
? "diff-del"
|
||||
: "diff-ctx";
|
||||
return (
|
||||
<span key={i} className={cls}>
|
||||
{line}
|
||||
{'\n'}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
|
@ -45,8 +51,10 @@ function DiffBody({ text }: { text: string }) {
|
|||
}
|
||||
|
||||
export function Row({ row }: { row: TermRow }) {
|
||||
const cssClass = 'level-' + row.level;
|
||||
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
|
||||
const cssClass = "level-" + row.level;
|
||||
const icon = row.icon != null && row.icon !== "" && (
|
||||
<span className="row-glyph">{row.icon}</span>
|
||||
);
|
||||
|
||||
if (row.body == null) {
|
||||
return (
|
||||
|
|
@ -59,7 +67,7 @@ export function Row({ row }: { row: TermRow }) {
|
|||
|
||||
// Empty summary + markdown body → the body itself is the whole row
|
||||
// (assistant text), no summary prefix line, never collapsible.
|
||||
if (row.body_format === 'markdown' && row.summary === '') {
|
||||
if (row.body_format === "markdown" && row.summary === "") {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
|
|
@ -69,14 +77,19 @@ export function Row({ row }: { row: TermRow }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
|
||||
<details
|
||||
className={`row ${cssClass}`}
|
||||
open={getExpandDetailsPref() || undefined}
|
||||
>
|
||||
<summary>
|
||||
{icon}
|
||||
<span className="summary-text">{row.summary}</span>
|
||||
</summary>
|
||||
{row.body_format === 'diff' && <DiffBody text={row.body} />}
|
||||
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
|
||||
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
|
||||
{row.body_format === "diff" && <DiffBody text={row.body} />}
|
||||
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
|
||||
{row.body_format == null && (
|
||||
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@
|
|||
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
|
||||
// already ships globally via `agent.css` and is reused verbatim by
|
||||
// InboxPanel/TodosPanel — only the drawer chrome itself is new here.
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './SidePanel.css';
|
||||
import { useEffect } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./SidePanel.css";
|
||||
|
||||
export interface SidePanelProps {
|
||||
open: boolean;
|
||||
|
|
@ -26,17 +26,21 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
class={`agent-side-panel-backdrop${open ? " open" : ""}`}
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<aside
|
||||
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
|
||||
class={`agent-side-panel-drawer${open ? " open" : ""}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden={!open}
|
||||
|
|
@ -44,7 +48,12 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
|
|||
>
|
||||
<div class="agent-side-panel-head">
|
||||
<span class="agent-side-panel-title">{title}</span>
|
||||
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
|
||||
<button
|
||||
type="button"
|
||||
class="agent-side-panel-close"
|
||||
title="close (esc)"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@
|
|||
// selection state live in the caller (`Root.tsx`, via the
|
||||
// `useAgentState` hook), so this component can still be demoed and
|
||||
// reviewed against plain sample data independent of live `/api/state`.
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
|
||||
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
|
||||
import './StatusChips.css';
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
|
||||
import "./StatusChips.css";
|
||||
|
||||
export interface StatusChipsProps {
|
||||
statusLabel: string;
|
||||
|
|
@ -40,16 +40,16 @@ export interface StatusChipsProps {
|
|||
}
|
||||
|
||||
const MODEL_DESCRIPTIONS: Record<string, string> = {
|
||||
haiku: 'fast',
|
||||
sonnet: 'balanced',
|
||||
opus: 'powerful',
|
||||
haiku: "fast",
|
||||
sonnet: "balanced",
|
||||
opus: "powerful",
|
||||
};
|
||||
const EFFORT_DESCRIPTIONS: Record<string, string> = {
|
||||
low: '',
|
||||
medium: 'default',
|
||||
high: '',
|
||||
xhigh: '',
|
||||
max: '',
|
||||
low: "",
|
||||
medium: "default",
|
||||
high: "",
|
||||
xhigh: "",
|
||||
max: "",
|
||||
};
|
||||
|
||||
function Picker({
|
||||
|
|
@ -76,7 +76,13 @@ function Picker({
|
|||
}));
|
||||
return (
|
||||
<div class="status-chip-anchor" ref={anchorRef}>
|
||||
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} title={title} />
|
||||
<Badge
|
||||
label={label}
|
||||
value={value}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
expanded={open}
|
||||
title={title}
|
||||
/>
|
||||
<Dropdown
|
||||
open={open}
|
||||
options={dropdownOptions}
|
||||
|
|
@ -110,7 +116,13 @@ function StatusMenu({
|
|||
onCancelTurn,
|
||||
}: Pick<
|
||||
StatusChipsProps,
|
||||
'statusLabel' | 'statusTone' | 'statusTooltip' | 'paused' | 'onTogglePause' | 'thinking' | 'onCancelTurn'
|
||||
| "statusLabel"
|
||||
| "statusTone"
|
||||
| "statusTooltip"
|
||||
| "paused"
|
||||
| "onTogglePause"
|
||||
| "thinking"
|
||||
| "onCancelTurn"
|
||||
>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
|
@ -121,11 +133,13 @@ function StatusMenu({
|
|||
setConfirmCancel(false);
|
||||
}
|
||||
|
||||
const options: DropdownOption[] = [{ value: 'toggle-pause', label: paused ? '▶ resume' : '⏸ pause' }];
|
||||
const options: DropdownOption[] = [
|
||||
{ value: "toggle-pause", label: paused ? "▶ resume" : "⏸ pause" },
|
||||
];
|
||||
if (thinking) {
|
||||
options.push({
|
||||
value: 'cancel-turn',
|
||||
label: confirmCancel ? '■ click again to confirm' : '■ cancel turn',
|
||||
value: "cancel-turn",
|
||||
label: confirmCancel ? "■ click again to confirm" : "■ cancel turn",
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -144,7 +158,7 @@ function StatusMenu({
|
|||
options={options}
|
||||
label="agent status"
|
||||
onSelect={(value) => {
|
||||
if (value === 'toggle-pause') {
|
||||
if (value === "toggle-pause") {
|
||||
onTogglePause();
|
||||
close();
|
||||
return;
|
||||
|
|
@ -225,7 +239,9 @@ export function StatusChips({
|
|||
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
|
||||
/>
|
||||
) : null}
|
||||
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
|
||||
{lastTurnLabel ? (
|
||||
<span class="status-chips-last-turn">{lastTurnLabel}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@
|
|||
// a text-input flow anyway. Instead: typing the command once arms it
|
||||
// (a local note explains what confirming does), typing it again within
|
||||
// the same "armed" state fires it — a keyboard-native two-step confirm.
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { postModel, postEffort } from '../lib/modelEffort.js';
|
||||
import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js';
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import { postModel, postEffort } from "../lib/modelEffort.js";
|
||||
import {
|
||||
postCancelTurn,
|
||||
postCompact,
|
||||
postNewSession,
|
||||
postLogout,
|
||||
postSend,
|
||||
} from "../lib/termActions.js";
|
||||
|
||||
export interface TermInputProps {
|
||||
label: string;
|
||||
|
|
@ -22,14 +28,26 @@ export interface TermInputProps {
|
|||
}
|
||||
|
||||
const SLASH_COMMANDS: { name: string; desc: string }[] = [
|
||||
{ name: '/help', desc: 'list slash commands' },
|
||||
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
|
||||
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
|
||||
{ name: '/compact', desc: 'compact the persistent claude session' },
|
||||
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
|
||||
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' },
|
||||
{ name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' },
|
||||
{ name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' },
|
||||
{ name: "/help", desc: "list slash commands" },
|
||||
{ name: "/clear", desc: "wipe the terminal panel (local-only)" },
|
||||
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" },
|
||||
{ name: "/compact", desc: "compact the persistent claude session" },
|
||||
{
|
||||
name: "/model",
|
||||
desc: "/model <name> — switch claude model for future turns",
|
||||
},
|
||||
{
|
||||
name: "/effort",
|
||||
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
|
||||
},
|
||||
{
|
||||
name: "/new-session",
|
||||
desc: "fresh claude session next turn (type twice to confirm)",
|
||||
},
|
||||
{
|
||||
name: "/logout",
|
||||
desc: "rotate OAuth creds + park in needs-login (type twice to confirm)",
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
|
||||
|
|
@ -41,8 +59,13 @@ function completeSlash(prefix: string): string | null {
|
|||
return matches[(idx + 1) % matches.length].name;
|
||||
}
|
||||
|
||||
export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) {
|
||||
const [value, setValue] = useState('');
|
||||
export function TermInput({
|
||||
label,
|
||||
online,
|
||||
onLocalNote,
|
||||
onClear,
|
||||
}: TermInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Which destructive command is currently "armed" (typed once, waiting
|
||||
// for the confirming repeat) — any other command clears it.
|
||||
|
|
@ -51,59 +74,82 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
|
|||
function grow() {
|
||||
const ta = taRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
|
||||
ta.style.height = "auto";
|
||||
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + "px";
|
||||
}
|
||||
|
||||
function reportFailure(label_: string, detail?: string) {
|
||||
onLocalNote(`✗ ${label_} failed${detail ? ': ' + detail : ''}`);
|
||||
onLocalNote(`✗ ${label_} failed${detail ? ": " + detail : ""}`);
|
||||
}
|
||||
|
||||
function handleSlash(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
const [cmd, ...rest] = trimmed.split(/\s+/);
|
||||
const arg = rest.join(' ');
|
||||
const arg = rest.join(" ");
|
||||
const wasArmed = armedRef.current === cmd;
|
||||
armedRef.current = null;
|
||||
|
||||
switch (cmd) {
|
||||
case '/help':
|
||||
onLocalNote('/help');
|
||||
for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`);
|
||||
case "/help":
|
||||
onLocalNote("/help");
|
||||
for (const c of SLASH_COMMANDS)
|
||||
onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`);
|
||||
return true;
|
||||
case '/clear':
|
||||
case "/clear":
|
||||
onClear();
|
||||
onLocalNote('· terminal cleared (local view only — server history kept)');
|
||||
onLocalNote(
|
||||
"· terminal cleared (local view only — server history kept)",
|
||||
);
|
||||
return true;
|
||||
case '/cancel':
|
||||
postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail));
|
||||
case "/cancel":
|
||||
postCancelTurn().then(
|
||||
(r) => !r.ok && reportFailure("/cancel", r.detail),
|
||||
);
|
||||
return true;
|
||||
case '/compact':
|
||||
postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail));
|
||||
case "/compact":
|
||||
postCompact().then((r) => !r.ok && reportFailure("/compact", r.detail));
|
||||
return true;
|
||||
case '/new-session':
|
||||
case "/new-session":
|
||||
if (wasArmed) {
|
||||
postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail));
|
||||
postNewSession().then(
|
||||
(r) => !r.ok && reportFailure("/new-session", r.detail),
|
||||
);
|
||||
} else {
|
||||
armedRef.current = '/new-session';
|
||||
onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.');
|
||||
armedRef.current = "/new-session";
|
||||
onLocalNote(
|
||||
"⚠ drops all prior --continue context. type /new-session again to confirm.",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
case '/logout':
|
||||
case "/logout":
|
||||
if (wasArmed) {
|
||||
postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail));
|
||||
postLogout().then((r) => !r.ok && reportFailure("/logout", r.detail));
|
||||
} else {
|
||||
armedRef.current = '/logout';
|
||||
onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.');
|
||||
armedRef.current = "/logout";
|
||||
onLocalNote(
|
||||
"⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.",
|
||||
);
|
||||
}
|
||||
return true;
|
||||
case '/model':
|
||||
if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)');
|
||||
else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail));
|
||||
case "/model":
|
||||
if (!arg)
|
||||
onLocalNote(
|
||||
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
|
||||
);
|
||||
else
|
||||
postModel(arg).then(
|
||||
(r) => !r.ok && reportFailure("/model", r.detail),
|
||||
);
|
||||
return true;
|
||||
case '/effort':
|
||||
if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
|
||||
else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail));
|
||||
case "/effort":
|
||||
if (!arg)
|
||||
onLocalNote(
|
||||
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
|
||||
);
|
||||
else
|
||||
postEffort(arg).then(
|
||||
(r) => !r.ok && reportFailure("/effort", r.detail),
|
||||
);
|
||||
return true;
|
||||
default:
|
||||
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
|
||||
|
|
@ -114,14 +160,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
|
|||
function submit() {
|
||||
const line = value;
|
||||
if (!line.trim()) return;
|
||||
setValue('');
|
||||
setValue("");
|
||||
requestAnimationFrame(grow);
|
||||
if (line.trim().startsWith('/')) {
|
||||
if (line.trim().startsWith("/")) {
|
||||
handleSlash(line);
|
||||
return;
|
||||
}
|
||||
armedRef.current = null;
|
||||
postSend(line).then((r) => !r.ok && reportFailure('send', r.detail));
|
||||
postSend(line).then((r) => !r.ok && reportFailure("send", r.detail));
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
|
|
@ -130,7 +176,7 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
|
|||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) {
|
||||
if (e.key === "Tab" && value.startsWith("/") && !value.includes(" ")) {
|
||||
const next = completeSlash(value);
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
|
|
@ -138,14 +184,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
|
|||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class={`term-input${online ? '' : ' disabled'}`}>
|
||||
<div class={`term-input${online ? "" : " disabled"}`}>
|
||||
<form
|
||||
class="sendform-term"
|
||||
onSubmit={(e) => {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// flyout. "mark done" POSTs to this agent's own backend
|
||||
// (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent
|
||||
// mark-all-read.
|
||||
import { useState } from 'preact/hooks';
|
||||
import type { TodoRow } from '../types.js';
|
||||
import { useState } from "preact/hooks";
|
||||
import type { TodoRow } from "../types.js";
|
||||
|
||||
export interface TodosPanelProps {
|
||||
todos: TodoRow[];
|
||||
|
|
@ -19,7 +19,7 @@ function fmtAge(seconds: number): string {
|
|||
|
||||
export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [status, setStatus] = useState('');
|
||||
const [status, setStatus] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function toggle(id: number) {
|
||||
|
|
@ -35,15 +35,15 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
|
|||
const ids = [...checked];
|
||||
if (!ids.length) return;
|
||||
setBusy(true);
|
||||
setStatus('marking…');
|
||||
setStatus("marking…");
|
||||
try {
|
||||
const resp = await fetch('api/todos/mark-done', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `ids=${encodeURIComponent(ids.join(','))}`,
|
||||
const resp = await fetch("api/todos/mark-done", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: `ids=${encodeURIComponent(ids.join(","))}`,
|
||||
});
|
||||
if (resp.ok) {
|
||||
setStatus('✓ marked done');
|
||||
setStatus("✓ marked done");
|
||||
setChecked(new Set());
|
||||
onCleared();
|
||||
} else {
|
||||
|
|
@ -57,7 +57,9 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
|
|||
}
|
||||
|
||||
if (!todos.length) {
|
||||
return <p class="side-panel-empty">no todos — all subsystem queues are clear.</p>;
|
||||
return (
|
||||
<p class="side-panel-empty">no todos — all subsystem queues are clear.</p>
|
||||
);
|
||||
}
|
||||
|
||||
const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id));
|
||||
|
|
@ -68,11 +70,18 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
|
|||
<button
|
||||
type="button"
|
||||
class="inbox-mark-all-btn"
|
||||
onClick={() => setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))}
|
||||
onClick={() =>
|
||||
setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))
|
||||
}
|
||||
>
|
||||
{allChecked ? 'select none' : 'select all'}
|
||||
{allChecked ? "select none" : "select all"}
|
||||
</button>
|
||||
<button type="button" class="inbox-mark-all-btn" onClick={markDone} disabled={busy || checked.size === 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="inbox-mark-all-btn"
|
||||
onClick={markDone}
|
||||
disabled={busy || checked.size === 0}
|
||||
>
|
||||
✓ mark done
|
||||
</button>
|
||||
<span class="inbox-mark-status">{status}</span>
|
||||
|
|
@ -89,10 +98,10 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
|
|||
class="todo-cb"
|
||||
checked={checked.has(t.id)}
|
||||
onChange={() => toggle(t.id)}
|
||||
/>{' '}
|
||||
/>{" "}
|
||||
<label for={cbId} class="inbox-from">
|
||||
{label}
|
||||
</label>{' '}
|
||||
</label>{" "}
|
||||
<span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span>
|
||||
<div class="inbox-body">{t.summary}</div>
|
||||
</li>
|
||||
|
|
|
|||
2
frontend/packages/agent/src/css.d.ts
vendored
2
frontend/packages/agent/src/css.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
// Ambient module for `import './Foo.css'` side-effect imports (esbuild
|
||||
// resolves these directly, see build.mjs; tsc otherwise has no idea what
|
||||
// a `.css` specifier is and refuses the whole side-effect import).
|
||||
declare module '*.css';
|
||||
declare module "*.css";
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
// the UI derives from it, like the turn-state badge's elapsed-time text —
|
||||
// goes stale until something proactively refetches; without this the
|
||||
// only way back to a live reading was a full page reload.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { AgentState } from '../types.js';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { AgentState } from "../types.js";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
const RETRY_MS = 5000;
|
||||
|
|
@ -38,7 +38,7 @@ export function useAgentState(): UseAgentStateResult {
|
|||
|
||||
async function poll() {
|
||||
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()) as AgentState;
|
||||
if (stoppedRef.current) return;
|
||||
|
|
@ -80,10 +80,10 @@ export function useAgentState(): UseAgentStateResult {
|
|||
// component that calls this hook.
|
||||
useEffect(() => {
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') refresh();
|
||||
if (document.visibilityState === "visible") refresh();
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
return () => document.removeEventListener('visibilitychange', onVisible);
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, []);
|
||||
|
||||
return { state, error, refresh };
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@
|
|||
// initial history page, drop it from the buffer). `seq` alone is the
|
||||
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
|
||||
// `web_ui/stream.rs`.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
|
||||
|
||||
export interface UseLiveStreamOptions {
|
||||
historyUrl?: string;
|
||||
|
|
@ -50,9 +50,11 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
|
|||
return [...rows, row];
|
||||
}
|
||||
|
||||
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
|
||||
const historyUrl = opts.historyUrl ?? 'events/history';
|
||||
const streamUrl = opts.streamUrl ?? 'events/stream';
|
||||
export function useLiveStream(
|
||||
opts: UseLiveStreamOptions = {},
|
||||
): UseLiveStreamResult {
|
||||
const historyUrl = opts.historyUrl ?? "events/history";
|
||||
const streamUrl = opts.streamUrl ?? "events/stream";
|
||||
|
||||
const [rows, setRows] = useState<TermRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
|
@ -61,12 +63,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
const keySeqRef = useRef(0);
|
||||
function nextKey(): string {
|
||||
keySeqRef.current += 1;
|
||||
return 'r' + keySeqRef.current;
|
||||
return "r" + keySeqRef.current;
|
||||
}
|
||||
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
|
||||
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
|
||||
}
|
||||
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] {
|
||||
function appendEnvelope(
|
||||
rows: TermRow[],
|
||||
env: TermEnvelope,
|
||||
fromHistory: boolean,
|
||||
): TermRow[] {
|
||||
let next = rows;
|
||||
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
|
||||
return next;
|
||||
|
|
@ -89,9 +95,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
try {
|
||||
env = JSON.parse(e.data);
|
||||
} catch {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
|
||||
}));
|
||||
setRows((prev) =>
|
||||
appendRow(prev, {
|
||||
key: "parse-err-" + Date.now(),
|
||||
level: "warn",
|
||||
summary: "[parse err] " + e.data,
|
||||
fromHistory: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!liveRef.current) {
|
||||
|
|
@ -101,30 +112,54 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
pushLive(env);
|
||||
};
|
||||
es.onerror = () => {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
|
||||
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
|
||||
}));
|
||||
setRows((prev) =>
|
||||
appendRow(prev, {
|
||||
key: "conn-note",
|
||||
level: "warn",
|
||||
fromHistory: false,
|
||||
coalesce_key: "conn-status",
|
||||
summary:
|
||||
es.readyState === 0 /* CONNECTING */
|
||||
? "[reconnecting…]"
|
||||
: "[disconnected]",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
async function backfill() {
|
||||
try {
|
||||
const resp = await fetch(historyUrl);
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const body = await resp.json();
|
||||
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
|
||||
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null);
|
||||
const events: TermEnvelope[] = Array.isArray(body)
|
||||
? body
|
||||
: body.events || [];
|
||||
const boundarySeq: number | null = Array.isArray(body)
|
||||
? null
|
||||
: (body.seq ?? null);
|
||||
if (!Array.isArray(body)) {
|
||||
setHasMore(!!body.has_more);
|
||||
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
|
||||
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
|
||||
}
|
||||
if (cancelled) return;
|
||||
|
||||
let initial: TermRow[] = [];
|
||||
for (const env of events) initial = appendEnvelope(initial, env, true);
|
||||
initial = events.length
|
||||
? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' })
|
||||
: [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }];
|
||||
? appendRow(initial, {
|
||||
key: "live-sep",
|
||||
level: "debug",
|
||||
fromHistory: true,
|
||||
summary: "─── live (older above) ───",
|
||||
})
|
||||
: [
|
||||
{
|
||||
key: "placeholder",
|
||||
level: "debug",
|
||||
fromHistory: true,
|
||||
summary: "(connected — waiting for events)",
|
||||
},
|
||||
];
|
||||
setRows(initial);
|
||||
|
||||
const drained = bufferedRef.current;
|
||||
|
|
@ -133,11 +168,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
for (const env of drained) {
|
||||
// Already covered by the initial history page — drop it from
|
||||
// the buffer rather than rendering it twice.
|
||||
if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue;
|
||||
if (
|
||||
boundarySeq != null &&
|
||||
typeof env.seq === "number" &&
|
||||
env.seq <= boundarySeq
|
||||
)
|
||||
continue;
|
||||
pushLive(env);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('history backfill failed', err);
|
||||
console.warn("history backfill failed", err);
|
||||
if (cancelled) return;
|
||||
const drained = bufferedRef.current;
|
||||
bufferedRef.current = [];
|
||||
|
|
@ -157,30 +197,42 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
if (!hasMore || loadingMore || minIdRef.current == null) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const sep = historyUrl.includes('?') ? '&' : '?';
|
||||
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
|
||||
const sep = historyUrl.includes("?") ? "&" : "?";
|
||||
const resp = await fetch(historyUrl + sep + "before=" + minIdRef.current);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
|
||||
const events: TermEnvelope[] = Array.isArray(body)
|
||||
? body
|
||||
: body.events || [];
|
||||
setHasMore(!!body.has_more);
|
||||
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
|
||||
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
|
||||
if (events.length) {
|
||||
let older: TermRow[] = [];
|
||||
for (const env of events) older = appendEnvelope(older, env, true);
|
||||
older = appendRow(older, {
|
||||
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
|
||||
key: "older-sep-" + minIdRef.current,
|
||||
level: "debug",
|
||||
fromHistory: true,
|
||||
summary: "─── older above ───",
|
||||
});
|
||||
setRows((prev) => [...older, ...prev]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('loadMore failed', err);
|
||||
console.warn("loadMore failed", err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pushLocalNote(text: string) {
|
||||
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
|
||||
setRows((prev) =>
|
||||
appendRow(prev, {
|
||||
key: "local-" + nextKey(),
|
||||
level: "info",
|
||||
fromHistory: false,
|
||||
summary: text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
function clearLocal() {
|
||||
setRows([]);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// useAgentState: todos change asynchronously (matrix syncs, bash task
|
||||
// starts/completions) independent of the state snapshot, and the old
|
||||
// page polled them independently too.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { TodoRow } from '../types.js';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { TodoRow } from "../types.js";
|
||||
|
||||
const POLL_MS = 4000;
|
||||
|
||||
|
|
@ -20,16 +20,16 @@ export function useTodos(): UseTodosResult {
|
|||
|
||||
async function poll() {
|
||||
try {
|
||||
const resp = await fetch('api/todos');
|
||||
const resp = await fetch("api/todos");
|
||||
if (!resp.ok) throw new Error(`http ${resp.status}`);
|
||||
const body = (await resp.json()) as { todos?: TodoRow[] };
|
||||
if (stoppedRef.current) return;
|
||||
// Defensive filter: the wire type is the general `LooseEnd` tagged
|
||||
// enum even though this endpoint only ever emits the `todo` variant
|
||||
// today — see types.ts's TodoRow comment.
|
||||
setTodos((body.todos ?? []).filter((t) => t.kind === 'todo'));
|
||||
setTodos((body.todos ?? []).filter((t) => t.kind === "todo"));
|
||||
} catch (err) {
|
||||
console.warn('todos fetch failed', err);
|
||||
console.warn("todos fetch failed", err);
|
||||
if (!stoppedRef.current) setTodos([]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,27 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive agent</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon">
|
||||
<link rel="stylesheet" href="static/colors.css">
|
||||
<link rel="stylesheet" href="static/theme.css">
|
||||
<link rel="stylesheet" href="static/agent.css">
|
||||
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive agent</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon" />
|
||||
<link rel="stylesheet" href="static/colors.css" />
|
||||
<link rel="stylesheet" href="static/theme.css" />
|
||||
<link rel="stylesheet" href="static/agent.css" />
|
||||
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
|
||||
StatusChips/SidePanel/...) MUST load after agent.css —
|
||||
`.login-card`/`.meta-nav-popover`/etc. share class-selector
|
||||
specificity with agent.css's legacy rules, so load order is what
|
||||
decides which one wins the cascade (see LoginFlow.css/MetaNav.css
|
||||
file comments). -->
|
||||
<link rel="stylesheet" href="static/main.css">
|
||||
</head>
|
||||
<body class="agent-shell">
|
||||
|
||||
<!-- The Preact rewrite owns everything below — header, terminal,
|
||||
<link rel="stylesheet" href="static/main.css" />
|
||||
</head>
|
||||
<body class="agent-shell">
|
||||
<!-- The Preact rewrite owns everything below — header, terminal,
|
||||
composer, side panel. See main.tsx/Root.tsx for the component
|
||||
tree; docs/web-ui/agent.md for the design. -->
|
||||
<div id="preact-root"></div>
|
||||
<div id="preact-root"></div>
|
||||
|
||||
<script type="module" src="static/main.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="static/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// `/agent/<name>/` on the dashboard's own origin (`location.origin`);
|
||||
// accessed directly, it's the same host on `dashboardPort`.
|
||||
export function resolveDashboardBase(dashboardPort: number): string {
|
||||
return location.pathname.startsWith('/agent/')
|
||||
? location.origin + '/'
|
||||
return location.pathname.startsWith("/agent/")
|
||||
? location.origin + "/"
|
||||
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
// output shapes — this page's operators are used to reading them).
|
||||
|
||||
export function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1_000) return Math.round(n / 1000) + 'k';
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
||||
if (n >= 1_000) return Math.round(n / 1000) + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function fmtAge(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return s + 's';
|
||||
if (s < 60) return s + "s";
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
||||
if (m < 60) return m + "m " + (s % 60) + "s";
|
||||
const h = Math.floor(m / 60);
|
||||
return h + 'h ' + (m % 60) + 'm';
|
||||
return h + "h " + (m % 60) + "m";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@
|
|||
// output there, JSX fragment here). Same regex + trailing-punctuation
|
||||
// strip; deliberately text-only, never innerHTML, so untrusted row text
|
||||
// (matrix-relayed bodies, tool args) can't inject markup this way.
|
||||
import type { JSX } from 'preact';
|
||||
import type { JSX } from "preact";
|
||||
|
||||
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
|
||||
|
||||
export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] {
|
||||
const str = text == null ? '' : String(text);
|
||||
if (str.indexOf('://') === -1) return [str];
|
||||
export function linkifyToNodes(
|
||||
text: string | null | undefined,
|
||||
): (string | JSX.Element)[] {
|
||||
const str = text == null ? "" : String(text);
|
||||
if (str.indexOf("://") === -1) return [str];
|
||||
const out: (string | JSX.Element)[] = [];
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
|
|
@ -18,10 +20,10 @@ export function linkifyToNodes(text: string | null | undefined): (string | JSX.E
|
|||
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
|
||||
let url = m[0];
|
||||
const trail = url.match(/[.,;:!?)\]}'"]+$/);
|
||||
const tail = trail ? trail[0] : '';
|
||||
const tail = trail ? trail[0] : "";
|
||||
if (tail) url = url.slice(0, -tail.length);
|
||||
if (m.index > last) out.push(str.slice(last, m.index));
|
||||
if (!url.slice(url.indexOf('://') + 3)) {
|
||||
if (!url.slice(url.indexOf("://") + 3)) {
|
||||
// Nothing past the scheme — not a real URL, emit verbatim.
|
||||
out.push(m[0]);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,23 +6,38 @@
|
|||
// these three routes actually redirect today, but the same tolerant
|
||||
// check costs nothing and keeps every same-origin POST in this package
|
||||
// behaving identically.
|
||||
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: undefined,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postLoginStart = () => post('login/start');
|
||||
export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post('login/cancel');
|
||||
export const postLoginStart = () => post("login/start");
|
||||
export const postLoginCode = (code: string) =>
|
||||
post("login/code", new URLSearchParams({ code }));
|
||||
export const postLoginCancel = () => post("login/cancel");
|
||||
|
|
|
|||
|
|
@ -4,22 +4,27 @@
|
|||
// agent-authored files) — `marked` itself no longer sanitizes (v5+
|
||||
// dropped the built-in sanitizer), so every parse is run through
|
||||
// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`.
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const ESCAPE_RE = /[&<>"]/g;
|
||||
const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
||||
const ESCAPE_MAP: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
};
|
||||
|
||||
/** Render `text` as sanitized markdown HTML. Falls back to escaped plain
|
||||
* text if `marked` throws (mirrors app.js's try/catch fallback). */
|
||||
export function renderMarkdown(text: string | null | undefined): string {
|
||||
const src = String(text ?? '');
|
||||
const src = String(text ?? "");
|
||||
try {
|
||||
return DOMPurify.sanitize(marked.parse(src) as string);
|
||||
} catch (err) {
|
||||
console.warn('marked failed', err);
|
||||
console.warn("marked failed", err);
|
||||
return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,36 @@
|
|||
// ported from app.js unchanged — the endpoints don't actually redirect
|
||||
// today, but treating an opaque redirect as success costs nothing and
|
||||
// matches the existing contract exactly.
|
||||
async function post(path: string, field: string, value: string): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
field: string,
|
||||
value: string,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ [field]: value }),
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postModel = (name: string) => post('api/model', 'model', name);
|
||||
export const postEffort = (level: string) => post('api/effort', 'effort', level);
|
||||
export const postModel = (name: string) => post("api/model", "model", name);
|
||||
export const postEffort = (level: string) =>
|
||||
post("api/effort", "effort", level);
|
||||
|
|
|
|||
|
|
@ -14,10 +14,14 @@
|
|||
// navigates the whole page to whatever hive-c0re's endpoint returns —
|
||||
// here, the literal text "ok". Fire-and-forget `fetch` keeps the click
|
||||
// on the agent page.
|
||||
export async function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): Promise<void> {
|
||||
export async function submitPauseResume(
|
||||
dashboardBase: string,
|
||||
label: string,
|
||||
verb: "pause" | "resume",
|
||||
): Promise<void> {
|
||||
await fetch(`${dashboardBase}api/${verb}/${label}`, {
|
||||
method: 'POST',
|
||||
mode: 'no-cors',
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
mode: "no-cors",
|
||||
credentials: "include",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,40 @@
|
|||
// Same-origin fetch POST helpers for the term-input slash commands —
|
||||
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
|
||||
// throwing), reused by TermInput.tsx.
|
||||
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
|
||||
async function post(
|
||||
path: string,
|
||||
body?: URLSearchParams,
|
||||
): Promise<{ ok: boolean; detail?: string }> {
|
||||
try {
|
||||
const resp = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: undefined,
|
||||
body,
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (ok) return { ok: true };
|
||||
const detail = await resp.text().catch(() => '');
|
||||
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
|
||||
const detail = await resp.text().catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
|
||||
};
|
||||
} catch (err) {
|
||||
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
||||
return {
|
||||
ok: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const postCancelTurn = () => post('api/cancel');
|
||||
export const postCompact = () => post('api/compact');
|
||||
export const postNewSession = () => post('api/new-session');
|
||||
export const postLogout = () => post('api/logout');
|
||||
export const postSend = (body: string) => post('send', new URLSearchParams({ body }));
|
||||
export const postCancelTurn = () => post("api/cancel");
|
||||
export const postCompact = () => post("api/compact");
|
||||
export const postNewSession = () => post("api/new-session");
|
||||
export const postLogout = () => post("api/logout");
|
||||
export const postSend = (body: string) =>
|
||||
post("send", new URLSearchParams({ body }));
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
|
||||
// separate client-side row model or classification step any more —
|
||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
||||
export type Level = 'debug' | 'info' | 'warn' | 'error';
|
||||
export type Level = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface TermMsg {
|
||||
icon?: string;
|
||||
level: Level;
|
||||
summary: string;
|
||||
body?: string;
|
||||
body_format?: 'markdown' | 'diff';
|
||||
body_format?: "markdown" | "diff";
|
||||
coalesce_key?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
// the old widget family. First click arms the button (caller renders a
|
||||
// distinct "sure? click again" label off `armed`); a second click within
|
||||
// `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
export function useConfirmClick(onConfirm: () => void, resetMs = 2500) {
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
// Mounts to `#preact-root`, the only content `index.html`'s `<body>`
|
||||
// declares now — no more coexisting-with-legacy-markup sibling div,
|
||||
// that was only needed while both pages built in parallel.
|
||||
import { render } from 'preact';
|
||||
import { Root } from './Root.js';
|
||||
import { render } from "preact";
|
||||
import { Root } from "./Root.js";
|
||||
|
||||
const root = document.getElementById('preact-root');
|
||||
const root = document.getElementById("preact-root");
|
||||
if (root) render(<Root />, root);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +1,84 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive agent — stats</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon">
|
||||
<link rel="stylesheet" href="static/colors.css">
|
||||
<link rel="stylesheet" href="static/theme.css">
|
||||
<link rel="stylesheet" href="static/agent.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<a class="page-back" id="back-link" href="./">← live</a>
|
||||
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
|
||||
<span class="page-title" id="title">◆ … ◆</span>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive agent — stats</title>
|
||||
<link rel="icon" type="image/svg+xml" href="icon" />
|
||||
<link rel="stylesheet" href="static/colors.css" />
|
||||
<link rel="stylesheet" href="static/theme.css" />
|
||||
<link rel="stylesheet" href="static/agent.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="page-header">
|
||||
<a class="page-back" id="back-link" href="./">← live</a>
|
||||
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
|
||||
<span class="page-title" id="title">◆ … ◆</span>
|
||||
</header>
|
||||
|
||||
<div class="window-tabs" id="window-tabs" role="tablist">
|
||||
<button type="button" data-tab="1h">last 1h</button>
|
||||
<button type="button" data-tab="4h">last 4h</button>
|
||||
<button type="button" data-tab="24h">last 24h</button>
|
||||
<button type="button" data-tab="3d">last 3d</button>
|
||||
<button type="button" data-tab="7d">last 7d</button>
|
||||
<button type="button" data-tab="30d">last 30d</button>
|
||||
<button type="button" data-tab="all">all</button>
|
||||
</div>
|
||||
<div class="window-tabs" id="window-tabs" role="tablist">
|
||||
<button type="button" data-tab="1h">last 1h</button>
|
||||
<button type="button" data-tab="4h">last 4h</button>
|
||||
<button type="button" data-tab="24h">last 24h</button>
|
||||
<button type="button" data-tab="3d">last 3d</button>
|
||||
<button type="button" data-tab="7d">last 7d</button>
|
||||
<button type="button" data-tab="30d">last 30d</button>
|
||||
<button type="button" data-tab="all">all</button>
|
||||
</div>
|
||||
|
||||
<div class="summary" id="summary"></div>
|
||||
<div class="summary" id="summary"></div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stats-card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
|
||||
<div class="stats-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="stats-card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
|
||||
<div class="stats-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="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
|
||||
<!-- "favorite tools": most-run shell commands. Hidden until the
|
||||
<div class="stats-grid">
|
||||
<div class="stats-card wide">
|
||||
<h3>turns per bucket</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-turns"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>turn duration (ms) — p50 / p95 / avg</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-duration"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-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="stats-card wide">
|
||||
<h3>token cost per bucket (sum across inferences)</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-cost"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-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="stats-card">
|
||||
<h3>top tools</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-tools"></canvas></div>
|
||||
</div>
|
||||
<!-- "favorite tools": most-run shell commands. Hidden until the
|
||||
bash_commands capture (hive-bash-daemon) has recorded data, so the
|
||||
card never shows a permanently-empty doughnut. -->
|
||||
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
|
||||
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
|
||||
<div class="stats-card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
|
||||
<div class="stats-card wide"><h3>result trend per bucket — errors / rate-limits / compactions over time</h3><div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div></div>
|
||||
</div>
|
||||
<div class="stats-card" id="card-bash" hidden>
|
||||
<h3>favorite tools (bash)</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-bash"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card">
|
||||
<h3>wake source mix</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-wake"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card">
|
||||
<h3>result mix</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-result"></canvas></div>
|
||||
</div>
|
||||
<div class="stats-card wide">
|
||||
<h3>
|
||||
result trend per bucket — errors / rate-limits / compactions over time
|
||||
</h3>
|
||||
<div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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
|
||||
machines without internet egress. No SRI hash to maintain. -->
|
||||
<script type="module" src="static/stats.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="static/stats.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
// once on load, then /api/stats?window=... for the chart data — re-fetches
|
||||
// when the operator clicks a window tab.
|
||||
|
||||
import Chart from 'chart.js/auto';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import Chart from "chart.js/auto";
|
||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||
|
||||
// Expose for the IIFE below — pre-split this was a window global from
|
||||
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
|
||||
|
|
@ -12,26 +12,36 @@ import { createTabStrip } from '@hive/shared/tabs.js';
|
|||
window.Chart = Chart;
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
"use strict";
|
||||
|
||||
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
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'),
|
||||
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'];
|
||||
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.
|
||||
|
|
@ -42,16 +52,16 @@ window.Chart = Chart;
|
|||
Chart.defaults.plugins.legend.labels.color = palette.fg;
|
||||
|
||||
const charts = {};
|
||||
let currentWindow = '24h';
|
||||
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';
|
||||
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';
|
||||
if (!Number.isFinite(n)) return "0";
|
||||
return new Intl.NumberFormat().format(Math.round(n));
|
||||
}
|
||||
|
||||
|
|
@ -74,18 +84,21 @@ window.Chart = Chart;
|
|||
destroy(canvasId);
|
||||
const cv = document.getElementById(canvasId);
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d');
|
||||
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.font = "12px monospace";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(msg, cv.width / 2, cv.height / 2);
|
||||
}
|
||||
|
||||
// Sum the four token streams across every bucket in the window.
|
||||
function tokenTotals(s) {
|
||||
let input = 0, output = 0, cacheRead = 0, cacheCreation = 0;
|
||||
let input = 0,
|
||||
output = 0,
|
||||
cacheRead = 0,
|
||||
cacheCreation = 0;
|
||||
for (const b of s.buckets || []) {
|
||||
input += b.input_tokens || 0;
|
||||
output += b.output_tokens || 0;
|
||||
|
|
@ -96,54 +109,58 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const root = document.getElementById('summary');
|
||||
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],
|
||||
["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],
|
||||
];
|
||||
// Token-efficiency chips: cache hit-rate (cached input vs all
|
||||
// input-side tokens) and average tokens billed per turn.
|
||||
const t = tokenTotals(s);
|
||||
const inputSide = t.input + t.cacheRead + t.cacheCreation;
|
||||
if (inputSide > 0) {
|
||||
chips.push(['cache hit-rate', (100 * t.cacheRead / inputSide).toFixed(1) + '%']);
|
||||
chips.push([
|
||||
"cache hit-rate",
|
||||
((100 * t.cacheRead) / inputSide).toFixed(1) + "%",
|
||||
]);
|
||||
}
|
||||
if (s.turn_count > 0) {
|
||||
const perTurn = (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
|
||||
chips.push(['tokens/turn', fmtInt(perTurn)]);
|
||||
const perTurn =
|
||||
(t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
|
||||
chips.push(["tokens/turn", fmtInt(perTurn)]);
|
||||
}
|
||||
if (s.reminder_stats) {
|
||||
chips.push(
|
||||
['reminders scheduled', fmtInt(s.reminder_stats.scheduled)],
|
||||
['reminders delivered', fmtInt(s.reminder_stats.delivered)],
|
||||
['reminders pending', fmtInt(s.reminder_stats.pending)],
|
||||
["reminders scheduled", fmtInt(s.reminder_stats.scheduled)],
|
||||
["reminders delivered", fmtInt(s.reminder_stats.delivered)],
|
||||
["reminders pending", fmtInt(s.reminder_stats.pending)],
|
||||
);
|
||||
}
|
||||
// Session count: fresh claude sessions started in the window (each
|
||||
// new-session or auto-compaction-fallback mints one). Omitted until
|
||||
// the sessions table exists (older db).
|
||||
if (typeof s.session_count === 'number') {
|
||||
chips.push(['sessions', fmtInt(s.session_count)]);
|
||||
if (typeof s.session_count === "number") {
|
||||
chips.push(["sessions", fmtInt(s.session_count)]);
|
||||
}
|
||||
// First-turn ctx: input tokens of the most recent fresh session's
|
||||
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl
|
||||
// proxy. Omitted from the JSON (and so absent here) until the
|
||||
// per-session capture has data.
|
||||
if (typeof s.first_turn_ctx === 'number') {
|
||||
chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]);
|
||||
if (typeof s.first_turn_ctx === "number") {
|
||||
chips.push(["first-turn ctx", fmtInt(s.first_turn_ctx)]);
|
||||
}
|
||||
for (const [label, value] of chips) {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
const l = document.createElement('span');
|
||||
l.className = 'label';
|
||||
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';
|
||||
const v = document.createElement("span");
|
||||
v.className = "value";
|
||||
v.textContent = value;
|
||||
chip.append(l, v);
|
||||
root.append(chip);
|
||||
|
|
@ -151,54 +168,67 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderTurnsChart(s) {
|
||||
const id = 'chart-turns';
|
||||
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',
|
||||
type: "bar",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'turns',
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
}],
|
||||
datasets: [
|
||||
{
|
||||
label: "turns",
|
||||
data,
|
||||
backgroundColor: palette.purple,
|
||||
borderColor: palette.purple,
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderDurationChart(s) {
|
||||
const id = 'chart-duration';
|
||||
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,
|
||||
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',
|
||||
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'),
|
||||
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,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: {
|
||||
|
|
@ -212,77 +242,108 @@ window.Chart = Chart;
|
|||
}
|
||||
|
||||
function renderCtxChart(s) {
|
||||
const id = 'chart-ctx';
|
||||
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',
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'avg ctx',
|
||||
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,
|
||||
backgroundColor: palette.cyan + "33",
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: 'max ctx',
|
||||
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,
|
||||
backgroundColor: palette.amber + "33",
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2,
|
||||
spanGaps: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: { grid: { color: palette.border } },
|
||||
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtInt(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderCostChart(s) {
|
||||
const id = 'chart-cost';
|
||||
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',
|
||||
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 },
|
||||
{
|
||||
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,
|
||||
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) } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { callback: (v) => fmtInt(v) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderModelChart(s) {
|
||||
const id = 'chart-model';
|
||||
const id = "chart-model";
|
||||
destroy(id);
|
||||
const models = s.models || [];
|
||||
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
|
||||
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.
|
||||
|
|
@ -292,28 +353,36 @@ window.Chart = Chart;
|
|||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
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 } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderResultTrendChart(s) {
|
||||
const id = 'chart-result-trend';
|
||||
const id = "chart-result-trend";
|
||||
destroy(id);
|
||||
// Series = the result kinds seen in the window (same order +
|
||||
// colours as the result-mix doughnut). One stacked bar series per
|
||||
// kind, so error / rate-limit / compaction spikes line up in time.
|
||||
const kinds = (s.result_mix || []).map((kc) => kc.key);
|
||||
if (!kinds.length) { paintEmpty(id, 'no results'); return; }
|
||||
if (!kinds.length) {
|
||||
paintEmpty(id, "no results");
|
||||
return;
|
||||
}
|
||||
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
|
||||
const datasets = kinds.map((k, i) => ({
|
||||
label: k,
|
||||
|
|
@ -321,15 +390,20 @@ window.Chart = Chart;
|
|||
backgroundColor: wheel[i % wheel.length],
|
||||
}));
|
||||
charts[id] = new Chart(document.getElementById(id), {
|
||||
type: 'bar',
|
||||
type: "bar",
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
|
||||
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 } },
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: { color: palette.border },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -345,11 +419,22 @@ window.Chart = Chart;
|
|||
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 }] },
|
||||
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 } } },
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: "right", labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -360,30 +445,30 @@ window.Chart = Chart;
|
|||
// doughnut. Runs independently of turn_count (a bash task is tied to
|
||||
// a turn, but we don't want to couple the two reads).
|
||||
function renderBashCard(s) {
|
||||
const card = document.getElementById('card-bash');
|
||||
const card = document.getElementById("card-bash");
|
||||
const items = s.bash_breakdown || [];
|
||||
if (!items.length) {
|
||||
if (card) card.hidden = true;
|
||||
destroy('chart-bash');
|
||||
destroy("chart-bash");
|
||||
return;
|
||||
}
|
||||
if (card) card.hidden = false;
|
||||
renderKeyCount('chart-bash', items, 'no bash commands');
|
||||
renderKeyCount("chart-bash", items, "no bash commands");
|
||||
}
|
||||
|
||||
function render(s) {
|
||||
renderSummary(s);
|
||||
renderBashCard(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');
|
||||
paintEmpty('chart-result-trend', 'no results');
|
||||
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");
|
||||
paintEmpty("chart-result-trend", "no results");
|
||||
return;
|
||||
}
|
||||
renderTurnsChart(s);
|
||||
|
|
@ -391,51 +476,63 @@ window.Chart = Chart;
|
|||
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');
|
||||
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");
|
||||
renderResultTrendChart(s);
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const resp = await fetch('api/stats?window=' + encodeURIComponent(currentWindow));
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
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;
|
||||
document.getElementById("summary").textContent =
|
||||
"stats fetch failed: " + e;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
|
||||
const dl = document.getElementById('dashboard-link');
|
||||
document.title = "stats · " + s.label;
|
||||
document.getElementById("title").textContent = "◆ " + s.label + " ◆";
|
||||
const dl = document.getElementById("dashboard-link");
|
||||
// When accessed via hive-gateway the page lives at `/agent/<name>/`
|
||||
// on the same origin as the dashboard. Detect via path prefix
|
||||
// rather than the direct port (which is unreachable or wrong scheme
|
||||
// behind HTTPS TLS termination). The dashboard SPA lives at
|
||||
// `dashboard.html` — the `/` root now serves the H0M3 menu hub.
|
||||
dl.href = window.location.pathname.startsWith('/agent/')
|
||||
? window.location.origin + '/dashboard.html'
|
||||
: 'http://' + window.location.hostname + ':' + s.dashboard_port + '/dashboard.html';
|
||||
} catch (_) { /* non-fatal */ }
|
||||
dl.href = window.location.pathname.startsWith("/agent/")
|
||||
? window.location.origin + "/dashboard.html"
|
||||
: "http://" +
|
||||
window.location.hostname +
|
||||
":" +
|
||||
s.dashboard_port +
|
||||
"/dashboard.html";
|
||||
} catch (_) {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadIdentity();
|
||||
// The shared hash-routed tab strip drives the stat-window selector,
|
||||
// making it deep-linkable (#1h / #24h / …). onShow updates the
|
||||
// window + reloads; the strip's initial show fires onShow once, so
|
||||
// there's no separate loadStats() call here (avoids a double fetch).
|
||||
createTabStrip(document.getElementById('window-tabs'), {
|
||||
createTabStrip(document.getElementById("window-tabs"), {
|
||||
defaultId: currentWindow,
|
||||
onShow: (w) => { currentWindow = w; loadStats(); },
|
||||
onShow: (w) => {
|
||||
currentWindow = w;
|
||||
loadStats();
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ export interface AgentState {
|
|||
label: string;
|
||||
qualified_label: string;
|
||||
dashboard_port: number;
|
||||
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress';
|
||||
status:
|
||||
| "online"
|
||||
| "rate_limited"
|
||||
| "needs_login_idle"
|
||||
| "needs_login_in_progress";
|
||||
session: SessionView | null;
|
||||
turn_state: string;
|
||||
turn_state_since: number;
|
||||
|
|
@ -38,7 +42,7 @@ export interface AgentLink {
|
|||
url: string;
|
||||
icon: string;
|
||||
label: string;
|
||||
kind: 'container' | 'forge' | 'external';
|
||||
kind: "container" | "forge" | "external";
|
||||
}
|
||||
|
||||
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
|
||||
|
|
@ -72,7 +76,7 @@ export interface InboxRow {
|
|||
// the only variant `GET /api/todos` actually returns (a dynamic,
|
||||
// subsystem-pushed todo: matrix/bash/forge are the built-in producers).
|
||||
export interface TodoRow {
|
||||
kind: 'todo';
|
||||
kind: "todo";
|
||||
id: number;
|
||||
subsystem: string;
|
||||
subsystem_key?: string | null;
|
||||
|
|
|
|||
|
|
@ -48,32 +48,41 @@
|
|||
// prefix) so it never shadows the exact-match /dashboard/stream +
|
||||
// /dashboard/history SSE routes registered before the ServeDir fallback.
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = (p) => resolve(here, 'src', p);
|
||||
const dist = (p) => resolve(here, 'dist', p);
|
||||
const staticDir = (p) => resolve(here, 'dist', 'static', p);
|
||||
const src = (p) => resolve(here, "src", p);
|
||||
const dist = (p) => resolve(here, "dist", p);
|
||||
const staticDir = (p) => resolve(here, "dist", "static", p);
|
||||
|
||||
rmSync(dist(''), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(''), { recursive: true });
|
||||
rmSync(dist(""), { recursive: true, force: true });
|
||||
mkdirSync(staticDir(""), { recursive: true });
|
||||
|
||||
// Bundle the JS entries. ES-module output, browser target, no minify
|
||||
// (line-aligned source aids debugging; minification belongs in a later
|
||||
// follow-up once asset sizes warrant it). esbuild writes each entry
|
||||
// to `static/<name>.js` based on the entryPoint basename.
|
||||
await build({
|
||||
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('stats.js'), src('core.js'), src('builds.js'), src('credentials.js')],
|
||||
outdir: staticDir(''),
|
||||
entryPoints: [
|
||||
src("tabs.js"),
|
||||
src("flow.js"),
|
||||
src("logs.js"),
|
||||
src("home.js"),
|
||||
src("stats.js"),
|
||||
src("core.js"),
|
||||
src("builds.js"),
|
||||
src("credentials.js"),
|
||||
],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
logLevel: "info",
|
||||
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
|
||||
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
|
||||
// text via a plain `import css from './foo.css'` — the `text` loader turns
|
||||
|
|
@ -82,15 +91,15 @@ await build({
|
|||
// file any other way, so this doesn't collide with the separate
|
||||
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
|
||||
// runs as its own esbuild invocation over different entry points.
|
||||
loader: { '.css': 'text' },
|
||||
loader: { ".css": "text" },
|
||||
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
|
||||
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
|
||||
// already picks the `jsx` loader for `.jsx` by extension, this just
|
||||
// sets the transform mode to match swarm-ui's (which also authors
|
||||
// this file). No other entry here uses JSX today; this doesn't turn
|
||||
// any plain `.js` file into one, `.js` still parses as plain JS.
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'preact',
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "preact",
|
||||
});
|
||||
|
||||
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
|
||||
|
|
@ -106,14 +115,14 @@ await build({
|
|||
// the IIFE format will surface it as a build error rather than
|
||||
// silently shipping broken code.
|
||||
await build({
|
||||
entryPoints: [src('stream-worker.js')],
|
||||
outdir: staticDir(''),
|
||||
entryPoints: [src("stream-worker.js")],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
platform: 'browser',
|
||||
target: ['es2022'],
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
logLevel: 'info',
|
||||
logLevel: "info",
|
||||
});
|
||||
|
||||
// Bundle CSS — one entry per page. esbuild resolves @import including
|
||||
|
|
@ -122,18 +131,39 @@ await build({
|
|||
// so a swap replaces only it) + theme.css (the semantic derivation
|
||||
// layer) + common.css (shared typography, badges, buttons, inbox, side
|
||||
// panel) plus its own page-specific bundle.
|
||||
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'stats.css', 'core.css', 'builds.css', 'credentials.css']) {
|
||||
for (const entry of [
|
||||
"colors.css",
|
||||
"theme.css",
|
||||
"common.css",
|
||||
"dashboard.css",
|
||||
"flow.css",
|
||||
"logs.css",
|
||||
"home.css",
|
||||
"stats.css",
|
||||
"core.css",
|
||||
"builds.css",
|
||||
"credentials.css",
|
||||
]) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
outfile: staticDir(entry),
|
||||
bundle: true,
|
||||
loader: { '.css': 'css' },
|
||||
logLevel: 'info',
|
||||
loader: { ".css": "css" },
|
||||
logLevel: "info",
|
||||
});
|
||||
}
|
||||
|
||||
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'stats.html', 'core.html', 'builds.html', 'credentials.html']) {
|
||||
for (const html of [
|
||||
"index.html",
|
||||
"dashboard.html",
|
||||
"flow.html",
|
||||
"logs.html",
|
||||
"stats.html",
|
||||
"core.html",
|
||||
"builds.html",
|
||||
"credentials.html",
|
||||
]) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
||||
console.log('dashboard build ok →', dist(''));
|
||||
console.log("dashboard build ok →", dist(""));
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@
|
|||
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
|
||||
// own shadow-scoped `.menu-dropdown`).
|
||||
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
|
||||
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
|
||||
import "@hive/shared/hive-menu.js"; // registers <hive-menu> — side-effect import
|
||||
|
||||
// Single-agent POST helper shared by all menu items. `flags` is an object
|
||||
// of boolean query params to set truthy (e.g. `{ graceful: true }` or
|
||||
|
|
@ -34,25 +34,32 @@ import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect imp
|
|||
async function agentMenuPost(actionPath, name, body, flags) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(flags || {})) {
|
||||
if (v) params.set(k, 'true');
|
||||
if (v) params.set(k, "true");
|
||||
}
|
||||
const qs = params.toString();
|
||||
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : '');
|
||||
const url = actionPath + encodeURIComponent(name) + (qs ? "?" + qs : "");
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
|
||||
method: "POST",
|
||||
headers: body
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: {},
|
||||
body: body ? new URLSearchParams(body) : undefined,
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||
|| (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
const text = await resp.text().catch(() => "");
|
||||
themedToast(
|
||||
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
|
||||
{ type: "error" },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
themedToast('action failed: ' + err, { type: 'error' });
|
||||
themedToast("action failed: " + err, { type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,40 +74,58 @@ class HiveAgentMenu extends HTMLElement {
|
|||
|
||||
const { c, forgeBase } = this._opts || {};
|
||||
|
||||
const btn = el('button', {
|
||||
type: 'button',
|
||||
class: 'agent-menu-btn',
|
||||
title: `actions for ${c.name}`,
|
||||
'aria-label': `actions for ${c.name}`,
|
||||
'aria-haspopup': 'menu',
|
||||
'aria-expanded': 'false',
|
||||
}, '⋮');
|
||||
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
|
||||
const btn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "agent-menu-btn",
|
||||
title: `actions for ${c.name}`,
|
||||
"aria-label": `actions for ${c.name}`,
|
||||
"aria-haspopup": "menu",
|
||||
"aria-expanded": "false",
|
||||
},
|
||||
"⋮",
|
||||
);
|
||||
const dropdown = el("ul", { class: "agent-menu-dropdown", role: "menu" });
|
||||
|
||||
const close = () => this._menu.close();
|
||||
|
||||
const menuItem = (label, opts) => {
|
||||
const li = el('li', { role: 'presentation' });
|
||||
const item = el('button', {
|
||||
type: 'button',
|
||||
class: 'agent-menu-item',
|
||||
role: 'menuitem',
|
||||
}, label);
|
||||
item.addEventListener('click', async () => {
|
||||
const li = el("li", { role: "presentation" });
|
||||
const item = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "agent-menu-item",
|
||||
role: "menuitem",
|
||||
},
|
||||
label,
|
||||
);
|
||||
item.addEventListener("click", async () => {
|
||||
close();
|
||||
let flags = {};
|
||||
if (opts.confirm) {
|
||||
const checkboxes = [];
|
||||
if (opts.graceful) {
|
||||
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' });
|
||||
checkboxes.push({
|
||||
name: "graceful",
|
||||
label:
|
||||
opts.gracefulLabel ||
|
||||
"stop gracefully — let the agent finish its turn and flush state before the container stops",
|
||||
});
|
||||
}
|
||||
if (opts.paused) {
|
||||
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' });
|
||||
checkboxes.push({
|
||||
name: "paused",
|
||||
label:
|
||||
opts.pausedLabel ||
|
||||
"start paused — come up without driving turns until resumed",
|
||||
});
|
||||
}
|
||||
const r = await themedConfirm({
|
||||
message: opts.confirm,
|
||||
danger: true,
|
||||
confirmLabel: opts.confirmLabel || 'confirm',
|
||||
confirmLabel: opts.confirmLabel || "confirm",
|
||||
checkboxes,
|
||||
});
|
||||
if (!r) return;
|
||||
|
|
@ -112,18 +137,23 @@ class HiveAgentMenu extends HTMLElement {
|
|||
return li;
|
||||
};
|
||||
|
||||
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' });
|
||||
const menuSep = () =>
|
||||
el("li", { class: "agent-menu-sep", role: "separator" });
|
||||
|
||||
// Navigation link item (opens in same tab by default).
|
||||
const menuLink = (label, href, title) => {
|
||||
const li = el('li', { role: 'presentation' });
|
||||
const a = el('a', {
|
||||
class: 'agent-menu-item',
|
||||
href,
|
||||
role: 'menuitem',
|
||||
title: title || '',
|
||||
}, label);
|
||||
a.addEventListener('click', close);
|
||||
const li = el("li", { role: "presentation" });
|
||||
const a = el(
|
||||
"a",
|
||||
{
|
||||
class: "agent-menu-item",
|
||||
href,
|
||||
role: "menuitem",
|
||||
title: title || "",
|
||||
},
|
||||
label,
|
||||
);
|
||||
a.addEventListener("click", close);
|
||||
li.append(a);
|
||||
return li;
|
||||
};
|
||||
|
|
@ -131,21 +161,28 @@ class HiveAgentMenu extends HTMLElement {
|
|||
// Show only actions that are applicable in the current state.
|
||||
if (c.running) {
|
||||
dropdown.append(
|
||||
menuItem('↺ R3ST4RT', {
|
||||
action: '/api/restart/',
|
||||
menuItem("↺ R3ST4RT", {
|
||||
action: "/api/restart/",
|
||||
confirm: `restart ${c.name}?`,
|
||||
graceful: true,
|
||||
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
|
||||
gracefulLabel:
|
||||
"restart gracefully — let the agent finish its turn and flush state before the container restarts",
|
||||
}),
|
||||
menuItem("■ ST0P", {
|
||||
action: "/api/kill/",
|
||||
confirm: `stop ${c.name}?`,
|
||||
confirmLabel: "■ stop",
|
||||
graceful: true,
|
||||
}),
|
||||
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
|
||||
);
|
||||
} else {
|
||||
dropdown.append(
|
||||
menuItem('▶ ST4RT', {
|
||||
action: '/api/start/',
|
||||
menuItem("▶ ST4RT", {
|
||||
action: "/api/start/",
|
||||
confirm: `start ${c.name}?`,
|
||||
paused: true,
|
||||
pausedLabel: 'start paused — come up without driving turns until resumed',
|
||||
pausedLabel:
|
||||
"start paused — come up without driving turns until resumed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -153,55 +190,70 @@ class HiveAgentMenu extends HTMLElement {
|
|||
// paused; a paused running agent keeps its container but drives no turns.
|
||||
if (c.paused) {
|
||||
dropdown.append(
|
||||
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
|
||||
menuItem("▶ R3SUM3", {
|
||||
action: "/api/resume/",
|
||||
confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.`,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dropdown.append(
|
||||
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
|
||||
menuItem("⏸ P4US3", {
|
||||
action: "/api/pause/",
|
||||
confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
dropdown.append(
|
||||
menuSep(),
|
||||
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
|
||||
menuItem("↻ R3BU1LD", {
|
||||
action: "/api/rebuild/",
|
||||
confirm: `rebuild ${c.name}? hot-reloads the container.`,
|
||||
}),
|
||||
menuSep(),
|
||||
// Deep-link to the AGENT log tab pre-filtered to this container.
|
||||
// The ?agent= param is read by logs.js on load and pre-selects this
|
||||
// agent's journal without extra clicks.
|
||||
menuLink('journal logs →',
|
||||
menuLink(
|
||||
"journal logs →",
|
||||
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
|
||||
`view ${c.name} journal logs`),
|
||||
`view ${c.name} journal logs`,
|
||||
),
|
||||
);
|
||||
|
||||
dropdown.append(
|
||||
menuSep(),
|
||||
menuItem('DESTR0Y', {
|
||||
action: '/api/destroy/',
|
||||
menuItem("DESTR0Y", {
|
||||
action: "/api/destroy/",
|
||||
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
|
||||
}),
|
||||
menuItem('PURG3', {
|
||||
action: '/api/destroy/',
|
||||
body: { purge: 'on' },
|
||||
menuItem("PURG3", {
|
||||
action: "/api/destroy/",
|
||||
body: { purge: "on" },
|
||||
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
|
||||
}),
|
||||
);
|
||||
|
||||
if (c.deployed_sha && forgeBase) {
|
||||
const li = el('li', { role: 'presentation' });
|
||||
const a = el('a', {
|
||||
class: 'agent-menu-item',
|
||||
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
|
||||
target: '_blank',
|
||||
rel: 'noopener',
|
||||
role: 'menuitem',
|
||||
title: 'deployed config commit on forge',
|
||||
}, `deployed:${c.deployed_sha} ↗`);
|
||||
const li = el("li", { role: "presentation" });
|
||||
const a = el(
|
||||
"a",
|
||||
{
|
||||
class: "agent-menu-item",
|
||||
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
role: "menuitem",
|
||||
title: "deployed config commit on forge",
|
||||
},
|
||||
`deployed:${c.deployed_sha} ↗`,
|
||||
);
|
||||
li.append(a);
|
||||
dropdown.append(menuSep(), li);
|
||||
}
|
||||
|
||||
this._menu = document.createElement('hive-menu');
|
||||
this._menu = document.createElement("hive-menu");
|
||||
this._menu._opts = { trigger: btn, content: dropdown };
|
||||
this.append(this._menu);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-agent-menu', HiveAgentMenu);
|
||||
customElements.define("hive-agent-menu", HiveAgentMenu);
|
||||
|
|
|
|||
|
|
@ -24,4 +24,6 @@ body.builds-shell {
|
|||
padding: 1.2em 1.5em 2em;
|
||||
}
|
||||
|
||||
.builds-pane[hidden] { display: none; }
|
||||
.builds-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,103 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // BU1LDS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/builds.css">
|
||||
</head>
|
||||
<body class="builds-shell">
|
||||
|
||||
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // BU1LDS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/builds.css" />
|
||||
</head>
|
||||
<body class="builds-shell">
|
||||
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
|
||||
meta inputs, and build log history on one page. Carved out of the
|
||||
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
|
||||
tab) so the full build lifecycle is in one place. Same minimal
|
||||
chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
|
||||
sub-tab nav. Default sub-tab is the rebuild queue. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip class="hive-tabbar builds-tabbar" id="builds-tabbar" prefix="builds"
|
||||
role="tablist"></hive-tab-strip>
|
||||
</header>
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar builds-tabbar"
|
||||
id="builds-tabbar"
|
||||
prefix="builds"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="builds-main">
|
||||
|
||||
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
||||
<main class="builds-main">
|
||||
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
|
||||
first-spawns. Rendered from GET /api/jobq/graph by the
|
||||
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
|
||||
`rebuild_queue_changed` over /api/dashboard/stream is the
|
||||
refresh trigger and carries no payload of its own. Default tab. -->
|
||||
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
|
||||
role="tabpanel" aria-labelledby="builds-tab-rebuild">
|
||||
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
|
||||
<div id="rebuild-queue-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<!-- Live build log of the currently-running rebuild (one runs at a
|
||||
<section
|
||||
class="builds-pane"
|
||||
id="builds-pane-rebuild"
|
||||
data-tab-pane="rebuild"
|
||||
role="tabpanel"
|
||||
aria-labelledby="builds-tab-rebuild"
|
||||
>
|
||||
<p class="meta">
|
||||
pending + running rebuilds, meta-updates, and first-spawns. one runs
|
||||
at a time; meta-update cascades nest under their parent. dedup:
|
||||
re-enqueueing a still-queued op collapses into the existing entry.
|
||||
</p>
|
||||
<div id="rebuild-queue-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<!-- Live build log of the currently-running rebuild (one runs at a
|
||||
time). Managed by renderRebuildLiveLog in builds.js, separate from
|
||||
rebuild-queue-section so the queue's row re-render never disturbs
|
||||
the open SSE stream. -->
|
||||
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
|
||||
</section>
|
||||
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
|
||||
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta"
|
||||
role="tabpanel" aria-labelledby="builds-tab-meta">
|
||||
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; the submitting agent learns each outcome via the usual <code>rebuilt</code> system event.</p>
|
||||
<div id="meta-inputs-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
|
||||
<section
|
||||
class="builds-pane"
|
||||
id="builds-pane-meta"
|
||||
data-tab-pane="meta"
|
||||
role="tabpanel"
|
||||
aria-labelledby="builds-tab-meta"
|
||||
>
|
||||
<p class="meta">
|
||||
select inputs to <code>nix flake update</code> in <code>/meta/</code>.
|
||||
selected agents rebuild in sequence after the lock bump; the
|
||||
submitting agent learns each outcome via the usual
|
||||
<code>rebuilt</code> system event.
|
||||
</p>
|
||||
<div id="meta-inputs-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
|
||||
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
|
||||
Click a row to expand stdout + stderr. Live builds stream in real
|
||||
time. Lazy-loaded on first tab activation; auto-refreshes when
|
||||
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
|
||||
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs"
|
||||
role="tabpanel" aria-labelledby="builds-tab-buildlogs">
|
||||
<p class="meta">all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.</p>
|
||||
<div class="logs-toolbar">
|
||||
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
|
||||
</div>
|
||||
<div id="build-list"><p class="meta">loading…</p></div>
|
||||
</section>
|
||||
<section
|
||||
class="builds-pane"
|
||||
id="builds-pane-buildlogs"
|
||||
data-tab-pane="buildlogs"
|
||||
role="tabpanel"
|
||||
aria-labelledby="builds-tab-buildlogs"
|
||||
>
|
||||
<p class="meta">
|
||||
all-agents build log history. click a row to expand stdout + stderr.
|
||||
live builds stream in real time.
|
||||
</p>
|
||||
<div class="logs-toolbar">
|
||||
<button type="button" class="btn btn-restart" id="build-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="build-list"><p class="meta">loading…</p></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/builds.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/builds.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -10,14 +10,20 @@
|
|||
// renderers here are direct copies from core.js / logs.js with only the
|
||||
// deep-link URL and count-pill id adjusted.
|
||||
|
||||
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { bindAsyncForms } from '@hive/shared/forms.js';
|
||||
import { themedConfirm } from '@hive/shared/modal.js';
|
||||
import { h, render } from 'preact';
|
||||
import { fmtAgo, fmtDuration, truncate } from './util.js';
|
||||
import '@hive/shared/hive-tab-strip.js';
|
||||
import { JobqGraph } from '@hive/shared/jobq-graph.js';
|
||||
import {
|
||||
$,
|
||||
fmtAgeSecs,
|
||||
openStream,
|
||||
openBuildLogStream,
|
||||
initServerWarnings,
|
||||
} from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { bindAsyncForms } from "@hive/shared/forms.js";
|
||||
import { themedConfirm } from "@hive/shared/modal.js";
|
||||
import { h, render } from "preact";
|
||||
import { fmtAgo, fmtDuration, truncate } from "./util.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
import { JobqGraph } from "@hive/shared/jobq-graph.js";
|
||||
|
||||
// ─── derived state ───────────────────────────────────────────────────────────
|
||||
let metaInputsState = [];
|
||||
|
|
@ -36,93 +42,122 @@ function syncFromSnapshot(s) {
|
|||
|
||||
// ─── meta inputs ─────────────────────────────────────────────────────────────
|
||||
function renderMetaInputs(s) {
|
||||
const root = $('meta-inputs-section');
|
||||
const root = $("meta-inputs-section");
|
||||
if (!root) return;
|
||||
// Snapshot ticked checkboxes before wiping so a concurrent
|
||||
// MetaInputsChanged doesn't silently clear a pending selection.
|
||||
const checkedInputs = new Set(
|
||||
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'))
|
||||
.map((cb) => cb.dataset.metaInput),
|
||||
Array.from(
|
||||
root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'),
|
||||
).map((cb) => cb.dataset.metaInput),
|
||||
);
|
||||
root.replaceChildren();
|
||||
const inputs = s.meta_inputs || [];
|
||||
if (!inputs.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
|
||||
root.append(el("p", { class: "empty" }, "meta repo not seeded yet"));
|
||||
return;
|
||||
}
|
||||
if (metaUpdateRunning) {
|
||||
root.append(el('p', { class: 'meta-update-running' },
|
||||
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
|
||||
+ 'watch the agent cards for per-rebuild progress.'));
|
||||
root.append(
|
||||
el(
|
||||
"p",
|
||||
{ class: "meta-update-running" },
|
||||
"⏳ meta-update running — flake lock bump + affected agents rebuilding. " +
|
||||
"watch the agent cards for per-rebuild progress.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const f = el('form', {
|
||||
method: 'POST',
|
||||
action: '/api/meta-update',
|
||||
class: 'meta-inputs-form',
|
||||
'data-async': '',
|
||||
'data-no-refresh': '',
|
||||
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
||||
const f = el("form", {
|
||||
method: "POST",
|
||||
action: "/api/meta-update",
|
||||
class: "meta-inputs-form",
|
||||
"data-async": "",
|
||||
"data-no-refresh": "",
|
||||
"data-confirm":
|
||||
"update selected meta flake inputs + rebuild affected agents?",
|
||||
});
|
||||
const bulk = el('div', { class: 'meta-inputs-bulk' });
|
||||
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
|
||||
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
|
||||
bulk.append('bulk: ', selAll, ' ', selNone);
|
||||
const bulk = el("div", { class: "meta-inputs-bulk" });
|
||||
const selAll = el(
|
||||
"button",
|
||||
{ type: "button", class: "meta-bulk-btn" },
|
||||
"select all",
|
||||
);
|
||||
const selNone = el(
|
||||
"button",
|
||||
{ type: "button", class: "meta-bulk-btn" },
|
||||
"select none",
|
||||
);
|
||||
bulk.append("bulk: ", selAll, " ", selNone);
|
||||
f.append(bulk);
|
||||
const ul = el('ul', { class: 'meta-inputs' });
|
||||
const ul = el("ul", { class: "meta-inputs" });
|
||||
for (const inp of inputs) {
|
||||
const depth = (inp.name.match(/\//g) || []).length;
|
||||
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
|
||||
const li = el('li');
|
||||
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
|
||||
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
|
||||
const cb = el('input', {
|
||||
type: 'checkbox',
|
||||
name: 'meta_input_' + inp.name,
|
||||
const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1);
|
||||
const li = el("li");
|
||||
if (depth > 0) li.style.marginLeft = depth * 1.3 + "em";
|
||||
const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_");
|
||||
const cb = el("input", {
|
||||
type: "checkbox",
|
||||
name: "meta_input_" + inp.name,
|
||||
id,
|
||||
value: inp.name,
|
||||
'data-meta-input': inp.name,
|
||||
"data-meta-input": inp.name,
|
||||
});
|
||||
if (checkedInputs.has(inp.name)) cb.checked = true;
|
||||
const label = el('label', { for: id, title: inp.name });
|
||||
const label = el("label", { for: id, title: inp.name });
|
||||
label.append(cb);
|
||||
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
|
||||
if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ "));
|
||||
label.append(
|
||||
el('span', { class: 'meta-input-name' }, leaf), ' ',
|
||||
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
|
||||
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
|
||||
el("span", { class: "meta-input-name" }, leaf),
|
||||
" ",
|
||||
el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)),
|
||||
" ",
|
||||
el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)),
|
||||
);
|
||||
if (inp.url) {
|
||||
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
|
||||
'· ' + truncate(inp.url, 48)));
|
||||
label.append(
|
||||
" ",
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta-input-url", title: inp.url },
|
||||
"· " + truncate(inp.url, 48),
|
||||
),
|
||||
);
|
||||
}
|
||||
li.append(label);
|
||||
ul.append(li);
|
||||
}
|
||||
f.append(ul);
|
||||
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
|
||||
const hidden = el("input", { type: "hidden", name: "inputs", value: "" });
|
||||
f.append(hidden);
|
||||
const btn = el('button', {
|
||||
type: 'submit',
|
||||
class: 'btn btn-meta-update',
|
||||
disabled: '',
|
||||
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
|
||||
const btn = el(
|
||||
"button",
|
||||
{
|
||||
type: "submit",
|
||||
class: "btn btn-meta-update",
|
||||
disabled: "",
|
||||
},
|
||||
metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD",
|
||||
);
|
||||
f.append(btn);
|
||||
function refreshDisabled() {
|
||||
const any = f.querySelectorAll('input[data-meta-input]:checked').length > 0;
|
||||
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
|
||||
else btn.setAttribute('disabled', '');
|
||||
const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0;
|
||||
if (any && !metaUpdateRunning) btn.removeAttribute("disabled");
|
||||
else btn.setAttribute("disabled", "");
|
||||
}
|
||||
f.addEventListener('change', refreshDisabled);
|
||||
f.addEventListener("change", refreshDisabled);
|
||||
function setAllChecked(val) {
|
||||
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = val;
|
||||
for (const b of f.querySelectorAll("input[data-meta-input]"))
|
||||
b.checked = val;
|
||||
refreshDisabled();
|
||||
}
|
||||
selAll.addEventListener('click', () => setAllChecked(true));
|
||||
selNone.addEventListener('click', () => setAllChecked(false));
|
||||
f.addEventListener('submit', () => {
|
||||
const selected = Array.from(f.querySelectorAll('input[data-meta-input]:checked'))
|
||||
.map((b) => b.dataset.metaInput);
|
||||
hidden.value = selected.join(',');
|
||||
selAll.addEventListener("click", () => setAllChecked(true));
|
||||
selNone.addEventListener("click", () => setAllChecked(false));
|
||||
f.addEventListener("submit", () => {
|
||||
const selected = Array.from(
|
||||
f.querySelectorAll("input[data-meta-input]:checked"),
|
||||
).map((b) => b.dataset.metaInput);
|
||||
hidden.value = selected.join(",");
|
||||
});
|
||||
root.append(f);
|
||||
}
|
||||
|
|
@ -150,41 +185,50 @@ function renderMetaInputs(s) {
|
|||
// tracked children out from under its diffing rather than let it
|
||||
// update them minimally).
|
||||
function mountRebuildQueue() {
|
||||
const root = $('rebuild-queue-section');
|
||||
const root = $("rebuild-queue-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
renderRebuildQueue();
|
||||
}
|
||||
function renderRebuildQueue() {
|
||||
const root = $('rebuild-queue-section');
|
||||
const root = $("rebuild-queue-section");
|
||||
if (!root) return;
|
||||
render(h(JobqGraph, {
|
||||
endpoint: '/api/jobq/graph',
|
||||
cancellable: true,
|
||||
refreshToken: jobqGraphToken,
|
||||
onUpdate: (nodes) => {
|
||||
jobqNodes = nodes || [];
|
||||
renderRebuildLiveLog();
|
||||
updateRebuildCount();
|
||||
},
|
||||
onCancel: async (id) => {
|
||||
const node = jobqNodes.find((n) => n.id === id);
|
||||
const label = node ? node.payload.label : 'node ' + id;
|
||||
if (!(await themedConfirm({
|
||||
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
||||
danger: true, confirmLabel: '✕ cancel',
|
||||
}))) return;
|
||||
try {
|
||||
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
// No manual refresh: cancel flips node state, which fires
|
||||
// rebuild_queue_changed over SSE — the existing handler below
|
||||
// already bumps jobqGraphToken and re-renders on that tick.
|
||||
} catch (err) {
|
||||
console.error('cancel failed', err);
|
||||
}
|
||||
},
|
||||
}), root);
|
||||
render(
|
||||
h(JobqGraph, {
|
||||
endpoint: "/api/jobq/graph",
|
||||
cancellable: true,
|
||||
refreshToken: jobqGraphToken,
|
||||
onUpdate: (nodes) => {
|
||||
jobqNodes = nodes || [];
|
||||
renderRebuildLiveLog();
|
||||
updateRebuildCount();
|
||||
},
|
||||
onCancel: async (id) => {
|
||||
const node = jobqNodes.find((n) => n.id === id);
|
||||
const label = node ? node.payload.label : "node " + id;
|
||||
if (
|
||||
!(await themedConfirm({
|
||||
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
|
||||
danger: true,
|
||||
confirmLabel: "✕ cancel",
|
||||
}))
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const r = await fetch("/api/rebuild-queue/" + id + "/cancel", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
// No manual refresh: cancel flips node state, which fires
|
||||
// rebuild_queue_changed over SSE — the existing handler below
|
||||
// already bumps jobqGraphToken and re-renders on that tick.
|
||||
} catch (err) {
|
||||
console.error("cancel failed", err);
|
||||
}
|
||||
},
|
||||
}),
|
||||
root,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── running-rebuild live log ─────────────────────────────────────────────────
|
||||
|
|
@ -196,13 +240,16 @@ function renderRebuildQueue() {
|
|||
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
|
||||
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
|
||||
// on transition, then done).
|
||||
let liveLogId = null; // current node id being shown
|
||||
let liveLogDone = false; // true once the node left 'Running'
|
||||
let liveLogId = null; // current node id being shown
|
||||
let liveLogDone = false; // true once the node left 'Running'
|
||||
let liveLogCollapsed = false;
|
||||
let liveLogPollTimer = null;
|
||||
|
||||
function clearLiveLogPoll() {
|
||||
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
|
||||
if (liveLogPollTimer) {
|
||||
clearInterval(liveLogPollTimer);
|
||||
liveLogPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// First running node with a log, in wire order (root-then-subtree per
|
||||
|
|
@ -210,26 +257,39 @@ function clearLiveLogPoll() {
|
|||
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
|
||||
// panel.
|
||||
function findLiveBuild() {
|
||||
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
|
||||
|| null;
|
||||
return (
|
||||
jobqNodes.find(
|
||||
(n) =>
|
||||
n.state === "Running" &&
|
||||
n.payload.data &&
|
||||
n.payload.data.build_log_id != null,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAndRenderLiveLog(nodeId, pre) {
|
||||
try {
|
||||
const r = await fetch('/api/build-log/' + nodeId);
|
||||
const r = await fetch("/api/build-log/" + nodeId);
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
const text = [data.stdout, data.stderr ? '--- stderr ---\n' + data.stderr : ''].filter(Boolean).join('\n');
|
||||
const text = [
|
||||
data.stdout,
|
||||
data.stderr ? "--- stderr ---\n" + data.stderr : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (pre.textContent !== text) {
|
||||
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
||||
pre.textContent = text;
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
} catch { /* network blip — ignore, next poll will retry */ }
|
||||
} catch {
|
||||
/* network blip — ignore, next poll will retry */
|
||||
}
|
||||
}
|
||||
|
||||
function renderRebuildLiveLog() {
|
||||
const root = $('rebuild-live-log');
|
||||
const root = $("rebuild-live-log");
|
||||
if (!root) return;
|
||||
const liveNode = findLiveBuild();
|
||||
|
||||
|
|
@ -237,22 +297,26 @@ function renderRebuildLiveLog() {
|
|||
clearLiveLogPoll();
|
||||
liveLogId = null;
|
||||
liveLogDone = false;
|
||||
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
|
||||
if (!root.hidden) {
|
||||
root.hidden = true;
|
||||
root.replaceChildren();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Same node, already polling — just let the timer tick (or do a final
|
||||
// fetch if the node just went non-running and we haven't marked done yet).
|
||||
if (liveNode.id === liveLogId) {
|
||||
if (!liveLogDone && liveNode.state !== 'Running') {
|
||||
if (!liveLogDone && liveNode.state !== "Running") {
|
||||
clearLiveLogPoll();
|
||||
liveLogDone = true;
|
||||
const pre = root.querySelector('.rebuild-live-log-output');
|
||||
const badge = root.querySelector('.rebuild-live-log-badge');
|
||||
const pre = root.querySelector(".rebuild-live-log-output");
|
||||
const badge = root.querySelector(".rebuild-live-log-badge");
|
||||
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
|
||||
if (badge) {
|
||||
const ok = liveNode.state !== 'Failed';
|
||||
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
|
||||
const ok = liveNode.state !== "Failed";
|
||||
badge.className =
|
||||
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
|
||||
badge.textContent = liveNode.state;
|
||||
}
|
||||
}
|
||||
|
|
@ -266,54 +330,84 @@ function renderRebuildLiveLog() {
|
|||
root.hidden = false;
|
||||
root.replaceChildren();
|
||||
|
||||
const pre = el('pre', { class: 'rebuild-live-log-output' }, '');
|
||||
const pre = el("pre", { class: "rebuild-live-log-output" }, "");
|
||||
pre.hidden = liveLogCollapsed;
|
||||
const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live');
|
||||
const toggle = el('button', {
|
||||
type: 'button',
|
||||
class: 'rebuild-live-log-toggle',
|
||||
'aria-expanded': String(!liveLogCollapsed),
|
||||
title: liveLogCollapsed ? 'expand live log' : 'collapse live log',
|
||||
}, liveLogCollapsed ? '▸' : '▾');
|
||||
toggle.addEventListener('click', () => {
|
||||
const badge = el(
|
||||
"span",
|
||||
{ class: "rebuild-live-log-badge rll-running" },
|
||||
"live",
|
||||
);
|
||||
const toggle = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "rebuild-live-log-toggle",
|
||||
"aria-expanded": String(!liveLogCollapsed),
|
||||
title: liveLogCollapsed ? "expand live log" : "collapse live log",
|
||||
},
|
||||
liveLogCollapsed ? "▸" : "▾",
|
||||
);
|
||||
toggle.addEventListener("click", () => {
|
||||
liveLogCollapsed = !liveLogCollapsed;
|
||||
pre.hidden = liveLogCollapsed;
|
||||
toggle.textContent = liveLogCollapsed ? '▸' : '▾';
|
||||
toggle.setAttribute('aria-expanded', String(!liveLogCollapsed));
|
||||
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log';
|
||||
toggle.textContent = liveLogCollapsed ? "▸" : "▾";
|
||||
toggle.setAttribute("aria-expanded", String(!liveLogCollapsed));
|
||||
toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log";
|
||||
});
|
||||
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
|
||||
const header = el('div', { class: 'rebuild-live-log-header' },
|
||||
toggle, ' ',
|
||||
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
|
||||
const header = el(
|
||||
"div",
|
||||
{ class: "rebuild-live-log-header" },
|
||||
toggle,
|
||||
" ",
|
||||
el("span", { class: "rebuild-live-log-title" }, "live build log — "),
|
||||
// Label the specific node's agent, not the whole group's agent set.
|
||||
el('code', { class: 'rqe-agent' }, liveAgent || ''),
|
||||
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
|
||||
' ', badge, ' ',
|
||||
el('a', {
|
||||
class: 'rebuild-live-log-raw',
|
||||
href: '/api/build-log/' + liveNode.id + '/raw',
|
||||
download: 'build-log-' + liveNode.id + '.txt',
|
||||
}, '↓ raw'),
|
||||
el("code", { class: "rqe-agent" }, liveAgent || ""),
|
||||
" ",
|
||||
el("span", { class: "rqe-kind" }, liveNode.payload.label),
|
||||
" ",
|
||||
badge,
|
||||
" ",
|
||||
el(
|
||||
"a",
|
||||
{
|
||||
class: "rebuild-live-log-raw",
|
||||
href: "/api/build-log/" + liveNode.id + "/raw",
|
||||
download: "build-log-" + liveNode.id + ".txt",
|
||||
},
|
||||
"↓ raw",
|
||||
),
|
||||
);
|
||||
root.append(header, pre);
|
||||
|
||||
// Start polling.
|
||||
fetchAndRenderLiveLog(liveNode.id, pre);
|
||||
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000);
|
||||
liveLogPollTimer = setInterval(
|
||||
() => fetchAndRenderLiveLog(liveNode.id, pre),
|
||||
2000,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
|
||||
function updateRebuildCount() {
|
||||
const pill = $('builds-tab-count-rebuild');
|
||||
const pill = $("builds-tab-count-rebuild");
|
||||
if (!pill) return;
|
||||
// Pending/Running/Finishing = in flight (Finishing = own work done, a
|
||||
// sub-node still running — still counts). Root nodes only: each is one
|
||||
// queue entry.
|
||||
const n = jobqNodes.filter((n) => n.parent == null
|
||||
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length;
|
||||
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
|
||||
else { pill.hidden = true; }
|
||||
const n = jobqNodes.filter(
|
||||
(n) =>
|
||||
n.parent == null &&
|
||||
(n.state === "Pending" ||
|
||||
n.state === "Running" ||
|
||||
n.state === "Finishing"),
|
||||
).length;
|
||||
if (n > 0) {
|
||||
pill.textContent = String(n);
|
||||
pill.hidden = false;
|
||||
} else {
|
||||
pill.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── render-all (cold load + any full re-render) ──────────────────────────────
|
||||
|
|
@ -330,43 +424,63 @@ function renderAll() {
|
|||
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
|
||||
// Deep-link: ?id=N#buildlogs auto-expands the target row.
|
||||
|
||||
const buildList = $('build-list');
|
||||
const buildRefresh = $('build-refresh');
|
||||
const buildList = $("build-list");
|
||||
const buildRefresh = $("build-refresh");
|
||||
let buildLogsLoaded = false;
|
||||
let buildTabs; // set in init
|
||||
|
||||
function fmtTs(unixSecs) {
|
||||
if (!unixSecs) return '';
|
||||
if (!unixSecs) return "";
|
||||
const age = Math.floor(Date.now() / 1000) - unixSecs;
|
||||
return fmtAgeSecs(Math.max(0, age)) + ' ago';
|
||||
return fmtAgeSecs(Math.max(0, age)) + " ago";
|
||||
}
|
||||
|
||||
async function fetchBuild() {
|
||||
if (!buildList) return;
|
||||
buildList.replaceChildren();
|
||||
buildList.append(el('p', { class: 'meta' }, 'loading…'));
|
||||
buildList.append(el("p", { class: "meta" }, "loading…"));
|
||||
try {
|
||||
const resp = await fetch('/api/build-logs?limit=30');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/build-logs?limit=30");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const rows = await resp.json();
|
||||
buildList.replaceChildren();
|
||||
if (!rows || rows.length === 0) {
|
||||
buildList.append(el('p', { class: 'meta' }, '(no build logs yet)'));
|
||||
buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'build-logs-list' });
|
||||
const ul = el("ul", { class: "build-logs-list" });
|
||||
for (const h of rows) {
|
||||
const li = el('li', { class: 'build-logs-item', 'data-log-id': String(h.id) });
|
||||
const li = el("li", {
|
||||
class: "build-logs-item",
|
||||
"data-log-id": String(h.id),
|
||||
});
|
||||
const live = !h.status;
|
||||
const ok = h.status === 'ok';
|
||||
const statusClass = live ? 'hive-pill-sm badge-running' : ok ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
|
||||
const statusLabel = live ? 'live' : ok ? 'ok' : 'fail';
|
||||
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at));
|
||||
const runtime = h.runtime_secs != null
|
||||
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
|
||||
const ok = h.status === "ok";
|
||||
const statusClass = live
|
||||
? "hive-pill-sm badge-running"
|
||||
: ok
|
||||
? "hive-pill-sm badge-ok"
|
||||
: "hive-pill-sm badge-fail";
|
||||
const statusLabel = live ? "live" : ok ? "ok" : "fail";
|
||||
const age = h.finished_at
|
||||
? fmtTs(h.finished_at)
|
||||
: live
|
||||
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…')
|
||||
: el('span', { class: 'build-logs-runtime meta' }, '');
|
||||
? ""
|
||||
: fmtTs(h.started_at);
|
||||
const runtime =
|
||||
h.runtime_secs != null
|
||||
? el(
|
||||
"span",
|
||||
{ class: "build-logs-runtime meta" },
|
||||
fmtDuration(Math.max(0, h.runtime_secs)),
|
||||
)
|
||||
: live
|
||||
? el(
|
||||
"span",
|
||||
{ class: "build-logs-runtime meta build-logs-live-dur" },
|
||||
"…",
|
||||
)
|
||||
: el("span", { class: "build-logs-runtime meta" }, "");
|
||||
|
||||
let durTimer = null;
|
||||
if (live && h.started_at) {
|
||||
|
|
@ -378,78 +492,106 @@ async function fetchBuild() {
|
|||
durTimer = setInterval(updateDur, 1000);
|
||||
}
|
||||
|
||||
const rowBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'build-logs-row-btn',
|
||||
'aria-expanded': 'false',
|
||||
},
|
||||
el('span', { class: statusClass }, statusLabel),
|
||||
el('span', { class: 'build-logs-agent' }, h.agent),
|
||||
const rowBtn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "build-logs-row-btn",
|
||||
"aria-expanded": "false",
|
||||
},
|
||||
el("span", { class: statusClass }, statusLabel),
|
||||
el("span", { class: "build-logs-agent" }, h.agent),
|
||||
runtime,
|
||||
el('span', { class: 'build-logs-kind' }, h.kind),
|
||||
el('span', { class: 'build-logs-age meta' }, age),
|
||||
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline),
|
||||
el("span", { class: "build-logs-kind" }, h.kind),
|
||||
el("span", { class: "build-logs-age meta" }, age),
|
||||
el("span", { class: "build-logs-cmdline meta" }, h.cmdline),
|
||||
);
|
||||
const detail = el('div', { class: 'build-logs-detail' });
|
||||
const detail = el("div", { class: "build-logs-detail" });
|
||||
detail.hidden = true;
|
||||
|
||||
let streamEs = null;
|
||||
rowBtn.addEventListener('click', async () => {
|
||||
const expanded = rowBtn.getAttribute('aria-expanded') === 'true';
|
||||
rowBtn.setAttribute('aria-expanded', String(!expanded));
|
||||
rowBtn.addEventListener("click", async () => {
|
||||
const expanded = rowBtn.getAttribute("aria-expanded") === "true";
|
||||
rowBtn.setAttribute("aria-expanded", String(!expanded));
|
||||
detail.hidden = expanded;
|
||||
if (expanded) {
|
||||
if (streamEs) { streamEs.close(); streamEs = null; }
|
||||
if (streamEs) {
|
||||
streamEs.close();
|
||||
streamEs = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (detail.dataset.loaded) return;
|
||||
detail.replaceChildren();
|
||||
detail.append(
|
||||
el('a', {
|
||||
href: '/api/build-logs/id/' + h.id + '/raw',
|
||||
download: 'build-log-' + h.id + '.txt',
|
||||
class: 'build-logs-dl',
|
||||
}, '↓ download raw'),
|
||||
el(
|
||||
"a",
|
||||
{
|
||||
href: "/api/build-logs/id/" + h.id + "/raw",
|
||||
download: "build-log-" + h.id + ".txt",
|
||||
class: "build-logs-dl",
|
||||
},
|
||||
"↓ download raw",
|
||||
),
|
||||
);
|
||||
if (live) {
|
||||
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
|
||||
const badge = el('span', { class: 'build-logs-live-badge hive-pill-sm badge-running' }, 'live');
|
||||
const pre = el(
|
||||
"pre",
|
||||
{ class: "build-logs-output build-logs-live" },
|
||||
"",
|
||||
);
|
||||
const badge = el(
|
||||
"span",
|
||||
{ class: "build-logs-live-badge hive-pill-sm badge-running" },
|
||||
"live",
|
||||
);
|
||||
detail.append(badge, pre);
|
||||
|
||||
streamEs = openBuildLogStream(h.id, pre, {
|
||||
onDone: (status) => {
|
||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||
if (durTimer) {
|
||||
clearInterval(durTimer);
|
||||
durTimer = null;
|
||||
}
|
||||
if (h.started_at) {
|
||||
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
|
||||
runtime.textContent = fmtDuration(Math.max(0, elapsed));
|
||||
}
|
||||
badge.className = status === 'ok' ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
|
||||
badge.className =
|
||||
status === "ok"
|
||||
? "hive-pill-sm badge-ok"
|
||||
: "hive-pill-sm badge-fail";
|
||||
badge.textContent = status;
|
||||
streamEs = null;
|
||||
detail.dataset.loaded = '1';
|
||||
detail.dataset.loaded = "1";
|
||||
},
|
||||
onError: () => {
|
||||
if (durTimer) { clearInterval(durTimer); durTimer = null; }
|
||||
badge.textContent = 'stream error';
|
||||
badge.className = 'hive-pill-sm badge-fail';
|
||||
if (durTimer) {
|
||||
clearInterval(durTimer);
|
||||
durTimer = null;
|
||||
}
|
||||
badge.textContent = "stream error";
|
||||
badge.className = "hive-pill-sm badge-fail";
|
||||
streamEs = null;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
|
||||
const pre = el("pre", { class: "build-logs-output" }, "fetching…");
|
||||
detail.append(pre);
|
||||
try {
|
||||
const r2 = await fetch('/api/build-logs/id/' + h.id);
|
||||
const r2 = await fetch("/api/build-logs/id/" + h.id);
|
||||
if (!r2.ok) {
|
||||
pre.textContent = 'error ' + r2.status;
|
||||
pre.textContent = "error " + r2.status;
|
||||
} else {
|
||||
const full = await r2.json();
|
||||
const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n');
|
||||
pre.textContent = out || '(empty)';
|
||||
const out = [full.stdout, full.stderr]
|
||||
.filter(Boolean)
|
||||
.join("\n--- stderr ---\n");
|
||||
pre.textContent = out || "(empty)";
|
||||
}
|
||||
detail.dataset.loaded = '1';
|
||||
detail.dataset.loaded = "1";
|
||||
} catch (err) {
|
||||
pre.textContent = 'fetch failed: ' + err;
|
||||
pre.textContent = "fetch failed: " + err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -458,24 +600,24 @@ async function fetchBuild() {
|
|||
}
|
||||
buildList.append(ul);
|
||||
// Deep-link: ?id=N auto-expands the target row.
|
||||
const deepId = new URLSearchParams(location.search).get('id');
|
||||
const deepId = new URLSearchParams(location.search).get("id");
|
||||
if (deepId) {
|
||||
const target = ul.querySelector('[data-log-id="' + deepId + '"]');
|
||||
if (target) {
|
||||
const btn = target.querySelector('.build-logs-row-btn');
|
||||
const btn = target.querySelector(".build-logs-row-btn");
|
||||
if (btn) {
|
||||
btn.click();
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
buildList.replaceChildren();
|
||||
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
|
||||
}
|
||||
}
|
||||
|
||||
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
|
||||
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
|
||||
|
||||
// ─── SSE handlers ─────────────────────────────────────────────────────────────
|
||||
let buildRefreshTimer = null;
|
||||
|
|
@ -488,7 +630,7 @@ const SSE_HANDLERS = {
|
|||
jobqGraphToken += 1;
|
||||
renderRebuildQueue();
|
||||
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
|
||||
if (buildTabs && buildTabs.active() === 'buildlogs') {
|
||||
if (buildTabs && buildTabs.active() === "buildlogs") {
|
||||
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
|
||||
buildRefreshTimer = setTimeout(fetchBuild, 2000);
|
||||
}
|
||||
|
|
@ -506,7 +648,7 @@ const SSE_HANDLERS = {
|
|||
// ─── boot ─────────────────────────────────────────────────────────────────────
|
||||
async function refreshState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (resp.ok) syncFromSnapshot(await resp.json());
|
||||
} catch {
|
||||
// best-effort
|
||||
|
|
@ -521,15 +663,19 @@ async function init() {
|
|||
// fetch needed here, unlike meta inputs below.
|
||||
mountRebuildQueue();
|
||||
|
||||
buildTabs = document.getElementById('builds-tabbar').configure({
|
||||
buildTabs = document.getElementById("builds-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: 'rebuild', label: 'R3BU1LD QU3U3', badgeId: 'builds-tab-count-rebuild' },
|
||||
{ id: 'meta', label: 'M3T4 1NPUTS' },
|
||||
{ id: 'buildlogs', label: 'BUILD L0GS' },
|
||||
{
|
||||
id: "rebuild",
|
||||
label: "R3BU1LD QU3U3",
|
||||
badgeId: "builds-tab-count-rebuild",
|
||||
},
|
||||
{ id: "meta", label: "M3T4 1NPUTS" },
|
||||
{ id: "buildlogs", label: "BUILD L0GS" },
|
||||
],
|
||||
defaultId: 'rebuild',
|
||||
defaultId: "rebuild",
|
||||
onShow: (id) => {
|
||||
if (id === 'buildlogs' && !buildLogsLoaded) {
|
||||
if (id === "buildlogs" && !buildLogsLoaded) {
|
||||
buildLogsLoaded = true;
|
||||
fetchBuild();
|
||||
}
|
||||
|
|
@ -544,12 +690,16 @@ async function init() {
|
|||
// (subscription discipline, part 1 of the dashboard-event-stream-
|
||||
// split issue).
|
||||
const es = openStream(
|
||||
'/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running',
|
||||
"/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
|
||||
);
|
||||
if (es) {
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); } catch { return; }
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const h = SSE_HANDLERS[ev.kind];
|
||||
if (h) h(ev);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,16 +10,17 @@
|
|||
// live-mutation paths call an injected `onCountsChanged` callback the entry
|
||||
// registers once via `initCall`.
|
||||
|
||||
import { $, form, appendLinkified } from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { epochSec, fmtAgo, fmtDuration } from './util.js';
|
||||
import { $, form, appendLinkified } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { epochSec, fmtAgo, fmtDuration } from "./util.js";
|
||||
|
||||
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
||||
// module is safe to call before wiring.
|
||||
let onCountsChanged = () => {};
|
||||
|
||||
export function initCall(opts = {}) {
|
||||
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged;
|
||||
if (typeof opts.onCountsChanged === "function")
|
||||
onCountsChanged = opts.onCountsChanged;
|
||||
}
|
||||
|
||||
// ─── operator inbox — unread agent→operator messages ────────────
|
||||
|
|
@ -31,50 +32,66 @@ export function initCall(opts = {}) {
|
|||
// Count folds into the Y3R C4LL pill + browser-title prefix.
|
||||
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
|
||||
|
||||
export function operatorInboxCount() { return operatorInbox.length; }
|
||||
export function operatorInboxCount() {
|
||||
return operatorInbox.length;
|
||||
}
|
||||
|
||||
export async function refreshOperatorInbox() {
|
||||
try {
|
||||
const r = await fetch('/api/operator-inbox');
|
||||
const r = await fetch("/api/operator-inbox");
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
|
||||
}
|
||||
} catch { /* keep prior list on transient failure */ }
|
||||
} catch {
|
||||
/* keep prior list on transient failure */
|
||||
}
|
||||
renderOperatorInbox();
|
||||
onCountsChanged();
|
||||
}
|
||||
|
||||
function renderOperatorInbox() {
|
||||
const root = $('operator-inbox-section');
|
||||
const root = $("operator-inbox-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!operatorInbox.length) {
|
||||
root.append(el('p', { class: 'meta' }, 'no unread messages'));
|
||||
root.append(el("p", { class: "meta" }, "no unread messages"));
|
||||
return;
|
||||
}
|
||||
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
|
||||
`✓ mark all read (${operatorInbox.length})`);
|
||||
mark.addEventListener('click', markOperatorInboxRead);
|
||||
root.append(el('div', { class: 'inbox-toolbar' }, mark));
|
||||
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
|
||||
const ul = el('ul', { class: 'inbox' });
|
||||
const mark = el(
|
||||
"button",
|
||||
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
|
||||
`✓ mark all read (${operatorInbox.length})`,
|
||||
);
|
||||
mark.addEventListener("click", markOperatorInboxRead);
|
||||
root.append(el("div", { class: "inbox-toolbar" }, mark));
|
||||
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
|
||||
const ul = el("ul", { class: "inbox" });
|
||||
for (const m of operatorInbox) {
|
||||
const body = el('span', { class: 'msg-body' });
|
||||
const body = el("span", { class: "msg-body" });
|
||||
appendLinkified(body, m.body, m.file_refs);
|
||||
ul.append(el('li', {},
|
||||
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
|
||||
el('span', { class: 'msg-from' }, m.from), ' ',
|
||||
el('span', { class: 'msg-sep' }, '→ '),
|
||||
body,
|
||||
));
|
||||
ul.append(
|
||||
el(
|
||||
"li",
|
||||
{},
|
||||
el("span", { class: "msg-ts" }, fmt(m.at)),
|
||||
" ",
|
||||
el("span", { class: "msg-from" }, m.from),
|
||||
" ",
|
||||
el("span", { class: "msg-sep" }, "→ "),
|
||||
body,
|
||||
),
|
||||
);
|
||||
}
|
||||
root.append(ul);
|
||||
}
|
||||
|
||||
async function markOperatorInboxRead() {
|
||||
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
|
||||
catch { /* best-effort; the next refresh reconciles */ }
|
||||
try {
|
||||
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
|
||||
} catch {
|
||||
/* best-effort; the next refresh reconciles */
|
||||
}
|
||||
operatorInbox = [];
|
||||
renderOperatorInbox();
|
||||
onCountsChanged();
|
||||
|
|
@ -86,7 +103,10 @@ async function markOperatorInboxRead() {
|
|||
export function operatorInboxAppendFromEvent(ev) {
|
||||
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
|
||||
operatorInbox.unshift({
|
||||
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
|
||||
id: ev.id,
|
||||
from: ev.from,
|
||||
body: ev.body,
|
||||
at: ev.at,
|
||||
file_refs: ev.file_refs || [],
|
||||
});
|
||||
if (operatorInbox.length > 100) operatorInbox.length = 100;
|
||||
|
|
@ -95,14 +115,16 @@ export function operatorInboxAppendFromEvent(ev) {
|
|||
}
|
||||
|
||||
// ─── approvals — the operator config-change / spawn approval queue ────────
|
||||
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
|
||||
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
|
||||
// Derived approval state — cold-loaded from /api/state, then mutated
|
||||
// live by `approval_added` / `approval_resolved` dashboard events.
|
||||
// `pending` is the open queue (newest-first); `history` is the last
|
||||
// 30 resolved rows.
|
||||
const APPROVAL_HISTORY_LIMIT = 30;
|
||||
const approvalsState = { pending: [], history: [] };
|
||||
export function activeApprovalCount() { return approvalsState.pending.length; }
|
||||
export function activeApprovalCount() {
|
||||
return approvalsState.pending.length;
|
||||
}
|
||||
export function syncApprovalsFromSnapshot(s) {
|
||||
approvalsState.pending = (s.approvals || []).slice();
|
||||
approvalsState.history = (s.approval_history || []).slice();
|
||||
|
|
@ -122,8 +144,8 @@ export function applyApprovalAdded(ev) {
|
|||
// approval was queued just now, so client-now is accurate — and
|
||||
// consistent with how fmtAgo compares everything to client-now.
|
||||
// A later /api/state cold-load swaps in the server value.
|
||||
requested_at: ev.requested_at != null
|
||||
? ev.requested_at : Math.floor(Date.now() / 1000),
|
||||
requested_at:
|
||||
ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
|
||||
};
|
||||
if (existing >= 0) approvalsState.pending[existing] = row;
|
||||
else approvalsState.pending.push(row);
|
||||
|
|
@ -154,7 +176,7 @@ export function applyApprovalResolved(ev) {
|
|||
renderApprovals();
|
||||
}
|
||||
export function renderApprovals() {
|
||||
const root = $('approvals-section');
|
||||
const root = $("approvals-section");
|
||||
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
|
||||
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
|
||||
// events route through here on every page that loads the bundle.
|
||||
|
|
@ -163,42 +185,42 @@ export function renderApprovals() {
|
|||
|
||||
const pending = approvalsState.pending;
|
||||
const history = approvalsState.history;
|
||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
|
||||
const tabs = el('div', { class: 'approval-tabs' });
|
||||
const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
|
||||
const tabs = el("div", { class: "approval-tabs" });
|
||||
const pendingTab = el(
|
||||
'button',
|
||||
"button",
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
|
||||
type: "button",
|
||||
class: "approval-tab" + (active === "pending" ? " active" : ""),
|
||||
},
|
||||
`pending · ${pending.length}`,
|
||||
);
|
||||
const historyTab = el(
|
||||
'button',
|
||||
"button",
|
||||
{
|
||||
type: 'button',
|
||||
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
|
||||
type: "button",
|
||||
class: "approval-tab" + (active === "history" ? " active" : ""),
|
||||
},
|
||||
`history · ${history.length}`,
|
||||
);
|
||||
pendingTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
|
||||
pendingTab.addEventListener("click", () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, "pending");
|
||||
renderApprovals();
|
||||
});
|
||||
historyTab.addEventListener('click', () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
|
||||
historyTab.addEventListener("click", () => {
|
||||
localStorage.setItem(APPROVAL_TAB_KEY, "history");
|
||||
renderApprovals();
|
||||
});
|
||||
tabs.append(pendingTab, historyTab);
|
||||
root.append(tabs);
|
||||
|
||||
if (active === 'history') {
|
||||
if (active === "history") {
|
||||
renderApprovalHistory(root, history);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pending.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'queue empty'));
|
||||
root.append(el("p", { class: "empty" }, "queue empty"));
|
||||
return;
|
||||
}
|
||||
// forge link base — only when the hive-forge container is up.
|
||||
|
|
@ -208,81 +230,143 @@ export function renderApprovals() {
|
|||
// below already gates on forgeBase being truthy.
|
||||
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
|
||||
|
||||
const ul = el('ul', { class: 'approvals' });
|
||||
const ul = el("ul", { class: "approvals" });
|
||||
for (const a of pending) {
|
||||
const isInit = a.kind === 'init_config';
|
||||
const isMergePr = a.kind === 'merge_config_pr';
|
||||
const isUpdateMeta = a.kind === 'update_meta_inputs';
|
||||
const isSchedule = a.kind === 'schedule_prompt';
|
||||
const li = el('li', { class: 'approval-card' });
|
||||
const isInit = a.kind === "init_config";
|
||||
const isMergePr = a.kind === "merge_config_pr";
|
||||
const isUpdateMeta = a.kind === "update_meta_inputs";
|
||||
const isSchedule = a.kind === "schedule_prompt";
|
||||
const li = el("li", { class: "approval-card" });
|
||||
|
||||
// ── identity header ──────────────────────────────────────────
|
||||
const head = el('div', { class: 'approval-head' },
|
||||
el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'),
|
||||
el('span', { class: 'id' }, '#' + a.id),
|
||||
el('span', { class: 'agent' }, a.agent),
|
||||
el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') },
|
||||
isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'),
|
||||
const head = el(
|
||||
"div",
|
||||
{ class: "approval-head" },
|
||||
el(
|
||||
"span",
|
||||
{ class: "glyph" },
|
||||
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕",
|
||||
),
|
||||
el("span", { class: "id" }, "#" + a.id),
|
||||
el("span", { class: "agent" }, a.agent),
|
||||
el(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"kind" +
|
||||
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
|
||||
},
|
||||
isMergePr
|
||||
? "merge-pr"
|
||||
: isUpdateMeta
|
||||
? "meta-update"
|
||||
: isSchedule
|
||||
? "schedule"
|
||||
: isInit
|
||||
? "init"
|
||||
: "spawn",
|
||||
),
|
||||
);
|
||||
if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short));
|
||||
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
|
||||
// When the approval was requested — relative time, right-aligned.
|
||||
// Goes amber once it's been pending an hour so a stale request is
|
||||
// obvious at a glance (see docs/web-ui.md::Approval card).
|
||||
if (a.requested_at != null) {
|
||||
const requestedSec = epochSec(a.requested_at);
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
|
||||
head.append(el('span', {
|
||||
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
|
||||
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
|
||||
'data-requested-at': String(requestedSec),
|
||||
}, 'requested ' + fmtAgo(a.requested_at)));
|
||||
head.append(
|
||||
el(
|
||||
"span",
|
||||
{
|
||||
class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
|
||||
title: "requested " + new Date(a.requested_at).toLocaleString(),
|
||||
"data-requested-at": String(requestedSec),
|
||||
},
|
||||
"requested " + fmtAgo(a.requested_at),
|
||||
),
|
||||
);
|
||||
}
|
||||
li.append(head);
|
||||
|
||||
// ── what-changed body ────────────────────────────────────────
|
||||
const body = el('div', { class: 'approval-body' });
|
||||
const body = el("div", { class: "approval-body" });
|
||||
if (a.description) {
|
||||
body.append(el('div', { class: 'approval-description' }, a.description));
|
||||
body.append(el("div", { class: "approval-description" }, a.description));
|
||||
}
|
||||
if (isMergePr) {
|
||||
// PR-based config deploy: link to the reviewed PR on the forge.
|
||||
// The config diff lives on the forge PR itself.
|
||||
const drill = el('div', { class: 'drill-ins' });
|
||||
const drill = el("div", { class: "drill-ins" });
|
||||
if (forgeBase && a.pr_number != null) {
|
||||
drill.append(el('a', {
|
||||
class: 'panel-trigger', target: '_blank', rel: 'noopener',
|
||||
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
|
||||
title: 'review this config PR on the hive forge',
|
||||
}, '↳ review PR on forge ↗'));
|
||||
drill.append(
|
||||
el(
|
||||
"a",
|
||||
{
|
||||
class: "panel-trigger",
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
|
||||
title: "review this config PR on the hive forge",
|
||||
},
|
||||
"↳ review PR on forge ↗",
|
||||
),
|
||||
);
|
||||
}
|
||||
body.append(drill);
|
||||
} else if (isUpdateMeta) {
|
||||
let inputs;
|
||||
try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; }
|
||||
body.append(el('span', { class: 'meta' },
|
||||
inputs.length
|
||||
? 'bump flake inputs: ' + inputs.join(', ')
|
||||
: 'bump all flake inputs'));
|
||||
try {
|
||||
inputs = JSON.parse(a.commit_ref || "[]");
|
||||
} catch (_) {
|
||||
inputs = [];
|
||||
}
|
||||
body.append(
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta" },
|
||||
inputs.length
|
||||
? "bump flake inputs: " + inputs.join(", ")
|
||||
: "bump all flake inputs",
|
||||
),
|
||||
);
|
||||
} else if (isSchedule) {
|
||||
let payload;
|
||||
try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; }
|
||||
const targets = (payload.targets || []).join(', ');
|
||||
try {
|
||||
payload = JSON.parse(a.commit_ref || "{}");
|
||||
} catch (_) {
|
||||
payload = {};
|
||||
}
|
||||
const targets = (payload.targets || []).join(", ");
|
||||
const firstFire = payload.first_fire_at_unix
|
||||
? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
|
||||
: '?';
|
||||
: "?";
|
||||
const cadence = payload.interval_seconds
|
||||
? ' · ↻ every ' + fmtDuration(payload.interval_seconds)
|
||||
: ' · one-shot';
|
||||
body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence));
|
||||
? " · ↻ every " + fmtDuration(payload.interval_seconds)
|
||||
: " · one-shot";
|
||||
body.append(
|
||||
el(
|
||||
"div",
|
||||
{ class: "meta" },
|
||||
"→ " + targets + " · first: " + firstFire + cadence,
|
||||
),
|
||||
);
|
||||
if (payload.body) {
|
||||
const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body;
|
||||
body.append(el('div', { class: 'approval-description' }, excerpt));
|
||||
const excerpt =
|
||||
payload.body.length > 80
|
||||
? payload.body.slice(0, 80) + "…"
|
||||
: payload.body;
|
||||
body.append(el("div", { class: "approval-description" }, excerpt));
|
||||
}
|
||||
} else {
|
||||
body.append(el('span', { class: 'meta' },
|
||||
isInit
|
||||
? 'scaffold proposed config repo — submitting agent customises agent.nix before spawn'
|
||||
: 'new sub-agent — container will be created on approve'));
|
||||
body.append(
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta" },
|
||||
isInit
|
||||
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
|
||||
: "new sub-agent — container will be created on approve",
|
||||
),
|
||||
);
|
||||
}
|
||||
li.append(body);
|
||||
|
||||
|
|
@ -291,16 +375,32 @@ export function renderApprovals() {
|
|||
// handler stashes it into a hidden `note` input that rides along
|
||||
// on the POST and is surfaced to the submitting agent via
|
||||
// HelperEvent::ApprovalResolved { note }.
|
||||
const denyForm = el('form', {
|
||||
method: 'POST', action: '/api/deny/' + a.id,
|
||||
class: 'inline', 'data-async': '', 'data-no-refresh': '',
|
||||
'data-prompt': 'reason for denying (optional, sent to submitter):',
|
||||
const denyForm = el("form", {
|
||||
method: "POST",
|
||||
action: "/api/deny/" + a.id,
|
||||
class: "inline",
|
||||
"data-async": "",
|
||||
"data-no-refresh": "",
|
||||
"data-prompt": "reason for denying (optional, sent to submitter):",
|
||||
});
|
||||
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
|
||||
li.append(el('div', { class: 'approval-actions' },
|
||||
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
|
||||
denyForm,
|
||||
));
|
||||
denyForm.append(
|
||||
el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
|
||||
);
|
||||
li.append(
|
||||
el(
|
||||
"div",
|
||||
{ class: "approval-actions" },
|
||||
form(
|
||||
"/api/approve/" + a.id,
|
||||
"btn-approve",
|
||||
"◆ APPR0VE",
|
||||
null,
|
||||
{},
|
||||
{ noRefresh: true },
|
||||
),
|
||||
denyForm,
|
||||
),
|
||||
);
|
||||
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
@ -309,31 +409,52 @@ export function renderApprovals() {
|
|||
|
||||
function renderApprovalHistory(root, history) {
|
||||
if (!history.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
|
||||
root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'approvals approvals-history' });
|
||||
const ul = el("ul", { class: "approvals approvals-history" });
|
||||
for (const a of history) {
|
||||
const li = el('li');
|
||||
const row = el('div', { class: 'row' });
|
||||
const glyph = a.status === 'approved' ? '✓'
|
||||
: a.status === 'denied' ? '✗'
|
||||
: a.status === 'cancelled' ? '⊘'
|
||||
: '⚠';
|
||||
const li = el("li");
|
||||
const row = el("div", { class: "row" });
|
||||
const glyph =
|
||||
a.status === "approved"
|
||||
? "✓"
|
||||
: a.status === "denied"
|
||||
? "✗"
|
||||
: a.status === "cancelled"
|
||||
? "⊘"
|
||||
: "⚠";
|
||||
row.append(
|
||||
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
|
||||
el('span', { class: 'id' }, '#' + a.id), ' ',
|
||||
el('span', { class: 'agent' }, a.agent), ' ',
|
||||
el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
|
||||
el("span", { class: "glyph glyph-" + a.status }, glyph),
|
||||
" ",
|
||||
el("span", { class: "id" }, "#" + a.id),
|
||||
" ",
|
||||
el("span", { class: "agent" }, a.agent),
|
||||
" ",
|
||||
el(
|
||||
"span",
|
||||
{ class: "kind" },
|
||||
a.kind === "merge_config_pr"
|
||||
? "merge-pr"
|
||||
: a.kind === "update_meta_inputs"
|
||||
? "meta-update"
|
||||
: a.kind === "schedule_prompt"
|
||||
? "schedule"
|
||||
: a.kind === "init_config"
|
||||
? "init"
|
||||
: "spawn",
|
||||
),
|
||||
" ",
|
||||
);
|
||||
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
|
||||
if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
|
||||
row.append(
|
||||
el('span', { class: 'status status-' + a.status }, a.status), ' ',
|
||||
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
|
||||
el("span", { class: "status status-" + a.status }, a.status),
|
||||
" ",
|
||||
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
|
||||
);
|
||||
li.append(row);
|
||||
if (a.note) {
|
||||
li.append(el('div', { class: 'history-note' }, a.note));
|
||||
li.append(el("div", { class: "history-note" }, a.note));
|
||||
}
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
Element-level rules shared across all three pages (index, flow,
|
||||
logs). These must not reference page-specific chrome classes. */
|
||||
|
||||
h1, h2 {
|
||||
h1,
|
||||
h2 {
|
||||
color: var(--purple);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
|
|
@ -26,9 +27,17 @@ h1, h2 {
|
|||
white-space: nowrap;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
ul { list-style: none; padding-left: 0; }
|
||||
li { padding: 0.5em 0; }
|
||||
.glyph { color: var(--purple); margin-right: 0.5em; }
|
||||
ul {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
li {
|
||||
padding: 0.5em 0;
|
||||
}
|
||||
.glyph {
|
||||
color: var(--purple);
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
a {
|
||||
color: var(--cyan);
|
||||
text-decoration: none;
|
||||
|
|
@ -51,10 +60,25 @@ code {
|
|||
/* ─── shared inline labels ──────────────────────────────────────────
|
||||
.meta / .id / .agent / .empty appear in message rows, build-log
|
||||
toolbars, and side-panel content rendered by common.js. */
|
||||
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
|
||||
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
|
||||
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.id {
|
||||
color: var(--pink);
|
||||
font-weight: bold;
|
||||
margin-right: 0.4em;
|
||||
}
|
||||
.agent {
|
||||
color: var(--amber);
|
||||
font-weight: bold;
|
||||
margin-right: 0.6em;
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ─── status badges ─────────────────────────────────────────────────
|
||||
Semantic colour variants only — shape comes from `hive-pill-sm`
|
||||
|
|
@ -63,48 +87,69 @@ code {
|
|||
(swarm.js/core.js/builds.js). Used on container rows and build-log
|
||||
rows. */
|
||||
.badge-warn {
|
||||
color: var(--amber); border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
|
||||
}
|
||||
.badge-rate-limited {
|
||||
color: var(--red); border-color: var(--red);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
|
||||
}
|
||||
.badge-muted {
|
||||
color: var(--muted); border-color: var(--purple-dim);
|
||||
color: var(--muted);
|
||||
border-color: var(--purple-dim);
|
||||
background: color-mix(in srgb, var(--muted) 8%, transparent);
|
||||
}
|
||||
.badge-loose-ends {
|
||||
color: var(--purple); border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent);
|
||||
}
|
||||
/* Paused agent: turn loop parked, container still up. Clickable (btn-inline):
|
||||
clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */
|
||||
.badge-paused {
|
||||
color: var(--yellow); border-color: var(--yellow);
|
||||
color: var(--yellow);
|
||||
border-color: var(--yellow);
|
||||
background: color-mix(in srgb, var(--yellow) 10%, transparent);
|
||||
}
|
||||
/* Active Claude model badge on dashboard container rows. */
|
||||
.badge-model {
|
||||
color: var(--blue); border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
border-color: var(--blue);
|
||||
opacity: 0.8;
|
||||
}
|
||||
/* Context-window usage badges on dashboard container rows. */
|
||||
.badge-ctx-ok {
|
||||
color: var(--green); border-color: var(--green);
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.badge-ctx-caution {
|
||||
color: var(--amber); border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
|
||||
}
|
||||
.badge-ctx-warn {
|
||||
color: var(--red); border-color: var(--red);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
|
||||
}
|
||||
.badge-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); border-color: var(--green); }
|
||||
.badge-fail { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); border-color: var(--red); }
|
||||
.badge-running { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); border-color: var(--amber); }
|
||||
.badge-ok {
|
||||
background: color-mix(in srgb, var(--green) 12%, transparent);
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.badge-fail {
|
||||
background: color-mix(in srgb, var(--red) 12%, transparent);
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.badge-running {
|
||||
background: color-mix(in srgb, var(--amber) 12%, transparent);
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
/* ─── buttons ───────────────────────────────────────────────────────
|
||||
.btn base + semantic colour/size modifiers. Logs page uses
|
||||
|
|
@ -145,26 +190,89 @@ code {
|
|||
text-shadow: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-approve { color: var(--green); border-color: var(--green); }
|
||||
.btn-deny { color: var(--red); border-color: var(--red); }
|
||||
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-approve {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
.btn-deny {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
}
|
||||
.btn-destroy {
|
||||
color: var(--red);
|
||||
border-color: var(--red);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-rebuild {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-restart {
|
||||
color: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-stop {
|
||||
color: var(--pink);
|
||||
border-color: var(--pink);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-start {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
/* Same yellow as .badge-paused (the paused-state pill) — pause enters
|
||||
that state, so the trigger and the resulting badge read as one
|
||||
colour, not two unrelated ones. Resume reads as green like
|
||||
.btn-start: both are "go" actions, back to the turn loop running. */
|
||||
.btn-pause { color: var(--yellow); border-color: var(--yellow); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-resume { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-pause {
|
||||
color: var(--yellow);
|
||||
border-color: var(--yellow);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-resume {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
/* M0V3 affordance (selection bar) — mauve reads as "structural
|
||||
change" rather than the destructive red / amber chrome of
|
||||
destroy / rebuild. See docs/web-ui.md::Selection bar. */
|
||||
.btn-move { color: var(--purple); border-color: var(--purple); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
|
||||
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
||||
.btn-fire-now { color: var(--purple); border-color: var(--purple); }
|
||||
.btn-move {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
.btn-talk {
|
||||
color: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.btn-spawn {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.btn-fire-now {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
}
|
||||
/* Post-fire flash: report renders directly on the button for ~1.5s
|
||||
so the operator sees ok/failed/missing/consumed counts inline
|
||||
without a modal. Green when at least one ok; muted otherwise. */
|
||||
|
|
@ -177,13 +285,24 @@ code {
|
|||
/* Inline edit button on each schedule row. Yellow reads as a
|
||||
parallel destructive-adjacent action (edit changes state, but
|
||||
isn't deletion). */
|
||||
.btn-edit-schedule { color: var(--yellow); border-color: var(--yellow); }
|
||||
.btn-edit-schedule {
|
||||
color: var(--yellow);
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
|
||||
/* ─── file-preview / diff panel (common.js Panel) ───────────────────
|
||||
Side-panel content rendered by openFilePanel / buildTabbedPreview
|
||||
in common.js; all three pages load the Panel singleton. */
|
||||
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
|
||||
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
|
||||
.diff-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
}
|
||||
.diff-base-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.diff-base-tab {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -193,14 +312,18 @@ code {
|
|||
padding: 0.2em 0.7em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.diff-base-tab:hover { color: var(--fg); }
|
||||
.diff-base-tab:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.diff-base-tab.active {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
background: color-mix(in srgb, var(--purple) 8%, transparent);
|
||||
}
|
||||
/* Image / tabbed file preview */
|
||||
.preview-host { margin-top: 0.5em; }
|
||||
.preview-host {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.img-preview {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
|
|
@ -209,7 +332,8 @@ code {
|
|||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
/* checkerboard so transparent regions of the image read clearly */
|
||||
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%) 50% / 18px 18px;
|
||||
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
|
||||
50% / 18px 18px;
|
||||
}
|
||||
|
||||
/* Path linkification — agents drop pointer strings into messages;
|
||||
|
|
@ -219,7 +343,9 @@ code {
|
|||
text-decoration: underline dotted;
|
||||
cursor: pointer;
|
||||
}
|
||||
.path-link:hover { color: var(--amber); }
|
||||
.path-link:hover {
|
||||
color: var(--amber);
|
||||
}
|
||||
/* File-preview body — rendered inside the side panel. */
|
||||
.path-preview-body {
|
||||
background: var(--bg);
|
||||
|
|
@ -255,11 +381,24 @@ code {
|
|||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
.inbox li:last-child { border-bottom: 0; }
|
||||
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
|
||||
.inbox .msg-from { color: var(--amber); }
|
||||
.inbox .msg-sep { color: var(--muted); }
|
||||
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||
.inbox li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.inbox .msg-ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.inbox .msg-from {
|
||||
color: var(--amber);
|
||||
}
|
||||
.inbox .msg-sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
.inbox .msg-body {
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
|
||||
msgrow / msg-* rules below power both the dashboard CALL tab and
|
||||
the /flow.html full-page terminal. */
|
||||
|
|
@ -275,8 +414,12 @@ code {
|
|||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
|
||||
.live .msgrow.delivered .msg-arrow { color: var(--green); }
|
||||
.live .msgrow.sent .msg-arrow {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.live .msgrow.delivered .msg-arrow {
|
||||
color: var(--green);
|
||||
}
|
||||
/* Reply-thread rendering: indented border-left + muted reply tag. */
|
||||
.live .msgrow.msg-reply {
|
||||
padding-left: 1.2em;
|
||||
|
|
@ -294,18 +437,41 @@ code {
|
|||
text-shadow: none;
|
||||
font-weight: normal;
|
||||
}
|
||||
.msg-reply-tag a:hover { color: var(--fg); }
|
||||
.msg-reply-tag a:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
@keyframes msg-highlight-fade {
|
||||
from { background: color-mix(in srgb, var(--purple) 18%, transparent); }
|
||||
to { background: transparent; }
|
||||
from {
|
||||
background: color-mix(in srgb, var(--purple) 18%, transparent);
|
||||
}
|
||||
to {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
.msg-highlight {
|
||||
animation: msg-highlight-fade 1.5s ease-out forwards;
|
||||
}
|
||||
.msg-ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.msg-arrow {
|
||||
font-weight: bold;
|
||||
}
|
||||
.msg-from {
|
||||
color: var(--amber);
|
||||
}
|
||||
.msg-sep {
|
||||
color: var(--muted);
|
||||
}
|
||||
.msg-to {
|
||||
color: var(--pink);
|
||||
}
|
||||
.msg-body {
|
||||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
|
||||
.msg-ts { color: var(--muted); font-size: 0.85em; }
|
||||
.msg-arrow { font-weight: bold; }
|
||||
.msg-from { color: var(--amber); }
|
||||
.msg-sep { color: var(--muted); }
|
||||
.msg-to { color: var(--pink); }
|
||||
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* ─── operator compose box ──────────────────────────────────────────
|
||||
Sits inside `.terminal-wrap` on both /flow.html and the dashboard
|
||||
|
|
@ -339,8 +505,12 @@ code {
|
|||
max-height: 8em;
|
||||
padding: 0;
|
||||
}
|
||||
.op-compose-input:focus { outline: none; }
|
||||
.op-compose-input::placeholder { color: var(--muted); }
|
||||
.op-compose-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
.op-compose-input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
.op-compose-suggest {
|
||||
position: absolute;
|
||||
left: 3em;
|
||||
|
|
@ -387,15 +557,29 @@ code {
|
|||
body.side-panel-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
body.side-panel-resizing * { cursor: ew-resize !important; }
|
||||
hive-side-panel .md { color: var(--fg); line-height: 1.5; }
|
||||
hive-side-panel .md > :first-child { margin-top: 0; }
|
||||
hive-side-panel .md > :last-child { margin-bottom: 0; }
|
||||
hive-side-panel .md p { margin: 0.5em 0; }
|
||||
body.side-panel-resizing * {
|
||||
cursor: ew-resize !important;
|
||||
}
|
||||
hive-side-panel .md {
|
||||
color: var(--fg);
|
||||
line-height: 1.5;
|
||||
}
|
||||
hive-side-panel .md > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
hive-side-panel .md > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
hive-side-panel .md p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
hive-side-panel .md h1,
|
||||
hive-side-panel .md h2,
|
||||
hive-side-panel .md h3,
|
||||
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
|
||||
hive-side-panel .md h4 {
|
||||
color: var(--purple);
|
||||
margin: 0.9em 0 0.4em;
|
||||
}
|
||||
hive-side-panel .md code {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -410,17 +594,29 @@ hive-side-panel .md pre {
|
|||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
hive-side-panel .md pre code { background: none; border: none; padding: 0; }
|
||||
hive-side-panel .md a { color: var(--cyan); }
|
||||
hive-side-panel .md pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
hive-side-panel .md a {
|
||||
color: var(--cyan);
|
||||
}
|
||||
hive-side-panel .md ul,
|
||||
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
|
||||
hive-side-panel .md ol {
|
||||
margin: 0.4em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
hive-side-panel .md blockquote {
|
||||
border-left: 3px solid var(--purple-dim);
|
||||
padding-left: 0.8em;
|
||||
margin: 0.4em 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
|
||||
hive-side-panel .md table {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
hive-side-panel .md th,
|
||||
hive-side-panel .md td {
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -435,7 +631,9 @@ hive-side-panel .md td {
|
|||
with no negative-margin breakout hacks. Only matches on pages that opt
|
||||
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
|
||||
ST4TS are full-bleed with their own `.<page>-main` padding. */
|
||||
.page-content { padding: 0 1.5em; }
|
||||
.page-content {
|
||||
padding: 0 1.5em;
|
||||
}
|
||||
|
||||
/* ─── sticky top region ───────────────────────────────────────────────
|
||||
One sticky container per page holding the warning banner above the
|
||||
|
|
@ -458,7 +656,9 @@ hive-side-panel .md td {
|
|||
the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the
|
||||
body level (their 1.5em gutter lives on an inner `.page-content`
|
||||
wrapper), so the banner is full-width for free. */
|
||||
.server-warnings[hidden] { display: none; }
|
||||
.server-warnings[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.server-warn {
|
||||
text-align: center;
|
||||
padding: 0.4em 1em;
|
||||
|
|
|
|||
|
|
@ -3,35 +3,55 @@
|
|||
// the OS-notification module, and the path-link / file-preview
|
||||
// infrastructure for the side panel.
|
||||
|
||||
import { linkify as termLinkify } from '@hive/shared/terminal.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
|
||||
import '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import
|
||||
import DOMPurify from 'dompurify';
|
||||
import { linkify as termLinkify } from "@hive/shared/terminal.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import "@hive/shared/side-panel.js"; // registers <hive-side-panel> — side-effect import
|
||||
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
||||
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
|
||||
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
|
||||
export const fmtAgeSecs = (s) =>
|
||||
s < 60
|
||||
? `${s}s`
|
||||
: s < 3600
|
||||
? `${Math.floor(s / 60)}m`
|
||||
: s < 86400
|
||||
? `${Math.floor(s / 3600)}h`
|
||||
: `${Math.floor(s / 86400)}d`;
|
||||
|
||||
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
|
||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
||||
);
|
||||
export const esc = (s) =>
|
||||
String(s).replace(
|
||||
/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c],
|
||||
);
|
||||
|
||||
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
|
||||
const f = el('form', {
|
||||
method: 'POST', action, class: 'inline', 'data-async': '',
|
||||
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
|
||||
export const form = (
|
||||
action,
|
||||
btnClass,
|
||||
btnLabel,
|
||||
confirmMsg,
|
||||
extra = {},
|
||||
opts = {},
|
||||
) => {
|
||||
const f = el("form", {
|
||||
method: "POST",
|
||||
action,
|
||||
class: "inline",
|
||||
"data-async": "",
|
||||
...(confirmMsg ? { "data-confirm": confirmMsg } : {}),
|
||||
// Endpoints whose mutation fires a DashboardEvent (and whose
|
||||
// derived store applies it live) opt out of the post-submit
|
||||
// /api/state refetch. See the async-form handler.
|
||||
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
|
||||
...(opts.noRefresh ? { "data-no-refresh": "" } : {}),
|
||||
});
|
||||
for (const [name, value] of Object.entries(extra)) {
|
||||
f.append(el('input', { type: 'hidden', name, value }));
|
||||
f.append(el("input", { type: "hidden", name, value }));
|
||||
}
|
||||
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
|
||||
f.append(
|
||||
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
|
||||
);
|
||||
return f;
|
||||
};
|
||||
|
||||
|
|
@ -55,8 +75,8 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
|
|||
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
|
||||
// drops the subscription (the worker closes the upstream when the last
|
||||
// subscriber leaves).
|
||||
const SHARED_WORKER_PATH = '/static/stream-worker.js';
|
||||
const SHARED_WORKER_NAME = 'hyperhive-stream';
|
||||
const SHARED_WORKER_PATH = "/static/stream-worker.js";
|
||||
const SHARED_WORKER_NAME = "hyperhive-stream";
|
||||
|
||||
// One SharedWorker port per page, reused by all openStream calls on
|
||||
// that page. Invalidated on `pagehide` so a bfcache restore picks up
|
||||
|
|
@ -64,13 +84,16 @@ const SHARED_WORKER_NAME = 'hyperhive-stream';
|
|||
// closed while this page was frozen.
|
||||
let _sharedPort = null;
|
||||
function makeSharedPort() {
|
||||
if (typeof SharedWorker === 'undefined') return null;
|
||||
if (typeof SharedWorker === "undefined") return null;
|
||||
try {
|
||||
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
|
||||
sw.port.start();
|
||||
return sw.port;
|
||||
} catch (err) {
|
||||
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
|
||||
console.warn(
|
||||
"SharedWorker unavailable, falling back to direct EventSource:",
|
||||
err,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -87,19 +110,27 @@ function getSharedPort() {
|
|||
const WORKER_DEAD_THRESHOLD_MS = 90_000;
|
||||
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
|
||||
let _lastWorkerActivityAt = 0;
|
||||
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
|
||||
function noteWorkerActivity() {
|
||||
_lastWorkerActivityAt = Date.now();
|
||||
}
|
||||
let _watchdogTimer = null;
|
||||
function startWorkerWatchdog() {
|
||||
if (_watchdogTimer != null) return;
|
||||
_watchdogTimer = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState !== "visible"
|
||||
)
|
||||
return;
|
||||
if (!_activeSubs.size) return;
|
||||
if (!_sharedPort) return;
|
||||
const sinceLast = Date.now() - _lastWorkerActivityAt;
|
||||
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
|
||||
console.warn(
|
||||
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000)
|
||||
+ 's, presumed dead — re-subscribing on a fresh port');
|
||||
"hyperhive-stream worker silent for " +
|
||||
Math.round(sinceLast / 1000) +
|
||||
"s, presumed dead — re-subscribing on a fresh port",
|
||||
);
|
||||
rebindOnFreshPort();
|
||||
}, WORKER_WATCHDOG_INTERVAL_MS);
|
||||
}
|
||||
|
|
@ -109,7 +140,9 @@ function rebindOnFreshPort() {
|
|||
// is the source of truth for what we need to re-attach.
|
||||
if (_sharedPort) {
|
||||
for (const sub of _activeSubs.values()) {
|
||||
try { _sharedPort.removeEventListener('message', sub.route); } catch {}
|
||||
try {
|
||||
_sharedPort.removeEventListener("message", sub.route);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
_sharedPort = null;
|
||||
|
|
@ -117,8 +150,10 @@ function rebindOnFreshPort() {
|
|||
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
|
||||
for (const [url, sub] of _activeSubs) {
|
||||
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
|
||||
port.addEventListener('message', sub.route);
|
||||
try { port.postMessage({ kind: 'subscribe', url }); } catch {}
|
||||
port.addEventListener("message", sub.route);
|
||||
try {
|
||||
port.postMessage({ kind: "subscribe", url });
|
||||
} catch {}
|
||||
}
|
||||
// Reset the activity clock so the watchdog gives the fresh worker
|
||||
// a full window to settle before re-triggering.
|
||||
|
|
@ -143,32 +178,41 @@ function bindLifecycleOnce() {
|
|||
if (_lifecycleBound) return;
|
||||
_lifecycleBound = true;
|
||||
startWorkerWatchdog();
|
||||
window.addEventListener('pagehide', () => {
|
||||
window.addEventListener("pagehide", () => {
|
||||
if (!_sharedPort) return;
|
||||
for (const url of _activeSubs.keys()) {
|
||||
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead — worker side already cleaned up */ }
|
||||
try {
|
||||
_sharedPort.postMessage({ kind: "unsubscribe", url });
|
||||
} catch {
|
||||
/* port dead — worker side already cleaned up */
|
||||
}
|
||||
}
|
||||
// Drop port routes too; the bfcache-restore path will re-add
|
||||
// them on a fresh port. Leaving stale routes on a dead port
|
||||
// would just keep a closure alive without cost, but cleaning
|
||||
// up keeps the registry shape honest.
|
||||
for (const sub of _activeSubs.values()) {
|
||||
try { _sharedPort.removeEventListener('message', sub.route); }
|
||||
catch { /* same */ }
|
||||
try {
|
||||
_sharedPort.removeEventListener("message", sub.route);
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
_sharedPort = null;
|
||||
});
|
||||
window.addEventListener('pageshow', (ev) => {
|
||||
window.addEventListener("pageshow", (ev) => {
|
||||
if (!ev.persisted) return; // cold load — openStream just bound listeners
|
||||
if (!_activeSubs.size) return;
|
||||
const port = getSharedPort();
|
||||
if (!port) return; // SharedWorker really gone; fallback already in place
|
||||
for (const [url, sub] of _activeSubs) {
|
||||
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
|
||||
port.addEventListener('message', sub.route);
|
||||
try { port.postMessage({ kind: 'subscribe', url }); }
|
||||
catch { /* port dead immediately — skip */ }
|
||||
port.addEventListener("message", sub.route);
|
||||
try {
|
||||
port.postMessage({ kind: "subscribe", url });
|
||||
} catch {
|
||||
/* port dead immediately — skip */
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -191,10 +235,16 @@ export function openStream(url) {
|
|||
close() {
|
||||
const p = _sharedPort;
|
||||
if (p) {
|
||||
try { p.postMessage({ kind: 'unsubscribe', url }); }
|
||||
catch { /* port dead */ }
|
||||
try { p.removeEventListener('message', route); }
|
||||
catch { /* same */ }
|
||||
try {
|
||||
p.postMessage({ kind: "unsubscribe", url });
|
||||
} catch {
|
||||
/* port dead */
|
||||
}
|
||||
try {
|
||||
p.removeEventListener("message", route);
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
_activeSubs.delete(url);
|
||||
},
|
||||
|
|
@ -204,29 +254,38 @@ export function openStream(url) {
|
|||
// before the URL filter, since heartbeat pings carry no URL.
|
||||
noteWorkerActivity();
|
||||
const m = e.data;
|
||||
if (!m || m.kind === 'ping') return;
|
||||
if (!m || m.kind === "ping") return;
|
||||
if (m.url !== url) return;
|
||||
if (m.kind === 'open') {
|
||||
if (m.kind === "open") {
|
||||
target.readyState = 1; // OPEN
|
||||
if (target.onopen) {
|
||||
try { target.onopen({ target }); }
|
||||
catch (err) { console.error('openStream onopen threw', err); }
|
||||
try {
|
||||
target.onopen({ target });
|
||||
} catch (err) {
|
||||
console.error("openStream onopen threw", err);
|
||||
}
|
||||
}
|
||||
} else if (m.kind === 'message') {
|
||||
} else if (m.kind === "message") {
|
||||
if (target.onmessage) {
|
||||
try { target.onmessage({ data: m.data, target }); }
|
||||
catch (err) { console.error('openStream onmessage threw', err); }
|
||||
try {
|
||||
target.onmessage({ data: m.data, target });
|
||||
} catch (err) {
|
||||
console.error("openStream onmessage threw", err);
|
||||
}
|
||||
}
|
||||
} else if (m.kind === 'error') {
|
||||
} else if (m.kind === "error") {
|
||||
if (target.onerror) {
|
||||
try { target.onerror({ target }); }
|
||||
catch (err) { console.error('openStream onerror threw', err); }
|
||||
try {
|
||||
target.onerror({ target });
|
||||
} catch (err) {
|
||||
console.error("openStream onerror threw", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
_activeSubs.set(url, { target, route });
|
||||
port.addEventListener('message', route);
|
||||
port.postMessage({ kind: 'subscribe', url });
|
||||
port.addEventListener("message", route);
|
||||
port.postMessage({ kind: "subscribe", url });
|
||||
// Seed the activity clock so the watchdog has a baseline (would
|
||||
// otherwise compare against 0 and trigger immediately).
|
||||
noteWorkerActivity();
|
||||
|
|
@ -244,33 +303,41 @@ export function openStream(url) {
|
|||
// (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling.
|
||||
export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
||||
let atBottom = true;
|
||||
pre.addEventListener('scroll', () => {
|
||||
pre.addEventListener("scroll", () => {
|
||||
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
|
||||
});
|
||||
let stderrSeen = false;
|
||||
const es = new EventSource('/api/build-logs/id/' + id + '/stream');
|
||||
const es = new EventSource("/api/build-logs/id/" + id + "/stream");
|
||||
es.onmessage = (e) => {
|
||||
let frame;
|
||||
try { frame = JSON.parse(e.data); } catch { return; }
|
||||
try {
|
||||
frame = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame.stdout_append) {
|
||||
pre.textContent += frame.stdout_append;
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
if (frame.stderr_append) {
|
||||
if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; }
|
||||
if (!stderrSeen) {
|
||||
pre.textContent += "\n--- stderr ---\n";
|
||||
stderrSeen = true;
|
||||
}
|
||||
pre.textContent += frame.stderr_append;
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
if (frame.done) {
|
||||
es.close();
|
||||
if (onDone) onDone(frame.status || 'done');
|
||||
if (onDone) onDone(frame.status || "done");
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// CONNECTING = the browser is auto-reconnecting; the stream replays from
|
||||
// the start, so clear the <pre> to avoid duplicated output and wait.
|
||||
if (es.readyState === EventSource.CONNECTING) {
|
||||
pre.textContent = ''; stderrSeen = false;
|
||||
pre.textContent = "";
|
||||
stderrSeen = false;
|
||||
return;
|
||||
}
|
||||
es.close();
|
||||
|
|
@ -295,7 +362,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
|
|||
// slotted content via a plain `hive-side-panel .md …` tag-name selector
|
||||
// (no compatibility class needed — the element's own tag name already
|
||||
// uniquely identifies it in the light DOM).
|
||||
export const sidePanel = document.createElement('hive-side-panel');
|
||||
export const sidePanel = document.createElement("hive-side-panel");
|
||||
document.body.append(sidePanel);
|
||||
|
||||
// ─── path linkification ─────────────────────────────────────────────────
|
||||
|
|
@ -309,45 +376,54 @@ document.body.append(sidePanel);
|
|||
// to translate it). Prefer `/agents/<name>/state/...` in agent
|
||||
// outputs and the link will resolve.
|
||||
async function fetchStateFile(path) {
|
||||
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
|
||||
const resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
|
||||
const text = await resp.text();
|
||||
if (!resp.ok) throw new Error(text || ('http ' + resp.status));
|
||||
if (!resp.ok) throw new Error(text || "http " + resp.status);
|
||||
return text;
|
||||
}
|
||||
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
|
||||
// `renderRendered()` produces the rendered-tab node fresh on each
|
||||
// switch; `plainText` backs the raw tab; `plainLabel` names it.
|
||||
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
|
||||
const tabs = el('div', { class: 'diff-base-tabs' });
|
||||
const host = el('div', { class: 'preview-host' });
|
||||
const tabs = el("div", { class: "diff-base-tabs" });
|
||||
const host = el("div", { class: "preview-host" });
|
||||
function show(mode) {
|
||||
for (const b of tabs.children) {
|
||||
b.classList.toggle('active', b.dataset.mode === mode);
|
||||
b.classList.toggle("active", b.dataset.mode === mode);
|
||||
}
|
||||
host.replaceChildren(mode === 'plain'
|
||||
? el('pre', { class: 'path-preview-body' }, plainText)
|
||||
: renderRendered());
|
||||
host.replaceChildren(
|
||||
mode === "plain"
|
||||
? el("pre", { class: "path-preview-body" }, plainText)
|
||||
: renderRendered(),
|
||||
);
|
||||
}
|
||||
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
|
||||
const b = el('button',
|
||||
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
|
||||
b.addEventListener('click', () => show(mode));
|
||||
for (const [mode, label] of [
|
||||
["rendered", "rendered"],
|
||||
["plain", plainLabel],
|
||||
]) {
|
||||
const b = el(
|
||||
"button",
|
||||
{ type: "button", class: "diff-base-tab", "data-mode": mode },
|
||||
label,
|
||||
);
|
||||
b.addEventListener("click", () => show(mode));
|
||||
tabs.append(b);
|
||||
}
|
||||
show('rendered');
|
||||
return el('div', {}, tabs, host);
|
||||
show("rendered");
|
||||
return el("div", {}, tabs, host);
|
||||
}
|
||||
// Rendered <img> for an SVG, loaded via an <img> data: URI —
|
||||
// <img>-loaded SVG runs in the browser's secure static mode (no
|
||||
// scripts, no external fetches), so an untrusted SVG from an
|
||||
// agent's state dir can't execute code in the dashboard.
|
||||
function svgImage(text) {
|
||||
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('div', { class: 'meta' },
|
||||
'(could not render — see the source tab)'));
|
||||
const img = el("img", { class: "img-preview", alt: "SVG preview" });
|
||||
img.addEventListener("error", () => {
|
||||
img.replaceWith(
|
||||
el("div", { class: "meta" }, "(could not render — see the source tab)"),
|
||||
);
|
||||
});
|
||||
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
|
||||
img.src = "data:image/svg+xml," + encodeURIComponent(text);
|
||||
return img;
|
||||
}
|
||||
// Marked-rendered markdown node (raw text fallback if `marked`
|
||||
|
|
@ -357,15 +433,15 @@ function svgImage(text) {
|
|||
// tags that `marked` itself no longer strips (v5+ dropped the built-in
|
||||
// sanitizer).
|
||||
function mdNode(text) {
|
||||
const div = el('div', { class: 'md' });
|
||||
if (window.marked && typeof window.marked.parse === 'function') {
|
||||
const div = el("div", { class: "md" });
|
||||
if (window.marked && typeof window.marked.parse === "function") {
|
||||
window.marked.setOptions({ breaks: true, gfm: true });
|
||||
div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates away from the dashboard.
|
||||
div.querySelectorAll('a[href]').forEach((a) => {
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener noreferrer';
|
||||
div.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
});
|
||||
} else {
|
||||
div.textContent = text;
|
||||
|
|
@ -381,38 +457,53 @@ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
|
|||
// render as an <img>; every other file stays raw text in a <pre>.
|
||||
async function openFilePanel(path) {
|
||||
if (RASTER_RE.test(path)) {
|
||||
const img = el('img', { class: 'img-preview', alt: path });
|
||||
img.addEventListener('error', () => {
|
||||
img.replaceWith(el('pre', { class: 'path-preview-body' },
|
||||
'(could not load image — it may be missing or over the preview size cap)'));
|
||||
const img = el("img", { class: "img-preview", alt: path });
|
||||
img.addEventListener("error", () => {
|
||||
img.replaceWith(
|
||||
el(
|
||||
"pre",
|
||||
{ class: "path-preview-body" },
|
||||
"(could not load image — it may be missing or over the preview size cap)",
|
||||
),
|
||||
);
|
||||
});
|
||||
img.src = '/api/state-file?path=' + encodeURIComponent(path);
|
||||
sidePanel.open('↳ ' + path, img);
|
||||
img.src = "/api/state-file?path=" + encodeURIComponent(path);
|
||||
sidePanel.open("↳ " + path, img);
|
||||
return;
|
||||
}
|
||||
const isMd = /\.(md|markdown)$/i.test(path);
|
||||
const isSvg = /\.svg$/i.test(path);
|
||||
const view = el('div');
|
||||
view.textContent = '(fetching…)';
|
||||
sidePanel.open('↳ ' + path, view);
|
||||
const view = el("div");
|
||||
view.textContent = "(fetching…)";
|
||||
sidePanel.open("↳ " + path, view);
|
||||
try {
|
||||
const text = await fetchStateFile(path);
|
||||
if (isSvg) {
|
||||
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
|
||||
view.replaceChildren(
|
||||
buildTabbedPreview(() => svgImage(text), text, "source"),
|
||||
);
|
||||
} else if (isMd) {
|
||||
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
|
||||
view.replaceChildren(
|
||||
buildTabbedPreview(() => mdNode(text), text, "plain"),
|
||||
);
|
||||
} else {
|
||||
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
|
||||
view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
|
||||
}
|
||||
} catch (e) {
|
||||
view.textContent = 'error: ' + (e.message || e);
|
||||
view.textContent = "error: " + (e.message || e);
|
||||
}
|
||||
}
|
||||
export function makePathLink(path) {
|
||||
const anchor = el('a', {
|
||||
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
|
||||
}, path);
|
||||
anchor.addEventListener('click', (e) => {
|
||||
const anchor = el(
|
||||
"a",
|
||||
{
|
||||
href: "#",
|
||||
class: "path-link",
|
||||
title: "open " + path + " in panel",
|
||||
},
|
||||
path,
|
||||
);
|
||||
anchor.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
openFilePanel(path);
|
||||
});
|
||||
|
|
@ -453,7 +544,11 @@ export function appendLinkified(parent, text, refs) {
|
|||
for (const t of tokens) {
|
||||
const idx = str.indexOf(t, i);
|
||||
if (idx === -1) continue;
|
||||
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
|
||||
if (
|
||||
bestStart === -1 ||
|
||||
idx < bestStart ||
|
||||
(idx === bestStart && t.length > bestToken.length)
|
||||
) {
|
||||
bestStart = idx;
|
||||
bestToken = t;
|
||||
}
|
||||
|
|
@ -479,55 +574,63 @@ export function appendLinkified(parent, text, refs) {
|
|||
// localhost) — on other origins the API is unavailable and we hide
|
||||
// the controls.
|
||||
export const NOTIF = (() => {
|
||||
const supported = typeof Notification !== 'undefined';
|
||||
const MUTED_KEY = 'hyperhive.notify.muted';
|
||||
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
|
||||
const setMuted = (v) => v
|
||||
? localStorage.setItem(MUTED_KEY, '1')
|
||||
: localStorage.removeItem(MUTED_KEY);
|
||||
const supported = typeof Notification !== "undefined";
|
||||
const MUTED_KEY = "hyperhive.notify.muted";
|
||||
const isMuted = () => localStorage.getItem(MUTED_KEY) === "1";
|
||||
const setMuted = (v) =>
|
||||
v
|
||||
? localStorage.setItem(MUTED_KEY, "1")
|
||||
: localStorage.removeItem(MUTED_KEY);
|
||||
function renderControls() {
|
||||
const enable = $('notif-enable');
|
||||
const mute = $('notif-mute');
|
||||
const unmute = $('notif-unmute');
|
||||
const status = $('notif-status');
|
||||
const enable = $("notif-enable");
|
||||
const mute = $("notif-mute");
|
||||
const unmute = $("notif-unmute");
|
||||
const status = $("notif-status");
|
||||
if (!enable || !mute || !unmute || !status) return;
|
||||
if (!supported) {
|
||||
enable.hidden = mute.hidden = unmute.hidden = true;
|
||||
status.hidden = false;
|
||||
status.textContent = 'notifications unsupported in this browser';
|
||||
status.textContent = "notifications unsupported in this browser";
|
||||
return;
|
||||
}
|
||||
const perm = Notification.permission;
|
||||
enable.hidden = perm === 'granted';
|
||||
mute.hidden = perm !== 'granted' || isMuted();
|
||||
unmute.hidden = perm !== 'granted' || !isMuted();
|
||||
status.hidden = perm !== 'denied';
|
||||
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
|
||||
enable.hidden = perm === "granted";
|
||||
mute.hidden = perm !== "granted" || isMuted();
|
||||
unmute.hidden = perm !== "granted" || !isMuted();
|
||||
status.hidden = perm !== "denied";
|
||||
if (perm === "denied")
|
||||
status.textContent = "notifications blocked — grant in site settings";
|
||||
}
|
||||
function bind() {
|
||||
const enable = $('notif-enable');
|
||||
const mute = $('notif-mute');
|
||||
const unmute = $('notif-unmute');
|
||||
const enable = $("notif-enable");
|
||||
const mute = $("notif-mute");
|
||||
const unmute = $("notif-unmute");
|
||||
if (!supported || !enable || !mute || !unmute) return;
|
||||
enable.addEventListener('click', async () => {
|
||||
enable.addEventListener("click", async () => {
|
||||
await Notification.requestPermission();
|
||||
renderControls();
|
||||
});
|
||||
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
|
||||
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
|
||||
mute.addEventListener("click", () => {
|
||||
setMuted(true);
|
||||
renderControls();
|
||||
});
|
||||
unmute.addEventListener("click", () => {
|
||||
setMuted(false);
|
||||
renderControls();
|
||||
});
|
||||
renderControls();
|
||||
}
|
||||
function show(title, body, tag) {
|
||||
if (!supported) {
|
||||
console.debug('notify: Notification API not supported');
|
||||
console.debug("notify: Notification API not supported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission !== 'granted') {
|
||||
console.debug('notify: permission not granted', Notification.permission);
|
||||
if (Notification.permission !== "granted") {
|
||||
console.debug("notify: permission not granted", Notification.permission);
|
||||
return;
|
||||
}
|
||||
if (isMuted()) {
|
||||
console.debug('notify: muted');
|
||||
console.debug("notify: muted");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
@ -537,12 +640,15 @@ export const NOTIF = (() => {
|
|||
// because that one tag would replace itself on every fire.
|
||||
const n = new Notification(title, {
|
||||
body,
|
||||
tag: tag || ('hyperhive:' + Date.now()),
|
||||
tag: tag || "hyperhive:" + Date.now(),
|
||||
});
|
||||
n.onclick = () => { window.focus(); n.close(); };
|
||||
console.debug('notify: shown', title, 'tag=', tag);
|
||||
n.onclick = () => {
|
||||
window.focus();
|
||||
n.close();
|
||||
};
|
||||
console.debug("notify: shown", title, "tag=", tag);
|
||||
} catch (err) {
|
||||
console.warn('notification show failed', err);
|
||||
console.warn("notification show failed", err);
|
||||
}
|
||||
}
|
||||
return { bind, show, renderControls };
|
||||
|
|
@ -564,11 +670,11 @@ export const NOTIF = (() => {
|
|||
// page's existing chrome element; pages without a chrome (e.g. the H0M3
|
||||
// hub) get a banner-only sticky region at the top of <body>.
|
||||
function ensureStickyTop() {
|
||||
let top = document.querySelector('.sticky-top');
|
||||
let top = document.querySelector(".sticky-top");
|
||||
if (top) return top;
|
||||
top = document.createElement('div');
|
||||
top.className = 'sticky-top';
|
||||
const chrome = document.querySelector('.dashboard-chrome, .page-header');
|
||||
top = document.createElement("div");
|
||||
top.className = "sticky-top";
|
||||
const chrome = document.querySelector(".dashboard-chrome, .page-header");
|
||||
if (chrome && chrome.parentNode) {
|
||||
chrome.parentNode.insertBefore(top, chrome);
|
||||
top.append(chrome);
|
||||
|
|
@ -579,12 +685,12 @@ function ensureStickyTop() {
|
|||
}
|
||||
|
||||
function ensureServerWarningsBar() {
|
||||
let bar = document.getElementById('server-warnings');
|
||||
let bar = document.getElementById("server-warnings");
|
||||
if (!bar) {
|
||||
bar = document.createElement('div');
|
||||
bar.id = 'server-warnings';
|
||||
bar.className = 'server-warnings';
|
||||
bar.setAttribute('role', 'alert');
|
||||
bar = document.createElement("div");
|
||||
bar.id = "server-warnings";
|
||||
bar.className = "server-warnings";
|
||||
bar.setAttribute("role", "alert");
|
||||
bar.hidden = true;
|
||||
ensureStickyTop().prepend(bar);
|
||||
}
|
||||
|
|
@ -601,10 +707,12 @@ export function renderServerWarnings(warnings) {
|
|||
return;
|
||||
}
|
||||
for (const w of warnings) {
|
||||
const row = el('div', {
|
||||
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
|
||||
const row = el("div", {
|
||||
class:
|
||||
"server-warn server-warn-" +
|
||||
(w && w.level === "crit" ? "crit" : "warn"),
|
||||
});
|
||||
appendText(row, '⚠ ' + ((w && w.message) || ''));
|
||||
appendText(row, "⚠ " + ((w && w.message) || ""));
|
||||
bar.append(row);
|
||||
}
|
||||
bar.hidden = false;
|
||||
|
|
@ -616,8 +724,10 @@ export function renderServerWarnings(warnings) {
|
|||
/// live updates instead.
|
||||
export function initServerWarnings() {
|
||||
ensureServerWarningsBar();
|
||||
fetch('/api/state')
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => renderServerWarnings(s && s.server_warnings))
|
||||
.catch(() => { /* non-fatal: no banner if the snapshot is unreachable */ });
|
||||
.catch(() => {
|
||||
/* non-fatal: no banner if the snapshot is unreachable */
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ body.core-shell {
|
|||
|
||||
/* createTabStrip toggles the native `hidden` attribute on inactive
|
||||
panes; ensure it wins over any inherited display. */
|
||||
.core-pane[hidden] { display: none; }
|
||||
.core-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ─── K3PT ST4T3 container cards ────────────────────────────────────
|
||||
core.html doesn't load dashboard.css (that's the operator SPA),
|
||||
|
|
@ -57,7 +59,9 @@ body.core-shell {
|
|||
font-size: 1.05em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.container-row.tombstone .head .name { color: var(--muted); }
|
||||
.container-row.tombstone .head .name {
|
||||
color: var(--muted);
|
||||
}
|
||||
.container-row .head .meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.88em;
|
||||
|
|
@ -68,4 +72,7 @@ body.core-shell {
|
|||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.container-row .actions form.inline { display: inline-block; margin: 0; }
|
||||
.container-row .actions form.inline {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // C0R3</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/core.css">
|
||||
</head>
|
||||
<body class="core-shell">
|
||||
|
||||
<!-- C0R3: the host/coordinator surface. Standalone page (served at
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // C0R3</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/core.css" />
|
||||
</head>
|
||||
<body class="core-shell">
|
||||
<!-- C0R3: the host/coordinator surface. Standalone page (served at
|
||||
/core.html) carved out of the dashboard's old SYST3M tab so the
|
||||
dashboard tab strip stays lean. Same minimal chrome as
|
||||
/logs.html — a `← home` back-link to the H0M3 hub + a
|
||||
|
|
@ -24,41 +23,62 @@
|
|||
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no
|
||||
dashboard panel — `hivectl stop`/`start`/`restart` is the only
|
||||
control surface. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core"
|
||||
role="tablist"></hive-tab-strip>
|
||||
</header>
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar core-tabbar"
|
||||
id="core-tabbar"
|
||||
prefix="core"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="core-main">
|
||||
|
||||
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
|
||||
<main class="core-main">
|
||||
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
|
||||
tombstones-section shows destroyed agents (purge button).
|
||||
tombstones-stale-perms is lazy-loaded on tab activation and shows
|
||||
agents with explicit capability/tool-group entries but no live
|
||||
container (typically renamed/deleted agents whose JSON entries
|
||||
persisted). Each stale entry gets a "✕ clear perms" button. -->
|
||||
<section class="core-pane" id="core-pane-kept" data-tab-pane="kept"
|
||||
role="tabpanel" aria-labelledby="core-tab-kept">
|
||||
<p class="meta">kept state from previously tombstoned agents — recreating an agent with the same name reuses it.</p>
|
||||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<div id="tombstones-stale-perms"></div>
|
||||
</section>
|
||||
<section
|
||||
class="core-pane"
|
||||
id="core-pane-kept"
|
||||
data-tab-pane="kept"
|
||||
role="tabpanel"
|
||||
aria-labelledby="core-tab-kept"
|
||||
>
|
||||
<p class="meta">
|
||||
kept state from previously tombstoned agents — recreating an agent
|
||||
with the same name reuses it.
|
||||
</p>
|
||||
<div id="tombstones-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<div id="tombstones-stale-perms"></div>
|
||||
</section>
|
||||
|
||||
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
|
||||
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
|
||||
cgroup v2 on the host. Polled only while this sub-tab is active. -->
|
||||
<section class="core-pane" id="core-pane-load" data-tab-pane="load"
|
||||
role="tabpanel" aria-labelledby="core-tab-load">
|
||||
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/networking/network.md</code>).</p>
|
||||
<div id="container-load-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="core-pane"
|
||||
id="core-pane-load"
|
||||
data-tab-pane="load"
|
||||
role="tabpanel"
|
||||
aria-labelledby="core-tab-load"
|
||||
>
|
||||
<p class="meta">
|
||||
live cpu + memory per agent container, from cgroup v2 on the host. cpu
|
||||
is % of total host capacity (all cores), sampled over ~200ms each
|
||||
refresh; polled every 5s while this tab is open. network is omitted on
|
||||
purpose — agents share the host netns, so there is no per-container
|
||||
counter (see <code>docs/networking/network.md</code>).
|
||||
</p>
|
||||
<div id="container-load-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/core.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/core.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@
|
|||
// (the same broker event channel the dashboard uses), maintaining its
|
||||
// own copy of the tombstones state.
|
||||
|
||||
import { $, form, openStream, initServerWarnings } from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
|
||||
import '@hive/shared/hive-tab-strip.js';
|
||||
import { $, form, openStream, initServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
|
||||
// ─── derived state (own copies; this bundle has its own runtime) ──────────
|
||||
let tombstonesState = [];
|
||||
|
|
@ -26,60 +26,75 @@ function syncFromSnapshot(s) {
|
|||
|
||||
// ─── kept state (tombstones) ──────────────────────────────────────────────
|
||||
function renderTombstones(s) {
|
||||
const root = $('tombstones-section');
|
||||
const root = $("tombstones-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
if (!s.tombstones || !s.tombstones.length) {
|
||||
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
|
||||
root.append(el("p", { class: "empty" }, "no kept state — clean"));
|
||||
return;
|
||||
}
|
||||
const fmtBytes = (n) => {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||
};
|
||||
const fmtAgeDays = (ts) => {
|
||||
if (!ts) return '?';
|
||||
if (!ts) return "?";
|
||||
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
|
||||
if (d <= 0) return 'today';
|
||||
if (d === 1) return '1 day ago';
|
||||
return d + ' days ago';
|
||||
if (d <= 0) return "today";
|
||||
if (d === 1) return "1 day ago";
|
||||
return d + " days ago";
|
||||
};
|
||||
// Only shown alongside actual rows — a caveat over an empty list is noise.
|
||||
// Wording is deliberately about what the list *is* rather than what it
|
||||
// isn't: nothing records a destroy, so "container absent" is the only thing
|
||||
// the backend can actually tell.
|
||||
const warn = el('hive-warn', { level: 'warning' });
|
||||
const warn = el("hive-warn", { level: "warning" });
|
||||
warn.append(
|
||||
el('strong', {}, 'shows every agent whose container is absent'),
|
||||
' — not only destroyed ones. An agent part-way through being spawned ' +
|
||||
'looks identical here, because nothing records a destroy. Check it is ' +
|
||||
'really gone before you PURG3.',
|
||||
el("strong", {}, "shows every agent whose container is absent"),
|
||||
" — not only destroyed ones. An agent part-way through being spawned " +
|
||||
"looks identical here, because nothing records a destroy. Check it is " +
|
||||
"really gone before you PURG3.",
|
||||
);
|
||||
root.append(warn);
|
||||
const ul = el('ul', { class: 'containers' });
|
||||
const ul = el("ul", { class: "containers" });
|
||||
for (const t of s.tombstones) {
|
||||
const li = el('li', { class: 'container-row tombstone' });
|
||||
const head = el('div', { class: 'head' });
|
||||
const li = el("li", { class: "container-row tombstone" });
|
||||
const head = el("div", { class: "head" });
|
||||
head.append(
|
||||
el('span', { class: 'name' }, t.name),
|
||||
el("span", { class: "name" }, t.name),
|
||||
// Was `destroyed`, which the backend cannot actually know — see the
|
||||
// caveat above. `offline` is what the absence of a container proves.
|
||||
el('span', { class: 'hive-pill-sm badge-muted' }, 'offline'),
|
||||
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
|
||||
);
|
||||
if (t.has_creds)
|
||||
head.append(
|
||||
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
|
||||
);
|
||||
head.append(
|
||||
el(
|
||||
"span",
|
||||
{ class: "meta" },
|
||||
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
|
||||
),
|
||||
);
|
||||
if (t.has_creds) head.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'creds kept'));
|
||||
head.append(el('span', { class: 'meta' },
|
||||
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`));
|
||||
li.append(head);
|
||||
|
||||
const actions = el('div', { class: 'actions' });
|
||||
actions.append(form(
|
||||
'/api/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||||
'PURGE ' + t.name + '? config history, claude creds, '
|
||||
+ 'and notes are all WIPED. no undo.',
|
||||
{}, { noRefresh: true },
|
||||
));
|
||||
const actions = el("div", { class: "actions" });
|
||||
actions.append(
|
||||
form(
|
||||
"/api/purge-tombstone/" + t.name,
|
||||
"btn-destroy",
|
||||
"PURG3",
|
||||
"PURGE " +
|
||||
t.name +
|
||||
"? config history, claude creds, " +
|
||||
"and notes are all WIPED. no undo.",
|
||||
{},
|
||||
{ noRefresh: true },
|
||||
),
|
||||
);
|
||||
li.append(actions);
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
@ -98,37 +113,53 @@ let stalePermsLoaded = false;
|
|||
function renderStalePerms(root, ghosts) {
|
||||
root.replaceChildren();
|
||||
if (!ghosts.length) return;
|
||||
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries'));
|
||||
root.append(el('p', { class: 'meta' },
|
||||
'agents with explicit capability or tool-group entries but no live container '
|
||||
+ 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).'));
|
||||
const errP = el('p', { class: 'tombstones-stale-err', hidden: true });
|
||||
const ul = el('ul', { class: 'tombstones-stale-list' });
|
||||
root.append(
|
||||
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
|
||||
);
|
||||
root.append(
|
||||
el(
|
||||
"p",
|
||||
{ class: "meta" },
|
||||
"agents with explicit capability or tool-group entries but no live container " +
|
||||
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
|
||||
),
|
||||
);
|
||||
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
|
||||
const ul = el("ul", { class: "tombstones-stale-list" });
|
||||
for (const name of ghosts) {
|
||||
const li = el('li', { class: 'tombstones-stale-row' });
|
||||
li.append(el('span', { class: 'tombstones-stale-name' }, name));
|
||||
li.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'stale perms'));
|
||||
const btn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-destroy',
|
||||
title: 'remove explicit capability and tool-group entries for ' + name,
|
||||
}, '✕ clear perms');
|
||||
btn.addEventListener('click', () => asyncBtn(btn, async () => {
|
||||
errP.hidden = true;
|
||||
try {
|
||||
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
if (!resp.ok) {
|
||||
const msg = await resp.text().catch(() => String(resp.status));
|
||||
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg;
|
||||
const li = el("li", { class: "tombstones-stale-row" });
|
||||
li.append(el("span", { class: "tombstones-stale-name" }, name));
|
||||
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
|
||||
const btn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "btn btn-destroy",
|
||||
title: "remove explicit capability and tool-group entries for " + name,
|
||||
},
|
||||
"✕ clear perms",
|
||||
);
|
||||
btn.addEventListener("click", () =>
|
||||
asyncBtn(btn, async () => {
|
||||
errP.hidden = true;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/permissions/" + encodeURIComponent(name),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const msg = await resp.text().catch(() => String(resp.status));
|
||||
errP.textContent = "failed to clear perms for " + name + ": " + msg;
|
||||
errP.hidden = false;
|
||||
return;
|
||||
}
|
||||
await fetchAndRenderStalePerms();
|
||||
} catch (err) {
|
||||
errP.textContent = "failed to clear perms for " + name + ": " + err;
|
||||
errP.hidden = false;
|
||||
return;
|
||||
}
|
||||
await fetchAndRenderStalePerms();
|
||||
} catch (err) {
|
||||
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
|
||||
errP.hidden = false;
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
li.append(btn);
|
||||
ul.append(li);
|
||||
}
|
||||
|
|
@ -140,16 +171,18 @@ function renderStalePerms(root, ghosts) {
|
|||
// but are absent from both the live roster and the kept-state tombstones.
|
||||
// One call, no client-side roster cache, always authoritative.
|
||||
async function fetchAndRenderStalePerms() {
|
||||
const root = $('tombstones-stale-perms');
|
||||
const root = $("tombstones-stale-perms");
|
||||
if (!root) return;
|
||||
try {
|
||||
const resp = await fetch('/api/permissions/stale');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/permissions/stale");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const data = await resp.json();
|
||||
renderStalePerms(root, data.stale || []);
|
||||
} catch (err) {
|
||||
root.replaceChildren();
|
||||
root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err));
|
||||
root.append(
|
||||
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
|
||||
);
|
||||
}
|
||||
stalePermsLoaded = true;
|
||||
}
|
||||
|
|
@ -158,21 +191,25 @@ async function fetchAndRenderStalePerms() {
|
|||
let containerLoadTimer = null;
|
||||
|
||||
function cloadFmtBytes(n) {
|
||||
if (!Number.isFinite(n) || n <= 0) return '0';
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let i = 0; let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
|
||||
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
|
||||
if (!Number.isFinite(n) || n <= 0) return "0";
|
||||
const u = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i];
|
||||
}
|
||||
function cloadMeter(pct) {
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
|
||||
const m = document.createElement('span');
|
||||
m.className = 'cload-meter';
|
||||
m.title = p.toFixed(0) + '%';
|
||||
const fill = document.createElement('span');
|
||||
fill.className = 'fill' + (cls ? ' ' + cls : '');
|
||||
fill.style.width = p + '%';
|
||||
const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : "";
|
||||
const m = document.createElement("span");
|
||||
m.className = "cload-meter";
|
||||
m.title = p.toFixed(0) + "%";
|
||||
const fill = document.createElement("span");
|
||||
fill.className = "fill" + (cls ? " " + cls : "");
|
||||
fill.style.width = p + "%";
|
||||
m.append(fill);
|
||||
return m;
|
||||
}
|
||||
|
|
@ -183,12 +220,14 @@ let lastLoadRows = [];
|
|||
|
||||
function renderContainerLoad(rows) {
|
||||
lastLoadRows = rows;
|
||||
const root = $('container-load-section');
|
||||
const root = $("container-load-section");
|
||||
if (!root) return;
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'no running agent containers'; root.append(p);
|
||||
const p = document.createElement("p");
|
||||
p.className = "meta";
|
||||
p.textContent = "no running agent containers";
|
||||
root.append(p);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -197,83 +236,105 @@ function renderContainerLoad(rows) {
|
|||
// resolved by the server) next to the live cgroup readings.
|
||||
const cvByName = new Map(containersState.map((c) => [c.name, c]));
|
||||
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hive-stats-table';
|
||||
const table = document.createElement("table");
|
||||
table.className = "hive-stats-table";
|
||||
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView
|
||||
// (effective drop-in values, take effect on next start/restart).
|
||||
// "limit" remains the live cgroup memory ceiling from /api/container-resources.
|
||||
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
|
||||
+ '<th>peak</th><th>limit</th><th>disk</th>'
|
||||
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>'
|
||||
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>'
|
||||
+ '<th></th></tr></thead>';
|
||||
const tb = document.createElement('tbody');
|
||||
table.innerHTML =
|
||||
"<thead><tr><th>agent</th><th>cpu</th><th>memory</th>" +
|
||||
"<th>peak</th><th>limit</th><th>disk</th>" +
|
||||
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' +
|
||||
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' +
|
||||
"<th></th></tr></thead>";
|
||||
const tb = document.createElement("tbody");
|
||||
for (const r of rows) {
|
||||
const cv = cvByName.get(r.name);
|
||||
const tr = document.createElement('tr');
|
||||
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
|
||||
const cpu = document.createElement('td'); cpu.className = 'num';
|
||||
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
|
||||
const tr = document.createElement("tr");
|
||||
const name = document.createElement("td");
|
||||
name.textContent = r.name;
|
||||
tr.append(name);
|
||||
const cpu = document.createElement("td");
|
||||
cpu.className = "num";
|
||||
cpu.append(
|
||||
(Number(r.cpu_pct) || 0).toFixed(1) + "%",
|
||||
cloadMeter(Number(r.cpu_pct) || 0),
|
||||
);
|
||||
tr.append(cpu);
|
||||
const mem = document.createElement('td'); mem.className = 'num';
|
||||
const mem = document.createElement("td");
|
||||
mem.className = "num";
|
||||
const memCur = Number(r.mem_current_bytes) || 0;
|
||||
if (r.mem_max_bytes) {
|
||||
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
|
||||
mem.append(
|
||||
cloadFmtBytes(memCur),
|
||||
cloadMeter((100 * memCur) / r.mem_max_bytes),
|
||||
);
|
||||
} else {
|
||||
mem.textContent = cloadFmtBytes(memCur);
|
||||
}
|
||||
tr.append(mem);
|
||||
const peak = document.createElement('td'); peak.className = 'num';
|
||||
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
|
||||
const peak = document.createElement("td");
|
||||
peak.className = "num";
|
||||
peak.textContent = r.mem_peak_bytes
|
||||
? cloadFmtBytes(Number(r.mem_peak_bytes))
|
||||
: "—";
|
||||
tr.append(peak);
|
||||
const lim = document.createElement('td'); lim.className = 'num';
|
||||
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
|
||||
const lim = document.createElement("td");
|
||||
lim.className = "num";
|
||||
lim.textContent = r.mem_max_bytes
|
||||
? cloadFmtBytes(Number(r.mem_max_bytes))
|
||||
: "∞";
|
||||
tr.append(lim);
|
||||
// Disk: on-disk footprint (state dir + container writable rootfs, shared
|
||||
// nix store excluded). Sampled out-of-band every few minutes server-side,
|
||||
// so it's null until the first sample lands — show an em-dash then.
|
||||
const disk = document.createElement('td'); disk.className = 'num';
|
||||
disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes';
|
||||
disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—';
|
||||
const disk = document.createElement("td");
|
||||
disk.className = "num";
|
||||
disk.title =
|
||||
"state dir + container writable rootfs (shared nix store excluded); sampled every few minutes";
|
||||
disk.textContent =
|
||||
r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "—";
|
||||
tr.append(disk);
|
||||
|
||||
// Configured CPU / memory caps from ContainerView (resolved effective
|
||||
// values: per-agent override when set, hive-wide default otherwise).
|
||||
const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap';
|
||||
cpuCap.title = 'configured ceiling — takes effect on next start';
|
||||
cpuCap.textContent = cv?.cpu_quota || '—';
|
||||
const cpuCap = document.createElement("td");
|
||||
cpuCap.className = "num cload-cap";
|
||||
cpuCap.title = "configured ceiling — takes effect on next start";
|
||||
cpuCap.textContent = cv?.cpu_quota || "—";
|
||||
tr.append(cpuCap);
|
||||
const memCap = document.createElement('td'); memCap.className = 'num cload-cap';
|
||||
memCap.title = 'configured ceiling — takes effect on next start';
|
||||
memCap.textContent = cv?.memory_max || '—';
|
||||
const memCap = document.createElement("td");
|
||||
memCap.className = "num cload-cap";
|
||||
memCap.title = "configured ceiling — takes effect on next start";
|
||||
memCap.textContent = cv?.memory_max || "—";
|
||||
tr.append(memCap);
|
||||
|
||||
// S3T button toggles the inline edit row for this agent.
|
||||
const actTd = document.createElement('td');
|
||||
const setBtn = document.createElement('button');
|
||||
setBtn.type = 'button';
|
||||
setBtn.className = 'btn btn-sm cload-set-btn';
|
||||
setBtn.textContent = 'S3T';
|
||||
setBtn.title = 'set CPU / memory cap for ' + r.name;
|
||||
const actTd = document.createElement("td");
|
||||
const setBtn = document.createElement("button");
|
||||
setBtn.type = "button";
|
||||
setBtn.className = "btn btn-sm cload-set-btn";
|
||||
setBtn.textContent = "S3T";
|
||||
setBtn.title = "set CPU / memory cap for " + r.name;
|
||||
tr.append(actTd);
|
||||
actTd.append(setBtn);
|
||||
tb.append(tr);
|
||||
|
||||
// Inline edit row (hidden by default, toggled by the S3T button).
|
||||
const editRow = document.createElement('tr');
|
||||
editRow.className = 'cload-edit-row';
|
||||
const editRow = document.createElement("tr");
|
||||
editRow.className = "cload-edit-row";
|
||||
editRow.hidden = true;
|
||||
const editTd = document.createElement('td');
|
||||
const editTd = document.createElement("td");
|
||||
editTd.colSpan = 9;
|
||||
editTd.className = 'cload-edit-cell';
|
||||
editTd.className = "cload-edit-cell";
|
||||
|
||||
const editForm = document.createElement('form');
|
||||
editForm.className = 'cload-edit-form';
|
||||
editForm.addEventListener('submit', async (e) => {
|
||||
const editForm = document.createElement("form");
|
||||
editForm.className = "cload-edit-form";
|
||||
editForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const cpuInput = editForm.querySelector('.cload-cpu-input');
|
||||
const memInput = editForm.querySelector('.cload-mem-input');
|
||||
const errSpan = editForm.querySelector('.cload-edit-err');
|
||||
const cpuInput = editForm.querySelector(".cload-cpu-input");
|
||||
const memInput = editForm.querySelector(".cload-mem-input");
|
||||
const errSpan = editForm.querySelector(".cload-edit-err");
|
||||
const submitBtn = editForm.querySelector('[type="submit"]');
|
||||
errSpan.hidden = true;
|
||||
submitBtn.disabled = true;
|
||||
|
|
@ -283,11 +344,13 @@ function renderContainerLoad(rows) {
|
|||
memory_max: memInput.value.trim(),
|
||||
});
|
||||
const resp = await fetch(
|
||||
'/api/resource-limits/' + encodeURIComponent(r.name),
|
||||
{ method: 'POST', body },
|
||||
"/api/resource-limits/" + encodeURIComponent(r.name),
|
||||
{ method: "POST", body },
|
||||
);
|
||||
if (!resp.ok) {
|
||||
errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status);
|
||||
errSpan.textContent = await resp
|
||||
.text()
|
||||
.catch(() => "error " + resp.status);
|
||||
errSpan.hidden = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -295,7 +358,7 @@ function renderContainerLoad(rows) {
|
|||
// ContainerView data (cpu_quota/memory_max) to containersState,
|
||||
// triggering a re-render of the cap columns via refreshContainerLoad.
|
||||
editRow.hidden = true;
|
||||
setBtn.textContent = 'S3T';
|
||||
setBtn.textContent = "S3T";
|
||||
} catch (err) {
|
||||
errSpan.textContent = String(err);
|
||||
errSpan.hidden = false;
|
||||
|
|
@ -304,44 +367,50 @@ function renderContainerLoad(rows) {
|
|||
}
|
||||
});
|
||||
|
||||
const cpuLabel = document.createElement('label');
|
||||
cpuLabel.className = 'cload-edit-label';
|
||||
cpuLabel.textContent = 'cpu quota';
|
||||
const cpuInput = document.createElement('input');
|
||||
cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input';
|
||||
cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%';
|
||||
cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
|
||||
const cpuLabel = document.createElement("label");
|
||||
cpuLabel.className = "cload-edit-label";
|
||||
cpuLabel.textContent = "cpu quota";
|
||||
const cpuInput = document.createElement("input");
|
||||
cpuInput.type = "text";
|
||||
cpuInput.className = "cload-cpu-input";
|
||||
cpuInput.placeholder = cv ? cv.cpu_quota : "e.g. 200%";
|
||||
cpuInput.title =
|
||||
'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
|
||||
cpuLabel.append(cpuInput);
|
||||
|
||||
const memLabel = document.createElement('label');
|
||||
memLabel.className = 'cload-edit-label';
|
||||
memLabel.textContent = 'mem max';
|
||||
const memInput = document.createElement('input');
|
||||
memInput.type = 'text'; memInput.className = 'cload-mem-input';
|
||||
memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G';
|
||||
memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
|
||||
const memLabel = document.createElement("label");
|
||||
memLabel.className = "cload-edit-label";
|
||||
memLabel.textContent = "mem max";
|
||||
const memInput = document.createElement("input");
|
||||
memInput.type = "text";
|
||||
memInput.className = "cload-mem-input";
|
||||
memInput.placeholder = cv ? cv.memory_max : "e.g. 8G";
|
||||
memInput.title =
|
||||
'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
|
||||
memLabel.append(memInput);
|
||||
|
||||
const submitBtn = document.createElement('button');
|
||||
submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn';
|
||||
submitBtn.textContent = 'S4V3';
|
||||
const submitBtn = document.createElement("button");
|
||||
submitBtn.type = "submit";
|
||||
submitBtn.className = "btn btn-restart cload-save-btn";
|
||||
submitBtn.textContent = "S4V3";
|
||||
|
||||
const hintSpan = document.createElement('span');
|
||||
hintSpan.className = 'meta cload-edit-hint';
|
||||
hintSpan.textContent = '↺ restart to apply to a running container';
|
||||
const hintSpan = document.createElement("span");
|
||||
hintSpan.className = "meta cload-edit-hint";
|
||||
hintSpan.textContent = "↺ restart to apply to a running container";
|
||||
|
||||
const errSpan = document.createElement('span');
|
||||
errSpan.className = 'cload-edit-err'; errSpan.hidden = true;
|
||||
const errSpan = document.createElement("span");
|
||||
errSpan.className = "cload-edit-err";
|
||||
errSpan.hidden = true;
|
||||
|
||||
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
|
||||
editTd.append(editForm);
|
||||
editRow.append(editTd);
|
||||
tb.append(editRow);
|
||||
|
||||
setBtn.addEventListener('click', () => {
|
||||
setBtn.addEventListener("click", () => {
|
||||
const open = !editRow.hidden;
|
||||
editRow.hidden = open;
|
||||
setBtn.textContent = open ? 'S3T' : '✕';
|
||||
setBtn.textContent = open ? "S3T" : "✕";
|
||||
});
|
||||
}
|
||||
table.append(tb);
|
||||
|
|
@ -350,15 +419,17 @@ function renderContainerLoad(rows) {
|
|||
|
||||
async function refreshContainerLoad() {
|
||||
try {
|
||||
const resp = await fetch('/api/container-resources');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/container-resources");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
renderContainerLoad(await resp.json());
|
||||
} catch (e) {
|
||||
const root = $('container-load-section');
|
||||
const root = $("container-load-section");
|
||||
if (root) {
|
||||
root.replaceChildren();
|
||||
const p = document.createElement('p'); p.className = 'meta';
|
||||
p.textContent = 'container load fetch failed: ' + e; root.append(p);
|
||||
const p = document.createElement("p");
|
||||
p.className = "meta";
|
||||
p.textContent = "container load fetch failed: " + e;
|
||||
root.append(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -368,7 +439,10 @@ function startContainerLoadPolling() {
|
|||
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
|
||||
}
|
||||
function stopContainerLoadPolling() {
|
||||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||
if (containerLoadTimer) {
|
||||
clearInterval(containerLoadTimer);
|
||||
containerLoadTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── render-all (cold load + any full re-render) ──────────────────────────
|
||||
|
|
@ -412,7 +486,7 @@ const SSE_HANDLERS = {
|
|||
// purge actions that opt out of `data-no-refresh`).
|
||||
async function refreshState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (resp.ok) syncFromSnapshot(await resp.json());
|
||||
} catch {
|
||||
// best-effort: the page keeps its last-rendered state
|
||||
|
|
@ -431,18 +505,18 @@ async function init() {
|
|||
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
|
||||
// polling runs only while the LOAD sub-tab is open (cpu is a short
|
||||
// two-sample read each refresh on the server).
|
||||
document.getElementById('core-tabbar').configure({
|
||||
document.getElementById("core-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: 'kept', label: 'K3PT ST4T3' },
|
||||
{ id: 'load', label: 'C0NT41N3R L04D' },
|
||||
{ id: "kept", label: "K3PT ST4T3" },
|
||||
{ id: "load", label: "C0NT41N3R L04D" },
|
||||
],
|
||||
defaultId: 'kept',
|
||||
defaultId: "kept",
|
||||
onShow: (id) => {
|
||||
if (id === 'load') startContainerLoadPolling();
|
||||
if (id === "load") startContainerLoadPolling();
|
||||
else stopContainerLoadPolling();
|
||||
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
|
||||
// re-fetch on subsequent visits in case perms changed.
|
||||
if (id === 'kept') fetchAndRenderStalePerms();
|
||||
if (id === "kept") fetchAndRenderStalePerms();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -454,13 +528,17 @@ async function init() {
|
|||
// (subscription discipline, part 1 of the dashboard-event-stream-
|
||||
// split issue).
|
||||
const es = openStream(
|
||||
'/api/dashboard/stream?kinds=tombstones_changed,container_state_changed,' +
|
||||
'capabilities_changed,tool_groups_changed',
|
||||
"/api/dashboard/stream?kinds=tombstones_changed,container_state_changed," +
|
||||
"capabilities_changed,tool_groups_changed",
|
||||
);
|
||||
if (es) {
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); } catch { return; }
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const h = SSE_HANDLERS[ev.kind];
|
||||
if (h) h(ev);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,9 +25,13 @@ body.cred-shell {
|
|||
padding: 1.2em 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.cred-pane[hidden] { display: none; }
|
||||
.cred-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gh-status { margin: 0.5rem 0 1.2rem; }
|
||||
.gh-status {
|
||||
margin: 0.5rem 0 1.2rem;
|
||||
}
|
||||
.gh-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -39,10 +43,18 @@ body.cred-shell {
|
|||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.gh-dot.present { background: var(--green); }
|
||||
.gh-dot.absent { background: var(--muted); }
|
||||
.gh-status-text.present { color: var(--green); }
|
||||
.gh-status-text.absent { color: var(--muted); }
|
||||
.gh-dot.present {
|
||||
background: var(--green);
|
||||
}
|
||||
.gh-dot.absent {
|
||||
background: var(--muted);
|
||||
}
|
||||
.gh-status-text.present {
|
||||
color: var(--green);
|
||||
}
|
||||
.gh-status-text.absent {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ma-field {
|
||||
display: flex;
|
||||
|
|
@ -50,7 +62,10 @@ body.cred-shell {
|
|||
gap: 0.25rem;
|
||||
margin: 0.55rem 0;
|
||||
}
|
||||
.ma-field > span { font-size: 0.8rem; color: var(--muted); }
|
||||
.ma-field > span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ma-field input,
|
||||
.ma-field select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
|
|
@ -72,13 +87,28 @@ body.cred-shell {
|
|||
padding: 0.45rem 0.75rem 0.6rem;
|
||||
margin: 0.85rem 0;
|
||||
}
|
||||
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; }
|
||||
.ma-mode label { margin-right: 1.3rem; cursor: pointer; }
|
||||
.ma-mode legend {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.ma-mode label {
|
||||
margin-right: 1.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ma-modefields { margin: 0.4rem 0; }
|
||||
.ma-modefields {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.ma-list { margin: 0.5rem 0 1.2rem; }
|
||||
.ma-accounts { list-style: none; padding: 0; margin: 0; }
|
||||
.ma-list {
|
||||
margin: 0.5rem 0 1.2rem;
|
||||
}
|
||||
.ma-accounts {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.ma-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -96,10 +126,16 @@ body.cred-shell {
|
|||
`offline`/`stale` are the amber states (provisioned-not-live / container-down
|
||||
⟹ daemon-down). `absent` = no token. */
|
||||
.ma-dot.ok,
|
||||
.ma-dot.live { background: var(--green); }
|
||||
.ma-dot.live {
|
||||
background: var(--green);
|
||||
}
|
||||
.ma-dot.offline,
|
||||
.ma-dot.stale { background: var(--amber); }
|
||||
.ma-dot.absent { background: var(--muted); }
|
||||
.ma-dot.stale {
|
||||
background: var(--amber);
|
||||
}
|
||||
.ma-dot.absent {
|
||||
background: var(--muted);
|
||||
}
|
||||
/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled
|
||||
(> ~90s). Keep the green hue but dim + desaturate so it reads "was online,
|
||||
now uncertain" — visually distinct from the solid amber container-down
|
||||
|
|
@ -109,23 +145,52 @@ body.cred-shell {
|
|||
opacity: 0.4;
|
||||
filter: saturate(0.45);
|
||||
}
|
||||
.ma-name { font-weight: 600; color: var(--fg); }
|
||||
.ma-uid { color: var(--muted); font-size: 0.8rem; margin-left: 0.4em; }
|
||||
.ma-hs { color: var(--muted); font-size: 0.85rem; }
|
||||
.ma-status { margin-left: auto; font-size: 0.8rem; }
|
||||
.ma-name {
|
||||
font-weight: 600;
|
||||
color: var(--fg);
|
||||
}
|
||||
.ma-uid {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
.ma-hs {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.ma-status {
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.ma-status.ok,
|
||||
.ma-status.live { color: var(--green); }
|
||||
.ma-status.live {
|
||||
color: var(--green);
|
||||
}
|
||||
.ma-status.offline,
|
||||
.ma-status.stale { color: var(--amber); }
|
||||
.ma-status.absent { color: var(--muted); }
|
||||
.ma-status.live.stale-age { color: var(--green); opacity: 0.6; }
|
||||
.ma-status.stale {
|
||||
color: var(--amber);
|
||||
}
|
||||
.ma-status.absent {
|
||||
color: var(--muted);
|
||||
}
|
||||
.ma-status.live.stale-age {
|
||||
color: var(--green);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.ma-result {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.ma-result.ok { color: var(--green); }
|
||||
.ma-result.err { color: var(--red); }
|
||||
.ma-result.ok {
|
||||
color: var(--green);
|
||||
}
|
||||
.ma-result.err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.ma-list .err { color: var(--red); font-size: 0.9rem; }
|
||||
.ma-list .err {
|
||||
color: var(--red);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,156 +1,253 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // CR3D3NTIALS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/credentials.css">
|
||||
</head>
|
||||
<body class="cred-shell">
|
||||
|
||||
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // CR3D3NTIALS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/credentials.css" />
|
||||
</head>
|
||||
<body class="cred-shell">
|
||||
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
|
||||
logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
|
||||
link points to the H0M3 hub (served at /). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip class="hive-tabbar cred-tabbar" id="cred-tabbar" prefix="cred"
|
||||
role="tablist"></hive-tab-strip>
|
||||
</header>
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar cred-tabbar"
|
||||
id="cred-tabbar"
|
||||
prefix="cred"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="cred-main">
|
||||
|
||||
<!-- Agent picker: shared across both tabs (one agent selected at a
|
||||
<main class="cred-main">
|
||||
<!-- Agent picker: shared across both tabs (one agent selected at a
|
||||
time drives both the matrix account list and the github status). -->
|
||||
<h3>◇ agent</h3>
|
||||
<label class="ma-field">
|
||||
<span>agent</span>
|
||||
<select id="ma-agent"></select>
|
||||
</label>
|
||||
<h3>◇ agent</h3>
|
||||
<label class="ma-field">
|
||||
<span>agent</span>
|
||||
<select id="ma-agent"></select>
|
||||
</label>
|
||||
|
||||
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
|
||||
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
|
||||
moved under a tab pane. -->
|
||||
<section class="cred-pane" id="cred-pane-matrix" data-tab-pane="matrix"
|
||||
role="tabpanel" aria-labelledby="cred-tab-matrix">
|
||||
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.<account>.tokenFile</code> by the host coordinator — it is never displayed back on this page.</p>
|
||||
<section
|
||||
class="cred-pane"
|
||||
id="cred-pane-matrix"
|
||||
data-tab-pane="matrix"
|
||||
role="tabpanel"
|
||||
aria-labelledby="cred-tab-matrix"
|
||||
>
|
||||
<p class="meta">
|
||||
provision or log in an <strong>external</strong> matrix account for an
|
||||
agent and store its access token. the token is written to the agent's
|
||||
<code>matrixAccounts.<account>.tokenFile</code> by the host
|
||||
coordinator — it is never displayed back on this page.
|
||||
</p>
|
||||
|
||||
<h3>◇ provisioned accounts</h3>
|
||||
<p class="meta">accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a <em>token is stored</em>, not a live session — a true online/offline indicator is a follow-up that needs the daemon's account registry.</p>
|
||||
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div>
|
||||
|
||||
<h3>◇ provision / log in</h3>
|
||||
<form id="ma-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>account name</span>
|
||||
<input type="text" name="account" placeholder="e.g. public" required>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>homeserver</span>
|
||||
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
|
||||
</label>
|
||||
|
||||
<fieldset class="ma-mode">
|
||||
<legend>login method</legend>
|
||||
<label><input type="radio" name="mode" value="password" checked> password</label>
|
||||
<label><input type="radio" name="mode" value="token"> existing token</label>
|
||||
</fieldset>
|
||||
|
||||
<div id="ma-pw-fields" class="ma-modefields">
|
||||
<label class="ma-field">
|
||||
<span>user id</span>
|
||||
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>password</span>
|
||||
<input type="password" name="password" autocomplete="new-password">
|
||||
</label>
|
||||
<h3>◇ provisioned accounts</h3>
|
||||
<p class="meta">
|
||||
accounts that have a stored token (provision one below to add it
|
||||
here); a config-declared account that hasn't been provisioned yet
|
||||
won't appear until it has a token. status reflects whether a
|
||||
<em>token is stored</em>, not a live session — a true
|
||||
online/offline indicator is a follow-up that needs the daemon's
|
||||
account registry.
|
||||
</p>
|
||||
<div id="ma-list" class="ma-list">
|
||||
<p class="meta">select an agent to see its matrix accounts.</p>
|
||||
</div>
|
||||
|
||||
<div id="ma-token-fields" class="ma-modefields" hidden>
|
||||
<h3>◇ provision / log in</h3>
|
||||
<form id="ma-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>access token</span>
|
||||
<input type="password" name="token" autocomplete="off">
|
||||
<span>account name</span>
|
||||
<input
|
||||
type="text"
|
||||
name="account"
|
||||
placeholder="e.g. public"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>user id <span class="meta">(optional — derived via whoami)</span></span>
|
||||
<input type="text" name="user_id" placeholder="@user:matrix.org">
|
||||
<span>homeserver</span>
|
||||
<input
|
||||
type="text"
|
||||
name="homeserver"
|
||||
placeholder="https://matrix.org"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-spawn">log in & store token</button>
|
||||
<p id="ma-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
<fieldset class="ma-mode">
|
||||
<legend>login method</legend>
|
||||
<label
|
||||
><input type="radio" name="mode" value="password" checked />
|
||||
password</label
|
||||
>
|
||||
<label
|
||||
><input type="radio" name="mode" value="token" /> existing
|
||||
token</label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<!-- GITHUB tab: single-account PAT paste. No login flow — the
|
||||
<div id="ma-pw-fields" class="ma-modefields">
|
||||
<label class="ma-field">
|
||||
<span>user id</span>
|
||||
<input
|
||||
type="text"
|
||||
name="user_id"
|
||||
placeholder="@user:matrix.org"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>password</span>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="ma-token-fields" class="ma-modefields" hidden>
|
||||
<label class="ma-field">
|
||||
<span>access token</span>
|
||||
<input type="password" name="token" autocomplete="off" />
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span
|
||||
>user id
|
||||
<span class="meta"
|
||||
>(optional — derived via whoami)</span
|
||||
></span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="user_id"
|
||||
placeholder="@user:matrix.org"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-spawn">
|
||||
log in & store token
|
||||
</button>
|
||||
<p id="ma-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- GITHUB tab: single-account PAT paste. No login flow — the
|
||||
operator pastes an existing PAT for a dedicated bot account.
|
||||
Security-warning banner + a link to generate a PAT. -->
|
||||
<section class="cred-pane" id="cred-pane-github" data-tab-pane="github"
|
||||
role="tabpanel" aria-labelledby="cred-tab-github" hidden>
|
||||
<hive-warn level="warning">
|
||||
⚠ use a <strong>dedicated bot account</strong>, not a human's —
|
||||
and a <strong>minimally-scoped</strong> personal access token (only
|
||||
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
|
||||
<code>workflow</code>). the container boundary is the enforcement:
|
||||
anything within the token's scopes is reachable if the agent is
|
||||
ever compromised. the token is injected into the agent's state dir
|
||||
and is <strong>never displayed back</strong> on this page.
|
||||
</hive-warn>
|
||||
<section
|
||||
class="cred-pane"
|
||||
id="cred-pane-github"
|
||||
data-tab-pane="github"
|
||||
role="tabpanel"
|
||||
aria-labelledby="cred-tab-github"
|
||||
hidden
|
||||
>
|
||||
<hive-warn level="warning">
|
||||
⚠ use a <strong>dedicated bot account</strong>, not a human's —
|
||||
and a <strong>minimally-scoped</strong> personal access token (only
|
||||
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
|
||||
<code>workflow</code>). the container boundary is the enforcement:
|
||||
anything within the token's scopes is reachable if the agent is ever
|
||||
compromised. the token is injected into the agent's state dir and is
|
||||
<strong>never displayed back</strong> on this page.
|
||||
</hive-warn>
|
||||
|
||||
<h3>◇ status</h3>
|
||||
<div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div>
|
||||
<h3>◇ status</h3>
|
||||
<div id="gh-status" class="gh-status">
|
||||
<p class="meta">
|
||||
select an agent to see its github credential status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>◇ provision</h3>
|
||||
<p class="meta">generate a token at
|
||||
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener">github.com/settings/tokens</a>
|
||||
and paste it below. one account per agent — pasting a new token replaces the stored one.</p>
|
||||
<form id="gh-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>personal access token</span>
|
||||
<input type="password" name="token" autocomplete="off" required>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-spawn">store token</button>
|
||||
<p id="gh-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
<h3>◇ provision</h3>
|
||||
<p class="meta">
|
||||
generate a token at
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>github.com/settings/tokens</a
|
||||
>
|
||||
and paste it below. one account per agent — pasting a new token
|
||||
replaces the stored one.
|
||||
</p>
|
||||
<form id="gh-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>personal access token</span>
|
||||
<input type="password" name="token" autocomplete="off" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-spawn">store token</button>
|
||||
<p id="gh-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
||||
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
|
||||
Entirely dashboard-provisioned, no host-side nix config — same
|
||||
shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
|
||||
The operator creates a token on the external forge themselves
|
||||
(however that forge lets them) and pastes label + URL + token
|
||||
below. No remote account minting/revoking — purely local. -->
|
||||
<section class="cred-pane" id="cred-pane-forges" data-tab-pane="forges"
|
||||
role="tabpanel" aria-labelledby="cred-tab-forges" hidden>
|
||||
<p class="meta">store a <strong>label + base URL + access token</strong> for an external Forgejo/Gitea/Codeberg-compatible forge, per agent. no account is created on the remote forge — create the token there yourself first. the token is never displayed back on this page.</p>
|
||||
<section
|
||||
class="cred-pane"
|
||||
id="cred-pane-forges"
|
||||
data-tab-pane="forges"
|
||||
role="tabpanel"
|
||||
aria-labelledby="cred-tab-forges"
|
||||
hidden
|
||||
>
|
||||
<p class="meta">
|
||||
store a <strong>label + base URL + access token</strong> for an
|
||||
external Forgejo/Gitea/Codeberg-compatible forge, per agent. no
|
||||
account is created on the remote forge — create the token there
|
||||
yourself first. the token is never displayed back on this page.
|
||||
</p>
|
||||
|
||||
<h3>◇ provisioned forges</h3>
|
||||
<div id="ef-list" class="ef-list"><p class="meta">select an agent to see its forge accounts.</p></div>
|
||||
<h3>◇ provisioned forges</h3>
|
||||
<div id="ef-list" class="ef-list">
|
||||
<p class="meta">select an agent to see its forge accounts.</p>
|
||||
</div>
|
||||
|
||||
<h3>◇ add forge account</h3>
|
||||
<form id="ef-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>label</span>
|
||||
<input type="text" name="label" placeholder="e.g. codeberg" required>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>base url</span>
|
||||
<input type="text" name="base_url" placeholder="https://codeberg.org" required>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>access token</span>
|
||||
<input type="password" name="token" autocomplete="off" required>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-spawn">store account</button>
|
||||
<p id="ef-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
<h3>◇ add forge account</h3>
|
||||
<form id="ef-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>label</span>
|
||||
<input
|
||||
type="text"
|
||||
name="label"
|
||||
placeholder="e.g. codeberg"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>base url</span>
|
||||
<input
|
||||
type="text"
|
||||
name="base_url"
|
||||
placeholder="https://codeberg.org"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="ma-field">
|
||||
<span>access token</span>
|
||||
<input type="password" name="token" autocomplete="off" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-spawn">store account</button>
|
||||
<p id="ef-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/credentials.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/credentials.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@
|
|||
// 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';
|
||||
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
|
||||
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";
|
||||
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
||||
|
||||
let agents = [];
|
||||
// agent name → container running (bool), from /api/state. Cross-referenced by
|
||||
|
|
@ -35,7 +35,7 @@ const containerRunning = new Map();
|
|||
|
||||
async function loadState() {
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
renderServerWarnings(s.server_warnings);
|
||||
|
|
@ -43,7 +43,7 @@ async function loadState() {
|
|||
// 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))
|
||||
.map((a) => (typeof a === "string" ? { name: a } : a))
|
||||
.filter((c) => c && c.name);
|
||||
agents = containers.map((c) => c.name).sort();
|
||||
containerRunning.clear();
|
||||
|
|
@ -56,14 +56,14 @@ async function loadState() {
|
|||
}
|
||||
|
||||
function renderAgentPicker() {
|
||||
const sel = $('ma-agent');
|
||||
const sel = $("ma-agent");
|
||||
sel.replaceChildren();
|
||||
if (!agents.length) {
|
||||
sel.append(el('option', { value: '' }, '— no agents —'));
|
||||
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));
|
||||
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)
|
||||
|
|
@ -86,91 +86,116 @@ function renderAgentPicker() {
|
|||
// v1 backend (no `live` field) falls back to token-present rendering.
|
||||
|
||||
async function loadAccounts(agent) {
|
||||
const list = $('ma-list');
|
||||
const list = $("ma-list");
|
||||
if (!agent) {
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
|
||||
list.replaceChildren(
|
||||
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
||||
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);
|
||||
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).'));
|
||||
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;
|
||||
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';
|
||||
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.'));
|
||||
list.append(
|
||||
el(
|
||||
"p",
|
||||
{ class: "meta" },
|
||||
"no matrix accounts configured for this agent.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'ma-accounts' });
|
||||
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;
|
||||
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';
|
||||
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;
|
||||
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;
|
||||
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 ✓';
|
||||
cls = "live";
|
||||
statusText = "online ✓";
|
||||
dotTitle = asOfText;
|
||||
} else if (present) {
|
||||
cls = 'offline';
|
||||
statusText = 'token stored · offline';
|
||||
dotTitle = 'provisioned but not live. ' + asOfText;
|
||||
cls = "offline";
|
||||
statusText = "token stored · offline";
|
||||
dotTitle = "provisioned but not live. " + asOfText;
|
||||
} else {
|
||||
cls = 'absent';
|
||||
statusText = 'no token';
|
||||
dotTitle = 'no token yet';
|
||||
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),
|
||||
));
|
||||
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);
|
||||
}
|
||||
|
|
@ -181,72 +206,90 @@ async function loadAccounts(agent) {
|
|||
// 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; });
|
||||
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 = ''; });
|
||||
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 out = $("ma-result");
|
||||
out.className = "ma-result";
|
||||
out.textContent = "";
|
||||
|
||||
const agent = $('ma-agent').value;
|
||||
const agent = $("ma-agent").value;
|
||||
if (!agent) {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = 'select an agent first.';
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "select an agent first.";
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(formEl);
|
||||
fd.set('agent', agent);
|
||||
fd.set("agent", agent);
|
||||
|
||||
const btn = formEl.querySelector('button[type="submit"]');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'logging in…';
|
||||
btn.textContent = "logging in…";
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/matrix-account-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
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 */ }
|
||||
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.';
|
||||
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).';
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "✗ login failed (unexpected response).";
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} else {
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
|
||||
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).';
|
||||
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;
|
||||
|
|
@ -256,81 +299,111 @@ async function submitLogin(e) {
|
|||
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadGithubStatus(agent) {
|
||||
const status = $('gh-status');
|
||||
const status = $("gh-status");
|
||||
if (!agent) {
|
||||
status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.'));
|
||||
status.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
{ class: "meta" },
|
||||
"select an agent to see its github credential status.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
status.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
||||
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);
|
||||
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).'));
|
||||
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'),
|
||||
));
|
||||
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 out = $("gh-result");
|
||||
out.className = "ma-result";
|
||||
out.textContent = "";
|
||||
|
||||
const agent = $('ma-agent').value;
|
||||
const agent = $("ma-agent").value;
|
||||
if (!agent) {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = 'select an agent first.';
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "select an agent first.";
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(formEl);
|
||||
fd.set('agent', agent);
|
||||
fd.set("agent", agent);
|
||||
|
||||
const btn = formEl.querySelector('button[type="submit"]');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'storing…';
|
||||
btn.textContent = "storing…";
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/github-account', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
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 */ }
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
/* tolerate odd 2xx body */
|
||||
}
|
||||
if (body.ok) {
|
||||
out.className = 'ma-result ok';
|
||||
out.textContent = '✓ token stored.';
|
||||
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).';
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "✗ store failed (unexpected response).";
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} else {
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
||||
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).';
|
||||
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;
|
||||
|
|
@ -347,39 +420,56 @@ async function submitGithub(e) {
|
|||
// forge themselves.
|
||||
|
||||
async function loadForgeAccounts(agent) {
|
||||
const list = $('ef-list');
|
||||
const list = $("ef-list");
|
||||
if (!agent) {
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.'));
|
||||
list.replaceChildren(
|
||||
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
|
||||
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);
|
||||
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).'));
|
||||
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.'));
|
||||
list.replaceChildren(
|
||||
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ul = el('ul', { class: 'ma-accounts' });
|
||||
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,
|
||||
));
|
||||
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);
|
||||
}
|
||||
|
|
@ -388,18 +478,22 @@ 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',
|
||||
confirmLabel: "⊘ remove",
|
||||
});
|
||||
if (!r) return;
|
||||
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = 'removing…';
|
||||
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' }),
|
||||
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);
|
||||
|
|
@ -408,66 +502,76 @@ async function onForgeRemoveClick(agent, forge, btn) {
|
|||
const msg = problemMessage(await readApiError(resp));
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
|
||||
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
|
||||
type: "error",
|
||||
});
|
||||
} catch (err) {
|
||||
btn.textContent = orig;
|
||||
btn.disabled = false;
|
||||
themedToast('✗ request failed: ' + String(err), { type: 'error' });
|
||||
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 out = $("ef-result");
|
||||
out.className = "ma-result";
|
||||
out.textContent = "";
|
||||
|
||||
const agent = $('ma-agent').value;
|
||||
const agent = $("ma-agent").value;
|
||||
if (!agent) {
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = 'select an agent first.';
|
||||
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');
|
||||
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…';
|
||||
btn.textContent = "storing…";
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/extra-forge-account', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
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 */ }
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
/* tolerate odd 2xx body */
|
||||
}
|
||||
if (body.ok) {
|
||||
out.className = 'ma-result ok';
|
||||
out.textContent = '✓ forge account stored.';
|
||||
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).';
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "✗ store failed (unexpected response).";
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} else {
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
out.className = 'ma-result err';
|
||||
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
|
||||
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).';
|
||||
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;
|
||||
|
|
@ -485,24 +589,27 @@ async function onAgentChange(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));
|
||||
$("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);
|
||||
$("ma-form").addEventListener("submit", submitLogin);
|
||||
$("gh-form").addEventListener("submit", submitGithub);
|
||||
$("ef-form").addEventListener("submit", submitForgeAccount);
|
||||
|
||||
document.getElementById('cred-tabbar').configure({
|
||||
document.getElementById("cred-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: 'matrix', label: 'MATRIX' },
|
||||
{ id: 'github', label: 'GITHUB' },
|
||||
{ id: 'forges', label: 'FORGES' },
|
||||
{ id: "matrix", label: "MATRIX" },
|
||||
{ id: "github", label: "GITHUB" },
|
||||
{ id: "forges", label: "FORGES" },
|
||||
],
|
||||
defaultId: 'matrix',
|
||||
defaultId: "matrix",
|
||||
});
|
||||
|
||||
onAgentChange('');
|
||||
onAgentChange("");
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ body.dashboard-shell {
|
|||
font-size: 0.82em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dash-home-back:hover { text-decoration: underline; }
|
||||
.dash-home-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.banner-thin {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
|
@ -95,7 +97,9 @@ body.dashboard-shell {
|
|||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, background 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.tabbar .tab:hover {
|
||||
color: var(--fg);
|
||||
|
|
@ -105,7 +109,10 @@ body.dashboard-shell {
|
|||
color: var(--purple);
|
||||
background: var(--border);
|
||||
}
|
||||
.tab-label { font-weight: bold; white-space: nowrap; }
|
||||
.tab-label {
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab-count {
|
||||
display: inline-block;
|
||||
background: var(--purple-dim);
|
||||
|
|
@ -124,11 +131,12 @@ body.dashboard-shell {
|
|||
color: var(--red);
|
||||
}
|
||||
|
||||
|
||||
/* Tab pane visibility — createTabStrip (@hive/shared/tabs.js) sets the
|
||||
`hidden` attribute on every inactive pane (resolved from the URL hash;
|
||||
default — no hash — is SW4RM). Only the active pane stays visible. */
|
||||
.tab-pane[hidden] { display: none; }
|
||||
.tab-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* FL0W is a separate page (`/flow.html`) — its full-viewport
|
||||
styling lives in flow.css. */
|
||||
|
|
@ -148,14 +156,20 @@ body.dashboard-shell {
|
|||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
|
||||
filter: drop-shadow(
|
||||
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
|
||||
);
|
||||
}
|
||||
.banner.active {
|
||||
animation: banner-shimmer 1.8s linear infinite;
|
||||
}
|
||||
@keyframes banner-shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -100% 0; }
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.role {
|
||||
|
|
@ -172,24 +186,44 @@ body.dashboard-shell {
|
|||
/* Container rows: a full-height square agent icon on the left, the
|
||||
identity / actions / drill-in lines stacked in the card body on the
|
||||
right. Pending rows dim everything except the pending indicator. */
|
||||
.containers { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.containers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.container-row {
|
||||
padding: 0.6em 0.8em;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
|
||||
transition: opacity 200ms ease, border-color 200ms ease;
|
||||
transition:
|
||||
opacity 200ms ease,
|
||||
border-color 200ms ease;
|
||||
}
|
||||
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
|
||||
+ lane geometry paragraph) for the 1.8em-per-depth-level
|
||||
rationale + CSS-attr()-not-yet-portable caveat. */
|
||||
.container-row[data-depth] { position: relative; }
|
||||
.container-row[data-depth="1"] { margin-left: 1.8em; }
|
||||
.container-row[data-depth="2"] { margin-left: 3.6em; }
|
||||
.container-row[data-depth="3"] { margin-left: 5.4em; }
|
||||
.container-row[data-depth="4"] { margin-left: 7.2em; }
|
||||
.container-row[data-depth="5"] { margin-left: 9em; }
|
||||
.container-row[data-depth="6"] { margin-left: 10.8em; }
|
||||
.container-row[data-depth] {
|
||||
position: relative;
|
||||
}
|
||||
.container-row[data-depth="1"] {
|
||||
margin-left: 1.8em;
|
||||
}
|
||||
.container-row[data-depth="2"] {
|
||||
margin-left: 3.6em;
|
||||
}
|
||||
.container-row[data-depth="3"] {
|
||||
margin-left: 5.4em;
|
||||
}
|
||||
.container-row[data-depth="4"] {
|
||||
margin-left: 7.2em;
|
||||
}
|
||||
.container-row[data-depth="5"] {
|
||||
margin-left: 9em;
|
||||
}
|
||||
.container-row[data-depth="6"] {
|
||||
margin-left: 10.8em;
|
||||
}
|
||||
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. */
|
||||
.container-row .tree-prefix {
|
||||
position: absolute;
|
||||
|
|
@ -201,19 +235,31 @@ body.dashboard-shell {
|
|||
user-select: none;
|
||||
color: var(--purple-dim);
|
||||
}
|
||||
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
|
||||
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
|
||||
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
|
||||
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; }
|
||||
.container-row[data-depth="5"] .tree-prefix { left: -9em; }
|
||||
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; }
|
||||
.container-row[data-depth="1"] .tree-prefix {
|
||||
left: -1.8em;
|
||||
}
|
||||
.container-row[data-depth="2"] .tree-prefix {
|
||||
left: -3.6em;
|
||||
}
|
||||
.container-row[data-depth="3"] .tree-prefix {
|
||||
left: -5.4em;
|
||||
}
|
||||
.container-row[data-depth="4"] .tree-prefix {
|
||||
left: -7.2em;
|
||||
}
|
||||
.container-row[data-depth="5"] .tree-prefix {
|
||||
left: -9em;
|
||||
}
|
||||
.container-row[data-depth="6"] .tree-prefix {
|
||||
left: -10.8em;
|
||||
}
|
||||
.tree-prefix .tree-lane {
|
||||
flex: 0 0 1.8em;
|
||||
position: relative;
|
||||
}
|
||||
.tree-prefix .lane-line::before,
|
||||
.tree-prefix .lane-joint::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.6em;
|
||||
top: 0;
|
||||
|
|
@ -225,7 +271,7 @@ body.dashboard-shell {
|
|||
height: 3.1em;
|
||||
}
|
||||
.tree-prefix .lane-joint::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0.6em;
|
||||
top: 3.1em;
|
||||
|
|
@ -246,7 +292,9 @@ body.dashboard-shell {
|
|||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 120ms ease, transform 120ms ease;
|
||||
transition:
|
||||
box-shadow 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
.container-row:not(.tombstone) > .container-icon:hover {
|
||||
box-shadow: 0 0 0 2px var(--purple);
|
||||
|
|
@ -261,7 +309,9 @@ body.dashboard-shell {
|
|||
background: color-mix(in srgb, var(--purple) 6%, transparent);
|
||||
}
|
||||
.container-row.selected > .container-icon {
|
||||
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--purple),
|
||||
0 0 12px -4px var(--purple);
|
||||
}
|
||||
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
|
||||
position: absolute;
|
||||
|
|
@ -352,17 +402,24 @@ hive-agent-menu {
|
|||
driven only now — see swarm.js's pending-badge derivation comment;
|
||||
there is no separate queued-but-not-running row state to tell apart
|
||||
from this one anymore). */
|
||||
.container-row.pending-running .actions { opacity: 0.4; pointer-events: none; }
|
||||
.container-row.pending-running .actions {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
.container-row.pending-running {
|
||||
border-color: var(--amber);
|
||||
background: color-mix(in srgb, var(--amber) 5%, transparent);
|
||||
}
|
||||
@keyframes container-icon-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.container-row.pending-running > .container-icon::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
|
|
@ -382,7 +439,9 @@ hive-agent-menu {
|
|||
font-size: 1.05em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.container-row .head .meta { margin-left: auto; }
|
||||
.container-row .head .meta {
|
||||
margin-left: auto;
|
||||
}
|
||||
.container-row .head .nav-strip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -396,7 +455,9 @@ hive-agent-menu {
|
|||
padding: 0.15em 0.35em;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
transition:
|
||||
background 0.12s ease,
|
||||
color 0.12s ease;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background: color-mix(in srgb, var(--purple) 12%, transparent);
|
||||
|
|
@ -408,7 +469,10 @@ hive-agent-menu {
|
|||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.container-row .actions form.inline { display: inline-block; margin: 0; }
|
||||
.container-row .actions form.inline {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.agent-status {
|
||||
font-size: 0.82em;
|
||||
|
|
@ -433,7 +497,9 @@ hive-agent-menu {
|
|||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.agent-status .status-icon { opacity: 0.65; }
|
||||
.agent-status .status-icon {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.agent-status .status-age {
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.5;
|
||||
|
|
@ -446,7 +512,9 @@ hive-agent-menu {
|
|||
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.container-row.tombstone .name { color: var(--muted); }
|
||||
.container-row.tombstone .name {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* K3PT ST4T3 caveat and the port-collision banner both moved to the
|
||||
shared <hive-warn> component — amber/no-pulse for
|
||||
|
|
@ -497,13 +565,22 @@ hive-agent-menu {
|
|||
animation: badge-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pending approval: a card with three stacked sections — identity
|
||||
header, what-changed body, decision actions. */
|
||||
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
|
||||
.approvals {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.4em 0 0;
|
||||
}
|
||||
.approval-card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -543,7 +620,9 @@ hive-agent-menu {
|
|||
padding-top: 0.45em;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.approval-actions form.inline { display: inline; }
|
||||
.approval-actions form.inline {
|
||||
display: inline;
|
||||
}
|
||||
/* Inline drill-in triggers (logs / config repo / view diff). */
|
||||
.drill-ins {
|
||||
display: flex;
|
||||
|
|
@ -551,7 +630,9 @@ hive-agent-menu {
|
|||
gap: 0.15em 1.1em;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
.drill-ins .panel-trigger { margin-top: 0; }
|
||||
.drill-ins .panel-trigger {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.approval-tabs {
|
||||
display: flex;
|
||||
|
|
@ -567,9 +648,14 @@ hive-agent-menu {
|
|||
letter-spacing: 0.08em;
|
||||
padding: 0.25em 0.9em;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.approval-tab:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.approval-tab:hover { color: var(--fg); }
|
||||
.approval-tab.active {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
|
|
@ -587,18 +673,43 @@ hive-agent-menu {
|
|||
padding: 0.35em 0.8em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
.approvals-history li:has(.glyph-approved) { border-left-color: var(--green); }
|
||||
.approvals-history li:has(.glyph-denied) { border-left-color: var(--red); }
|
||||
.approvals-history li:has(.glyph-failed) { border-left-color: var(--amber); }
|
||||
.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; }
|
||||
.status-approved { color: var(--green); }
|
||||
.status-denied { color: var(--red); }
|
||||
.status-failed { color: var(--amber); }
|
||||
.status-cancelled { color: var(--muted); }
|
||||
.glyph-approved { color: var(--green); }
|
||||
.glyph-denied { color: var(--red); }
|
||||
.glyph-failed { color: var(--amber); }
|
||||
.glyph-cancelled { color: var(--muted); }
|
||||
.approvals-history li:has(.glyph-approved) {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
.approvals-history li:has(.glyph-denied) {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
.approvals-history li:has(.glyph-failed) {
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
.approvals-history .status {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
.status-approved {
|
||||
color: var(--green);
|
||||
}
|
||||
.status-denied {
|
||||
color: var(--red);
|
||||
}
|
||||
.status-failed {
|
||||
color: var(--amber);
|
||||
}
|
||||
.status-cancelled {
|
||||
color: var(--muted);
|
||||
}
|
||||
.glyph-approved {
|
||||
color: var(--green);
|
||||
}
|
||||
.glyph-denied {
|
||||
color: var(--red);
|
||||
}
|
||||
.glyph-failed {
|
||||
color: var(--amber);
|
||||
}
|
||||
.glyph-cancelled {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.history-note {
|
||||
margin-left: 1.8em;
|
||||
|
|
@ -608,9 +719,14 @@ hive-agent-menu {
|
|||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
ul form.inline { display: inline-block; }
|
||||
ul form.inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.role-pending { color: var(--amber); border-color: var(--amber); }
|
||||
.role-pending {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.btn-inline {
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
|
|
@ -619,7 +735,9 @@ ul form.inline { display: inline-block; }
|
|||
}
|
||||
/* Off-palette warm-amber hover tint: not an exact theme var (brighter than
|
||||
--amber), left as a literal pending a dedicated named var. */
|
||||
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
|
||||
.btn-inline:hover {
|
||||
background: rgba(255, 184, 77, 0.1);
|
||||
}
|
||||
.kind {
|
||||
display: inline-block;
|
||||
margin-left: 0.4em;
|
||||
|
|
@ -631,8 +749,13 @@ ul form.inline { display: inline-block; }
|
|||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.kind-spawn { color: var(--amber); border-color: var(--amber); }
|
||||
details { margin-top: 0.5em; }
|
||||
.kind-spawn {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
details {
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
|
|
@ -640,7 +763,9 @@ summary {
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
summary:hover { color: var(--purple); }
|
||||
summary:hover {
|
||||
color: var(--purple);
|
||||
}
|
||||
.diff {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -652,12 +777,25 @@ summary:hover { color: var(--purple); }
|
|||
color: var(--muted);
|
||||
white-space: pre;
|
||||
}
|
||||
.diff span { display: block; }
|
||||
.diff .diff-add { color: var(--green); }
|
||||
.diff .diff-del { color: var(--red); }
|
||||
.diff .diff-hunk { color: var(--cyan); }
|
||||
.diff .diff-file { color: var(--purple); font-weight: bold; }
|
||||
.diff .diff-ctx { color: var(--fg); }
|
||||
.diff span {
|
||||
display: block;
|
||||
}
|
||||
.diff .diff-add {
|
||||
color: var(--green);
|
||||
}
|
||||
.diff .diff-del {
|
||||
color: var(--red);
|
||||
}
|
||||
.diff .diff-hunk {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.diff .diff-file {
|
||||
color: var(--purple);
|
||||
font-weight: bold;
|
||||
}
|
||||
.diff .diff-ctx {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4em;
|
||||
|
|
@ -665,7 +803,9 @@ footer {
|
|||
color: var(--muted);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
footer a { color: var(--purple); }
|
||||
footer a {
|
||||
color: var(--purple);
|
||||
}
|
||||
footer .banner-thin {
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
|
@ -674,8 +814,15 @@ footer .banner-thin {
|
|||
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
|
||||
as the tool-groups table below. Horizontally scrollable on narrow
|
||||
viewports. */
|
||||
.cap-table-wrap { overflow-x: auto; margin-top: 0.5em; }
|
||||
.cap-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
|
||||
.cap-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.cap-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82em;
|
||||
min-width: 100%;
|
||||
}
|
||||
.cap-table th,
|
||||
.cap-table td {
|
||||
padding: 0.35em 0.6em;
|
||||
|
|
@ -714,16 +861,48 @@ footer .banner-thin {
|
|||
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
|
||||
as the capabilities table above. Horizontally scrollable on narrow
|
||||
viewports. */
|
||||
.tg-table-wrap { overflow-x: auto; margin-top: 0.5em; }
|
||||
.tg-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
|
||||
.tg-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.tg-table {
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82em;
|
||||
min-width: 100%;
|
||||
}
|
||||
.tg-table th,
|
||||
.tg-table td { padding: 0.35em 0.6em; border: 1px solid var(--border); text-align: center; vertical-align: middle; }
|
||||
.tg-table thead th { background: var(--bg-elev); color: var(--muted); letter-spacing: 0.05em; white-space: nowrap; }
|
||||
.tg-agent-col { text-align: left !important; min-width: 8em; }
|
||||
.tg-group-col { min-width: 5em; }
|
||||
.tg-agent-name { color: var(--fg); font-weight: 600; }
|
||||
.tg-cb { cursor: pointer; width: 1em; height: 1em; accent-color: var(--purple); }
|
||||
.tg-row:hover td { background: var(--bg-elev); }
|
||||
.tg-table td {
|
||||
padding: 0.35em 0.6em;
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.tg-table thead th {
|
||||
background: var(--bg-elev);
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tg-agent-col {
|
||||
text-align: left !important;
|
||||
min-width: 8em;
|
||||
}
|
||||
.tg-group-col {
|
||||
min-width: 5em;
|
||||
}
|
||||
.tg-agent-name {
|
||||
color: var(--fg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tg-cb {
|
||||
cursor: pointer;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
accent-color: var(--purple);
|
||||
}
|
||||
.tg-row:hover td {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
|
||||
/* ─── permissions save bar (save-all) ──────────────────────────────
|
||||
One page-level save button for the whole P3RM1SS10NS tab. Sticks to
|
||||
|
|
@ -747,10 +926,14 @@ footer .banner-thin {
|
|||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.perm-save-err { color: var(--red); }
|
||||
.perm-save-err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* Stale permission entry (agent not in the live roster) */
|
||||
.perm-row-stale td { opacity: 0.7; }
|
||||
.perm-row-stale td {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.perm-stale-label {
|
||||
font-size: 0.75em;
|
||||
color: var(--muted);
|
||||
|
|
@ -768,8 +951,13 @@ footer .banner-thin {
|
|||
font-family: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.perm-remove-btn:hover { opacity: 1; }
|
||||
.perm-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.perm-remove-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.perm-remove-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ─── scheduled prompts tab ────────────────────────────────────────
|
||||
Creation form at the top, list of queued schedule cards below. */
|
||||
|
|
@ -816,7 +1004,10 @@ footer .banner-thin {
|
|||
border-radius: 3px;
|
||||
padding: 0.35em 0.5em;
|
||||
}
|
||||
.schedule-field textarea { resize: vertical; min-height: 4em; }
|
||||
.schedule-field textarea {
|
||||
resize: vertical;
|
||||
min-height: 4em;
|
||||
}
|
||||
.schedule-targets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
@ -833,13 +1024,17 @@ footer .banner-thin {
|
|||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
|
||||
}
|
||||
.schedule-target-chip:hover { border-color: var(--purple-dim); }
|
||||
.schedule-target-chip:hover {
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
.schedule-target-chip:has(input:checked) {
|
||||
border-color: var(--purple);
|
||||
color: var(--purple);
|
||||
background: color-mix(in srgb, var(--purple) 8%, transparent);
|
||||
}
|
||||
.schedule-target-chip input { margin: 0; }
|
||||
.schedule-target-chip input {
|
||||
margin: 0;
|
||||
}
|
||||
.schedule-actions {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
|
|
@ -886,9 +1081,15 @@ footer .banner-thin {
|
|||
font-size: 0.9em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
|
||||
.schedule-interval-preview-oneshot {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.schedules-table-wrap { overflow-x: auto; margin-top: 0.5em; }
|
||||
.schedules-table-wrap {
|
||||
overflow-x: auto;
|
||||
margin-top: 0.5em;
|
||||
}
|
||||
.schedules-table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
|
@ -908,13 +1109,26 @@ footer .banner-thin {
|
|||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-id { width: 3em; text-align: center; }
|
||||
.schedules-table-id {
|
||||
width: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
/* next / every columns: sized to typical content (short durations).
|
||||
Previous 8em / 7em wasted horizontal space. */
|
||||
.schedules-table-next-col { width: 5.5em; white-space: nowrap; }
|
||||
.schedules-table-every-col { width: 5em; white-space: nowrap; }
|
||||
.schedules-table-body-th { min-width: 12em; }
|
||||
.schedules-table-actions-th { width: 7em; }
|
||||
.schedules-table-next-col {
|
||||
width: 5.5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-every-col {
|
||||
width: 5em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.schedules-table-body-th {
|
||||
min-width: 12em;
|
||||
}
|
||||
.schedules-table-actions-th {
|
||||
width: 7em;
|
||||
}
|
||||
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
|
||||
readable without truncation. Previously used a -45° CSS transform which
|
||||
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
|
||||
|
|
@ -930,7 +1144,7 @@ footer .banner-thin {
|
|||
overflow: hidden;
|
||||
}
|
||||
.schedules-table-agent-th > div {
|
||||
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--fg);
|
||||
/* Rotate 90° bottom-to-top (conventional rotated column header). */
|
||||
|
|
@ -953,17 +1167,28 @@ footer .banner-thin {
|
|||
text-decoration: line-through;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.schedules-table-row-cancelled td { opacity: 0.55; }
|
||||
.schedules-table-row-paused td { opacity: 0.75; }
|
||||
.sched-paused-label { color: var(--yellow); font-size: 0.9em; }
|
||||
.btn-pause-schedule { color: var(--teal); }
|
||||
.btn-resume-schedule { color: var(--green); }
|
||||
.schedules-table-row-cancelled td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.schedules-table-row-paused td {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.sched-paused-label {
|
||||
color: var(--yellow);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.btn-pause-schedule {
|
||||
color: var(--teal);
|
||||
}
|
||||
.btn-resume-schedule {
|
||||
color: var(--green);
|
||||
}
|
||||
.schedules-table-body-cell {
|
||||
max-width: 30em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.schedules-table-check {
|
||||
|
|
@ -1019,7 +1244,7 @@ footer .banner-thin {
|
|||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||
font-family: ui-monospace, "JetBrains Mono", monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 2px;
|
||||
|
|
@ -1111,13 +1336,18 @@ footer .banner-thin {
|
|||
flex-wrap: wrap;
|
||||
gap: 0.6em;
|
||||
padding: 0.55em 1em;
|
||||
background: var(--flow-frost-bg, color-mix(in srgb, var(--bg) 74%, transparent));
|
||||
background: var(
|
||||
--flow-frost-bg,
|
||||
color-mix(in srgb, var(--bg) 74%, transparent)
|
||||
);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(140%);
|
||||
backdrop-filter: blur(12px) saturate(140%);
|
||||
border-top: 1px solid var(--purple);
|
||||
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.selection-bar[hidden] { display: none; }
|
||||
.selection-bar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.selection-count {
|
||||
color: var(--purple);
|
||||
font-weight: bold;
|
||||
|
|
@ -1150,7 +1380,9 @@ footer .banner-thin {
|
|||
}
|
||||
/* Pad the dashboard body so the sticky bar doesn't cover the
|
||||
bottom of the agent list. Only applies when bar is visible. */
|
||||
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
||||
body.dashboard-shell.has-selection {
|
||||
padding-bottom: 4.5em;
|
||||
}
|
||||
|
||||
.move-picker {
|
||||
display: inline-flex;
|
||||
|
|
@ -1178,4 +1410,3 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
|
|||
shared `.hive-stats-table` moved to common.css (the SYST3M ›
|
||||
C0NT41N3R L04D table in system-sections.css / on /core.html still
|
||||
uses it). */
|
||||
|
||||
|
|
|
|||
|
|
@ -1,199 +1,291 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // h1ve-c0re</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/dashboard.css">
|
||||
</head>
|
||||
<body class="dashboard-shell">
|
||||
|
||||
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // h1ve-c0re</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/dashboard.css" />
|
||||
</head>
|
||||
<body class="dashboard-shell">
|
||||
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
|
||||
slug lives at the page footer below `<main>`; chrome is
|
||||
navigation only. Tabs route via the URL hash so F5 / back-
|
||||
button / shared links keep you on the same view. JS owns
|
||||
the show/hide. -->
|
||||
<header class="dashboard-chrome">
|
||||
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
|
||||
<header class="dashboard-chrome">
|
||||
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
|
||||
dashboard all link back to the landing page rather than to each
|
||||
other — H0M3 is the single navigation hub. -->
|
||||
<a class="dash-home-back" href="/" title="back to the H0M3 menu">← home</a>
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
|
||||
aria-controls="tab-pane-swarm"
|
||||
data-tab="swarm">
|
||||
<span class="tab-label">◆ SW4RM ◆</span>
|
||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||
</a>
|
||||
<a class="tab" id="tab-call" href="#call" role="tab"
|
||||
aria-controls="tab-pane-call"
|
||||
data-tab="call">
|
||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
|
||||
</a>
|
||||
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
||||
<a class="dash-home-back" href="/" title="back to the H0M3 menu"
|
||||
>← home</a
|
||||
>
|
||||
<nav class="tabbar" id="tabbar" role="tablist">
|
||||
<a
|
||||
class="tab"
|
||||
id="tab-swarm"
|
||||
href="#swarm"
|
||||
role="tab"
|
||||
aria-controls="tab-pane-swarm"
|
||||
data-tab="swarm"
|
||||
>
|
||||
<span class="tab-label">◆ SW4RM ◆</span>
|
||||
<span class="tab-count" id="tab-count-swarm" hidden></span>
|
||||
</a>
|
||||
<a
|
||||
class="tab"
|
||||
id="tab-call"
|
||||
href="#call"
|
||||
role="tab"
|
||||
aria-controls="tab-pane-call"
|
||||
data-tab="call"
|
||||
>
|
||||
<span class="tab-label">◆ Y3R C4LL ◆</span>
|
||||
<span
|
||||
class="tab-count tab-count-attn"
|
||||
id="tab-count-call"
|
||||
hidden
|
||||
></span>
|
||||
</a>
|
||||
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
|
||||
reached from the H0M3 hub — keeps the dashboard tab strip lean.
|
||||
The rebuild-queue STATE still lives in tabs.js (it drives the
|
||||
"building…" badges on SW4RM agent cards); only the SYST3M tab +
|
||||
its section renderers moved out. -->
|
||||
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
|
||||
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
|
||||
assignments. Both tables are fetched on tab activation. -->
|
||||
<a class="tab" id="tab-permissions" href="#permissions" role="tab"
|
||||
aria-controls="tab-pane-permissions"
|
||||
data-tab="permissions">
|
||||
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
||||
</a>
|
||||
<a
|
||||
class="tab"
|
||||
id="tab-permissions"
|
||||
href="#permissions"
|
||||
role="tab"
|
||||
aria-controls="tab-pane-permissions"
|
||||
data-tab="permissions"
|
||||
>
|
||||
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
|
||||
</a>
|
||||
|
||||
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
||||
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
|
||||
schedules + an operator-direct creation form. Count pill
|
||||
mirrors the active (non-cancelled) schedule count; hidden
|
||||
when zero. -->
|
||||
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules">
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
<a
|
||||
class="tab"
|
||||
id="tab-schedules"
|
||||
href="#schedules"
|
||||
role="tab"
|
||||
aria-controls="tab-pane-schedules"
|
||||
data-tab="schedules"
|
||||
>
|
||||
<span class="tab-label">◆ SCH3DUL3S ◆</span>
|
||||
<span class="tab-count" id="tab-count-schedules" hidden></span>
|
||||
</a>
|
||||
|
||||
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
|
||||
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
|
||||
the H0M3 hub — the hive-wide rollup is a standalone read-only
|
||||
view, not part of the operational tab strip. -->
|
||||
|
||||
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||
<!-- (Peer hives are no longer a tab — they render as a headline
|
||||
under SW4RM. See tab-pane-swarm.) -->
|
||||
|
||||
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
|
||||
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
|
||||
H0M3 hub (served at /), not from the dashboard tab strip — the
|
||||
strip holds only real in-page tabs now. Contextual deep-links
|
||||
into the log viewer (an agent's logs, a build entry's log)
|
||||
still live inside the relevant dashboard content. -->
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
||||
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
|
||||
visible by toggling the `hidden` attribute, resolved from the URL
|
||||
hash (default SW4RM). Panes start `hidden` to avoid a flash before
|
||||
the script runs. -->
|
||||
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||
(.page-content, common.css) while <body> stays full-bleed so the
|
||||
sticky chrome above + the footer below span the full width. -->
|
||||
<div class="page-content">
|
||||
<main class="dashboard-main">
|
||||
|
||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||
<div class="page-content">
|
||||
<main class="dashboard-main">
|
||||
<!-- SW4RM: the swarm itself. Container cards (the central thing
|
||||
the operator looks at) and rebuild queue / cascade visualisation
|
||||
that drives them. No inline `C0NTAINERS` h2 heading + divider
|
||||
— the tab label SW4RM already says it. -->
|
||||
<section class="tab-pane" id="tab-pane-swarm" data-tab-pane="swarm" hidden
|
||||
role="tabpanel" aria-labelledby="tab-swarm">
|
||||
<!-- Swarm / hive identity headline. Populated by refreshState from
|
||||
<section
|
||||
class="tab-pane"
|
||||
id="tab-pane-swarm"
|
||||
data-tab-pane="swarm"
|
||||
hidden
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-swarm"
|
||||
>
|
||||
<!-- Swarm / hive identity headline. Populated by refreshState from
|
||||
hive_name + swarm_name; stays hidden when neither is set. -->
|
||||
<h2 id="swarm-identity" hidden></h2>
|
||||
<!-- JobqRollup mount point, kept outside #containers-section
|
||||
<h2 id="swarm-identity" hidden></h2>
|
||||
<!-- JobqRollup mount point, kept outside #containers-section
|
||||
deliberately — that section is wiped + rebuilt on every
|
||||
container-list render (see swarm.js::renderContainers), which
|
||||
would tear down and remount the Preact tree on every
|
||||
container-state tick. Mounted once by swarm.js::initJobqRollup,
|
||||
refreshed via its own handle rather than by re-rendering. -->
|
||||
<div id="jobq-rollup-section"></div>
|
||||
<div id="containers-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
<div id="jobq-rollup-section"></div>
|
||||
<div id="containers-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Y3R C4LL: things blocked on operator decision — the approval
|
||||
<!-- Y3R C4LL: things blocked on operator decision — the approval
|
||||
queue; surfaces full bodies inline so the operator can decide
|
||||
without leaving the pane. -->
|
||||
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
|
||||
role="tabpanel" aria-labelledby="tab-call">
|
||||
<!-- 1NB0X: unread agent→operator messages. Fetched on
|
||||
<section
|
||||
class="tab-pane"
|
||||
id="tab-pane-call"
|
||||
data-tab-pane="call"
|
||||
hidden
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-call"
|
||||
>
|
||||
<!-- 1NB0X: unread agent→operator messages. Fetched on
|
||||
tab activation + cold load, appended live from the broker
|
||||
stream, cleared via "mark all read". Count folds into the
|
||||
Y3R C4LL pill so messages aren't missed. -->
|
||||
<h2>◆ 1NB0X ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="operator-inbox-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<h2>◆ 1NB0X ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<div id="operator-inbox-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<div id="approvals-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<h2>◆ P3NDING APPR0VALS ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<div id="approvals-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Operator-local preferences (browser notifications). Was its own
|
||||
<!-- Operator-local preferences (browser notifications). Was its own
|
||||
S3TT1NGS page (`/settings.html`); mara moved it in here since a
|
||||
standalone page for one toggle didn't earn its own tile. State
|
||||
lives in the browser's localStorage — NOTIF.bind() (common.js)
|
||||
wires the buttons, same dispatch NOTIF.show() already used for
|
||||
approval/inbox notifications above. -->
|
||||
<h2>◆ PR3F3R3NC3S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p>
|
||||
<h3>◇ browser notifications</h3>
|
||||
<p class="meta">desktop notifications for new approvals and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
|
||||
<div id="notif-row" class="notif-row">
|
||||
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
|
||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
|
||||
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
|
||||
<span id="notif-status" class="meta" hidden></span>
|
||||
</div>
|
||||
</section>
|
||||
<h2>◆ PR3F3R3NC3S ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p class="meta">
|
||||
operator-local preferences. these live in the browser's localStorage
|
||||
— they do not sync between devices and do not survive a profile
|
||||
wipe.
|
||||
</p>
|
||||
<h3>◇ browser notifications</h3>
|
||||
<p class="meta">
|
||||
desktop notifications for new approvals and broker messages
|
||||
addressed to you. requires a secure context (https or localhost).
|
||||
mute silences the notifications without revoking the OS-level
|
||||
permission.
|
||||
</p>
|
||||
<div id="notif-row" class="notif-row">
|
||||
<button
|
||||
type="button"
|
||||
id="notif-enable"
|
||||
class="btn btn-notif"
|
||||
hidden
|
||||
>
|
||||
🔔 enable notifications
|
||||
</button>
|
||||
<button type="button" id="notif-mute" class="btn btn-notif" hidden>
|
||||
🔕 mute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="notif-unmute"
|
||||
class="btn btn-notif"
|
||||
hidden
|
||||
>
|
||||
🔔 unmute
|
||||
</button>
|
||||
<span id="notif-status" class="meta" hidden></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
|
||||
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
|
||||
meta inputs, rebuild queue, kept state, container load. Reached
|
||||
from the H0M3 hub. -->
|
||||
|
||||
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
|
||||
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
|
||||
assignments. Both tables are column-driven from the backend
|
||||
(GET /api/capabilities, GET /api/tool-groups) so new entries
|
||||
require no UI change. Edits stage in-browser across both
|
||||
matrices; the single page-level "save all" button POSTs every
|
||||
changed agent to /api/permissions in one atomic batch (caps +
|
||||
groups coalesce into one rebuild per agent). -->
|
||||
<section class="tab-pane" id="tab-pane-permissions" data-tab-pane="permissions" hidden
|
||||
role="tabpanel" aria-labelledby="tab-permissions">
|
||||
<h2>◆ C4P4B1L1T13S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">per-agent capability grants. capabilities unlock gated MCP tools and system access. toggle any cells across both tables, then hit "save all" at the bottom — each changed agent rebuilds exactly once.</p>
|
||||
<div id="capabilities-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<section
|
||||
class="tab-pane"
|
||||
id="tab-pane-permissions"
|
||||
data-tab-pane="permissions"
|
||||
hidden
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-permissions"
|
||||
>
|
||||
<h2>◆ C4P4B1L1T13S ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p class="meta">
|
||||
per-agent capability grants. capabilities unlock gated MCP tools and
|
||||
system access. toggle any cells across both tables, then hit "save
|
||||
all" at the bottom — each changed agent rebuilds exactly once.
|
||||
</p>
|
||||
<div id="capabilities-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
|
||||
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
|
||||
cols = tool groups fetched from GET /api/tool-groups.
|
||||
Checking / unchecking is staged in the UI; the single page-level
|
||||
"save all" button POSTs every changed agent to
|
||||
/api/permissions in one atomic batch, coalescing caps + groups
|
||||
per agent into one rebuild each.
|
||||
Absent agents default to the role default (shown in parens). -->
|
||||
<h2>◆ T00L GR0UPS ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">per-agent tool-group permissions. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution).</p>
|
||||
<div id="tool-groups-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<h2>◆ T00L GR0UPS ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p class="meta">
|
||||
per-agent tool-group permissions. columns are filled from the
|
||||
backend — adding a new group requires no UI change. agents without
|
||||
an explicit entry use the role default (agents: messaging, meta,
|
||||
inbox, execution).
|
||||
</p>
|
||||
<div id="tool-groups-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
|
||||
<!-- Page-level save bar (save-all issue): one button for the whole
|
||||
<!-- Page-level save bar (save-all issue): one button for the whole
|
||||
permissions page. Staged checkbox edits across BOTH matrices are
|
||||
diffed against their render-time baselines and POSTed together to
|
||||
/api/permissions; caps + groups for one agent coalesce into a
|
||||
single rebuild. Disabled until something is dirty. The atomic
|
||||
batch either fully lands (queued ✓ → rebuilding) or fully fails
|
||||
(error note, nothing applied). -->
|
||||
<div class="perm-save-bar">
|
||||
<button type="button" id="perm-save-all" class="btn btn-approve" disabled>save all</button>
|
||||
<span id="perm-save-note" class="meta"></span>
|
||||
</div>
|
||||
</section>
|
||||
<div class="perm-save-bar">
|
||||
<button
|
||||
type="button"
|
||||
id="perm-save-all"
|
||||
class="btn btn-approve"
|
||||
disabled
|
||||
>
|
||||
save all
|
||||
</button>
|
||||
<span id="perm-save-note" class="meta"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
|
||||
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
|
||||
into the same table — empty bottom row is the create form
|
||||
(fill cells, click +), inline-edit-row expands on the `✎`
|
||||
toggle for existing schedules. Schedules list driven by
|
||||
|
|
@ -203,60 +295,91 @@
|
|||
`schedules_changed` SSE; tab activation re-fetches as a
|
||||
safety net for disconnect windows.
|
||||
See docs/web-ui.md::SCH3DUL3S tab. -->
|
||||
<section class="tab-pane" id="tab-pane-schedules" data-tab-pane="schedules" hidden
|
||||
role="tabpanel" aria-labelledby="tab-schedules">
|
||||
<h2>◆ SCH3DUL3S ◆</h2>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
<section
|
||||
class="tab-pane"
|
||||
id="tab-pane-schedules"
|
||||
data-tab-pane="schedules"
|
||||
hidden
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-schedules"
|
||||
>
|
||||
<h2>◆ SCH3DUL3S ◆</h2>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p class="meta">
|
||||
all schedules currently in the table. fill the bottom row to queue a
|
||||
new schedule (operator-direct, no approval gate; recurring when an
|
||||
interval is set). cancel a single target with the row button or the
|
||||
whole schedule with <code>✕ cancel all</code>.
|
||||
</p>
|
||||
<div id="schedules-section">
|
||||
<p class="meta">loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
|
||||
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
|
||||
(`/stats.html`), reached from the H0M3 hub. The markup + the
|
||||
hive-stats render JS moved there; when tabs.js boots on the
|
||||
dashboard the renderers are simply gone (no stats tab to
|
||||
activate). -->
|
||||
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
<!-- FL0W: lives on its own page now (`/flow.html`). The
|
||||
message-flow + inbox + compose DOM only exists there — when
|
||||
tabs.js boots on this page the corresponding renderers
|
||||
no-op silently (each guard is `if (!el) return`). -->
|
||||
</main>
|
||||
</div>
|
||||
<!-- /.page-content -->
|
||||
|
||||
</main>
|
||||
</div><!-- /.page-content -->
|
||||
<footer>
|
||||
<pre class="banner banner-thin">
|
||||
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
|
||||
>
|
||||
<div class="divider">
|
||||
══════════════════════════════════════════════════════════════
|
||||
</div>
|
||||
<p>
|
||||
▲△▲
|
||||
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
|
||||
▲△▲ hive-c0re on this host ▲△▲
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
<footer>
|
||||
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
|
||||
<div class="divider">══════════════════════════════════════════════════════════════</div>
|
||||
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
|
||||
</footer>
|
||||
|
||||
<!-- Slide-in detail panel (clicked file previews, approval diffs,
|
||||
<!-- Slide-in detail panel (clicked file previews, approval diffs,
|
||||
journald logs, applied config) is a <hive-side-panel> element
|
||||
(@hive/shared/side-panel.js) — the Panel singleton in common.js
|
||||
creates + appends it to <body> lazily on first use, so nothing
|
||||
needs to be pre-declared here. -->
|
||||
|
||||
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||
<!-- Selection action bar. Sticky-bottom strip that slides into
|
||||
view when one or more agent cards is selected (click the icon
|
||||
to toggle). See docs/web-ui.md::Selection bar for the bulk
|
||||
action gating + clear semantics. -->
|
||||
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
|
||||
aria-label="bulk agent actions">
|
||||
<span class="selection-count" id="selection-count"></span>
|
||||
<span class="selection-names" id="selection-names"></span>
|
||||
<span class="selection-actions" id="selection-actions"></span>
|
||||
<button type="button" class="btn selection-clear" id="selection-clear"
|
||||
title="clear selection (esc)">✕ clear</button>
|
||||
</div>
|
||||
<div
|
||||
id="selection-bar"
|
||||
class="selection-bar"
|
||||
hidden
|
||||
role="toolbar"
|
||||
aria-label="bulk agent actions"
|
||||
>
|
||||
<span class="selection-count" id="selection-count"></span>
|
||||
<span class="selection-names" id="selection-names"></span>
|
||||
<span class="selection-actions" id="selection-actions"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn selection-clear"
|
||||
id="selection-clear"
|
||||
title="clear selection (esc)"
|
||||
>
|
||||
✕ clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
|
||||
flow.html has its own flow.js bundle. esbuild folds
|
||||
@hive/shared/terminal.js and the marked npm package into
|
||||
tabs.js. -->
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/tabs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ body.flow-shell {
|
|||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
|
||||
transparent 60%),
|
||||
radial-gradient(
|
||||
ellipse 80% 60% at 50% 0%,
|
||||
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
|
||||
transparent 60%
|
||||
),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
|
|
@ -101,5 +103,9 @@ body.flow-shell .flow-main-slim {
|
|||
padding: 0.25em 0.5em;
|
||||
margin-left: auto;
|
||||
}
|
||||
.flow-filter:focus { outline: 1px solid var(--purple); }
|
||||
.msgrow.flow-hidden { display: none; }
|
||||
.flow-filter:focus {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.msgrow.flow-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +1,64 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // FL0W</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/flow.css">
|
||||
</head>
|
||||
<body class="flow-shell">
|
||||
|
||||
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // FL0W</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/flow.css" />
|
||||
</head>
|
||||
<body class="flow-shell">
|
||||
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
|
||||
No full tabbar — the flow page is a dedicated full-viewport
|
||||
terminal surface; navigating back to the menu is the only chrome
|
||||
needed. Pages link back to H0M3, not the dashboard. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">FL0W</span>
|
||||
<select id="flow-agent-filter" class="flow-filter" title="filter timeline by agent" aria-label="filter timeline by agent">
|
||||
<option value="">all agents</option>
|
||||
</select>
|
||||
</header>
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">FL0W</span>
|
||||
<select
|
||||
id="flow-agent-filter"
|
||||
class="flow-filter"
|
||||
title="filter timeline by agent"
|
||||
aria-label="filter timeline by agent"
|
||||
>
|
||||
<option value="">all agents</option>
|
||||
</select>
|
||||
</header>
|
||||
|
||||
<!-- Main content: the full-viewport terminal. Padded for the
|
||||
<!-- Main content: the full-viewport terminal. Padded for the
|
||||
overlay header + composer so the first/last rows stay
|
||||
reachable. -->
|
||||
<main class="flow-main flow-main-slim">
|
||||
<div class="terminal-wrap">
|
||||
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="flow-main flow-main-slim">
|
||||
<div class="terminal-wrap">
|
||||
<div id="msgflow" class="live terminal">
|
||||
<div class="meta">connecting…</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
|
||||
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
|
||||
as the header — symmetric framing, terminal goes edge-to-edge
|
||||
between them. -->
|
||||
<footer class="flow-composer">
|
||||
<div id="op-compose" class="op-compose">
|
||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||
<textarea id="op-compose-input" class="op-compose-input"
|
||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||
rows="1" autocomplete="off"></textarea>
|
||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||
</div>
|
||||
</footer>
|
||||
<footer class="flow-composer">
|
||||
<div id="op-compose" class="op-compose">
|
||||
<span id="op-compose-prompt" class="op-compose-prompt">@—></span>
|
||||
<textarea
|
||||
id="op-compose-input"
|
||||
class="op-compose-input"
|
||||
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
|
||||
rows="1"
|
||||
autocomplete="off"
|
||||
></textarea>
|
||||
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Flow-specific bundle. Contains the broker terminal init + the
|
||||
<!-- Flow-specific bundle. Contains the broker terminal init + the
|
||||
@-mention composer. Tab renderers etc. live in
|
||||
`/static/tabs.js` which /flow.html doesn't load. -->
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/flow.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -11,15 +11,16 @@
|
|||
// initial /api/state fetch (compose autocomplete needs the live
|
||||
// container list).
|
||||
|
||||
import { create as termCreate } from '@hive/shared/terminal.js';
|
||||
import { create as termCreate } from "@hive/shared/terminal.js";
|
||||
import {
|
||||
$,
|
||||
NOTIF,
|
||||
appendLinkified,
|
||||
openStream, initServerWarnings,
|
||||
} from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { epochSec } from './util.js';
|
||||
openStream,
|
||||
initServerWarnings,
|
||||
} from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { epochSec } from "./util.js";
|
||||
|
||||
(() => {
|
||||
NOTIF.bind();
|
||||
|
|
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
|
|||
// by the same `container_state_changed` / `container_removed` events
|
||||
// the dashboard would handle.
|
||||
const flowContainers = new Map();
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
populateAgentFilter();
|
||||
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
populateAgentFilter();
|
||||
})
|
||||
.catch(() => {
|
||||
/* graceful: compose just shows `*` and nothing else */
|
||||
});
|
||||
|
||||
// ─── agent filter ───────────────────────────────────────────────
|
||||
// A select in the FL0W header narrows the timeline to messages involving
|
||||
|
|
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
|
|||
// filter re-scans existing rows. Selection persists in localStorage so a
|
||||
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
|
||||
// so it works regardless of the message-flow IIFE's local scope.
|
||||
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
|
||||
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
|
||||
function rowMatchesFilter(from, to) {
|
||||
return !agentFilter || from === agentFilter || to === agentFilter;
|
||||
}
|
||||
function applyAgentFilter() {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
for (const row of flow.children) {
|
||||
const { from, to } = row.dataset;
|
||||
if (from === undefined && to === undefined) continue; // non-message row
|
||||
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
|
||||
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
|
||||
}
|
||||
}
|
||||
function populateAgentFilter() {
|
||||
const sel = $('flow-agent-filter');
|
||||
const sel = $("flow-agent-filter");
|
||||
if (!sel) return;
|
||||
const names = [...flowContainers.keys()].sort();
|
||||
sel.replaceChildren();
|
||||
sel.append(el('option', { value: '' }, 'all agents'));
|
||||
for (const n of names) sel.append(el('option', { value: n }, n));
|
||||
sel.append(el("option", { value: "" }, "all agents"));
|
||||
for (const n of names) sel.append(el("option", { value: n }, n));
|
||||
// Preserve a saved selection even if that agent isn't in the live
|
||||
// container list (yet / anymore) so the filter doesn't silently reset.
|
||||
if (agentFilter && !names.includes(agentFilter)) {
|
||||
sel.append(el('option', { value: agentFilter }, agentFilter));
|
||||
sel.append(el("option", { value: agentFilter }, agentFilter));
|
||||
}
|
||||
sel.value = agentFilter;
|
||||
}
|
||||
{
|
||||
const sel = $('flow-agent-filter');
|
||||
const sel = $("flow-agent-filter");
|
||||
if (sel) {
|
||||
sel.addEventListener('change', () => {
|
||||
sel.addEventListener("change", () => {
|
||||
agentFilter = sel.value;
|
||||
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
|
||||
else localStorage.removeItem('flow-agent-filter');
|
||||
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
|
||||
else localStorage.removeItem("flow-agent-filter");
|
||||
applyAgentFilter();
|
||||
});
|
||||
}
|
||||
|
|
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
|
|||
// side effects (banner pulse, OS notifications on operator-bound
|
||||
// traffic).
|
||||
(() => {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
flow.replaceChildren();
|
||||
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
|
||||
|
|
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
|
|||
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
|
||||
// there's no element to find. Kept for parity if a future chrome
|
||||
// change reintroduces a banner.
|
||||
const banner = document.querySelector('.banner');
|
||||
const banner = document.querySelector(".banner");
|
||||
let bannerOffTimer = null;
|
||||
function pulseBanner() {
|
||||
if (!banner) return;
|
||||
banner.classList.add('active');
|
||||
banner.classList.add("active");
|
||||
if (bannerOffTimer) clearTimeout(bannerOffTimer);
|
||||
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
|
||||
bannerOffTimer = setTimeout(
|
||||
() => banner.classList.remove("active"),
|
||||
4000,
|
||||
);
|
||||
}
|
||||
// Map of broker row id → rendered row element. Lets reply rows add
|
||||
// a visual "↳ in reply to" indicator that links back to the parent.
|
||||
|
|
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
|
|||
|
||||
function renderMsg(ev, api, glyph) {
|
||||
const isReply = ev.in_reply_to != null;
|
||||
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
|
||||
const row = api.row(cls, '');
|
||||
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
|
||||
const row = api.row(cls, "");
|
||||
// Build via DOM so path anchors stay live + escape rules are
|
||||
// automatic (text nodes don't need esc()).
|
||||
const ts = document.createElement('span');
|
||||
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
|
||||
const from = document.createElement('span');
|
||||
from.className = 'msg-from'; from.textContent = ev.from;
|
||||
const sep = document.createElement('span');
|
||||
sep.className = 'msg-sep'; sep.textContent = '→';
|
||||
const to = document.createElement('span');
|
||||
to.className = 'msg-to'; to.textContent = ev.to;
|
||||
const body = document.createElement('span');
|
||||
body.className = 'msg-body';
|
||||
const ts = document.createElement("span");
|
||||
ts.className = "msg-ts";
|
||||
ts.textContent = tsFmt(ev.at);
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "msg-arrow";
|
||||
arrow.textContent = glyph;
|
||||
const from = document.createElement("span");
|
||||
from.className = "msg-from";
|
||||
from.textContent = ev.from;
|
||||
const sep = document.createElement("span");
|
||||
sep.className = "msg-sep";
|
||||
sep.textContent = "→";
|
||||
const to = document.createElement("span");
|
||||
to.className = "msg-to";
|
||||
to.textContent = ev.to;
|
||||
const body = document.createElement("span");
|
||||
body.className = "msg-body";
|
||||
appendLinkified(body, ev.body, ev.file_refs);
|
||||
// Reply thread indicator: a small "↳ reply to <from>" hint that
|
||||
// shows which message this is responding to. If we have the parent
|
||||
// in our row map, clicking scrolls it into view.
|
||||
if (isReply) {
|
||||
const replyTag = document.createElement('span');
|
||||
replyTag.className = 'msg-reply-tag';
|
||||
const replyTag = document.createElement("span");
|
||||
replyTag.className = "msg-reply-tag";
|
||||
const parentRow = msgRowMap.get(ev.in_reply_to);
|
||||
if (parentRow) {
|
||||
const link = document.createElement('a');
|
||||
link.href = '#';
|
||||
link.textContent = '↳ reply';
|
||||
link.title = 'scroll to parent message';
|
||||
link.addEventListener('click', (e) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = "#";
|
||||
link.textContent = "↳ reply";
|
||||
link.title = "scroll to parent message";
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
parentRow.classList.add('msg-highlight');
|
||||
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
|
||||
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
parentRow.classList.add("msg-highlight");
|
||||
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
|
||||
});
|
||||
replyTag.append(link);
|
||||
} else {
|
||||
replyTag.textContent = '↳ reply';
|
||||
replyTag.textContent = "↳ reply";
|
||||
}
|
||||
row.prepend(replyTag);
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
} else {
|
||||
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
|
||||
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
|
||||
}
|
||||
// Tag with the participants so the agent filter can match
|
||||
// on `from`/`to`, and hide the row up-front if a filter is active.
|
||||
row.dataset.from = ev.from;
|
||||
row.dataset.to = ev.to;
|
||||
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
|
||||
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
|
||||
// Register this row so future replies can reference it.
|
||||
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
|
||||
return row;
|
||||
|
|
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
|
|||
if (ev.id == null || ev.id <= 0) return false;
|
||||
const s = recentSent.get(ev.id);
|
||||
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
|
||||
const arrow = s.row.querySelector('.msg-arrow');
|
||||
if (arrow) arrow.textContent = '✓';
|
||||
const arrow = s.row.querySelector(".msg-arrow");
|
||||
if (arrow) arrow.textContent = "✓";
|
||||
// Re-style the row as delivered (green ✓) — the collapsed line now
|
||||
// represents the delivered state; it was sent + delivered as one.
|
||||
s.row.classList.remove('sent');
|
||||
s.row.classList.add('delivered');
|
||||
s.row.title = 'sent + delivered';
|
||||
s.row.classList.remove("sent");
|
||||
s.row.classList.add("delivered");
|
||||
s.row.title = "sent + delivered";
|
||||
recentSent.delete(ev.id);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
|
|||
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
|
||||
// page (Terminal-wrap) for the backdrop-filter stacking-context
|
||||
// gotcha (same shape on the flow page).
|
||||
const flowMain = document.querySelector('.flow-main');
|
||||
const flowMain = document.querySelector(".flow-main");
|
||||
termCreate({
|
||||
logEl: flow,
|
||||
pillAnchor: flowMain,
|
||||
historyUrl: '/api/dashboard/history',
|
||||
historyUrl: "/api/dashboard/history",
|
||||
// Server-side filter — only the kinds this page actually renders
|
||||
// or routes (sent/delivered → broker terminal,
|
||||
// container_state_changed/_removed → local autocomplete cache).
|
||||
|
|
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
|
|||
// JSON-serialise is skipped entirely on irrelevant kinds. The
|
||||
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
|
||||
// since it routes every mutation kind into its derived stores.
|
||||
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
|
||||
streamUrl:
|
||||
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
|
||||
// Route through the SharedWorker — see docs/web-ui.md (SSE
|
||||
// multiplexing paragraph). Worker keys on the full URL incl.
|
||||
// query string, so this filtered subscribe is its own upstream
|
||||
// and won't accidentally share with tabs.js's wider subscribe.
|
||||
streamFactory: openStream,
|
||||
renderers: {
|
||||
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
|
||||
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
|
||||
delivered: (ev, api) => {
|
||||
// Fold into the matching sent row when it just happened;
|
||||
// otherwise render the delivery as its own line.
|
||||
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
|
||||
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
|
||||
},
|
||||
// Maintain the local containers cache from the same stream
|
||||
// (compose autocomplete reads from `flowContainers`). The
|
||||
|
|
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
|
|||
// are never replayed, so without this the compose autocomplete
|
||||
// could drift stale.
|
||||
onStreamOpen: () => {
|
||||
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
}).catch(() => {});
|
||||
fetch("/api/state")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((s) => {
|
||||
if (!s || !Array.isArray(s.containers)) return;
|
||||
flowContainers.clear();
|
||||
for (const c of s.containers) flowContainers.set(c.name, c);
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
onLiveEvent: (ev) => {
|
||||
pulseBanner();
|
||||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||
if (ev.kind === "sent" && ev.to === "operator") {
|
||||
NOTIF.show(
|
||||
'◆ ' + ev.from + ' → operator',
|
||||
String(ev.body || '').slice(0, 200),
|
||||
"◆ " + ev.from + " → operator",
|
||||
String(ev.body || "").slice(0, 200),
|
||||
// Unique-per-arrival tag so a burst stacks instead of
|
||||
// overwriting itself in the OS notification center.
|
||||
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
|
||||
"hyperhive:msg:" +
|
||||
ev.at +
|
||||
":" +
|
||||
Math.random().toString(36).slice(2, 6),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
|
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
|
|||
|
||||
// ─── compose: @-mention with sticky recipient ───────────────────────────
|
||||
(() => {
|
||||
const input = $('op-compose-input');
|
||||
const prompt = $('op-compose-prompt');
|
||||
const suggest = $('op-compose-suggest');
|
||||
const input = $("op-compose-input");
|
||||
const prompt = $("op-compose-prompt");
|
||||
const suggest = $("op-compose-suggest");
|
||||
if (!input || !prompt || !suggest) return;
|
||||
const STORAGE_KEY = 'hyperhive:op-compose:to';
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
|
||||
const STORAGE_KEY = "hyperhive:op-compose:to";
|
||||
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
|
||||
let suggestActive = -1;
|
||||
function renderPrompt() {
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
|
||||
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
|
||||
}
|
||||
function knownAgents() {
|
||||
// Read live from the flow-local containers cache so newly-spawned
|
||||
// agents become addressable without a manual reload.
|
||||
const names = Array.from(flowContainers.values())
|
||||
.map((c) => c.name);
|
||||
const names = Array.from(flowContainers.values()).map((c) => c.name);
|
||||
// `*` fans out to every registered agent (server-side
|
||||
// broadcast_send).
|
||||
names.unshift('*');
|
||||
names.unshift("*");
|
||||
return names;
|
||||
}
|
||||
function autosize() {
|
||||
input.style.height = 'auto';
|
||||
input.style.height = "auto";
|
||||
input.style.height = `${input.scrollHeight}px`;
|
||||
}
|
||||
/// Parse "@name body…" — return {to, body} when the input opens
|
||||
|
|
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
|
|||
}
|
||||
function renderSuggest(matches) {
|
||||
suggest.replaceChildren();
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < matches.length; i += 1) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'item' + (i === suggestActive ? ' active' : '');
|
||||
item.textContent = '@' + matches[i];
|
||||
item.addEventListener('mousedown', (e) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "item" + (i === suggestActive ? " active" : "");
|
||||
item.textContent = "@" + matches[i];
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
applySuggestion(matches[i]);
|
||||
});
|
||||
|
|
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
|
|||
const v = input.value;
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (m) {
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
|
||||
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
|
||||
} else {
|
||||
input.value = `@${name} ` + v;
|
||||
}
|
||||
|
|
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
|
|||
// input — switching recipient is always "redirect this whole
|
||||
// line." Mid-message @-mentions stay literal.
|
||||
const m = v.match(/^@(\S*)/);
|
||||
if (!m) { hideSuggest(); return; }
|
||||
if (!m) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
const partial = m[1].toLowerCase();
|
||||
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
|
||||
if (!matches.length) { hideSuggest(); return; }
|
||||
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
|
||||
const matches = knownAgents().filter((n) =>
|
||||
n.toLowerCase().startsWith(partial),
|
||||
);
|
||||
if (!matches.length) {
|
||||
hideSuggest();
|
||||
return;
|
||||
}
|
||||
if (suggestActive < 0 || suggestActive >= matches.length)
|
||||
suggestActive = 0;
|
||||
renderSuggest(matches);
|
||||
}
|
||||
async function submit() {
|
||||
|
|
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
|
|||
to = stickyTo;
|
||||
body = raw;
|
||||
} else {
|
||||
flashError('no recipient — start with @name to address a message');
|
||||
flashError("no recipient — start with @name to address a message");
|
||||
return;
|
||||
}
|
||||
if (!body) return;
|
||||
const fd = new FormData();
|
||||
fd.append('to', to);
|
||||
fd.append('body', body);
|
||||
fd.append("to", to);
|
||||
fd.append("body", body);
|
||||
input.disabled = true;
|
||||
try {
|
||||
// /op-send returns 200. The SSE channel carries the resulting
|
||||
// MessageEvent → the terminal renders the sent row on its own;
|
||||
// no /api/state refetch needed.
|
||||
const resp = await fetch('/api/op-send', {
|
||||
method: 'POST',
|
||||
const resp = await fetch("/api/op-send", {
|
||||
method: "POST",
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
|
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
|
|||
}
|
||||
stickyTo = to;
|
||||
localStorage.setItem(STORAGE_KEY, to);
|
||||
input.value = '';
|
||||
input.value = "";
|
||||
autosize();
|
||||
renderPrompt();
|
||||
input.focus();
|
||||
}
|
||||
function flashError(msg) {
|
||||
const flow = $('msgflow');
|
||||
const flow = $("msgflow");
|
||||
if (!flow) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'msgrow meta';
|
||||
row.textContent = '✗ ' + msg;
|
||||
const row = document.createElement("div");
|
||||
row.className = "msgrow meta";
|
||||
row.textContent = "✗ " + msg;
|
||||
// Append at the bottom so the error is visible — the terminal
|
||||
// renders newest-last, so inserting before firstChild would place
|
||||
// the error at the top (oldest end) and hide it from view.
|
||||
|
|
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
|
|||
const wrap = flow.parentElement;
|
||||
if (wrap) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
input.addEventListener('input', () => { autosize(); updateSuggest(); });
|
||||
input.addEventListener('keydown', (e) => {
|
||||
input.addEventListener("input", () => {
|
||||
autosize();
|
||||
updateSuggest();
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (!suggest.hidden) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
if (e.key === "ArrowDown") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive + 1) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
const items = suggest.querySelectorAll('.item');
|
||||
if (e.key === "ArrowUp") {
|
||||
const items = suggest.querySelectorAll(".item");
|
||||
suggestActive = (suggestActive - 1 + items.length) % items.length;
|
||||
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||
const active = suggest.querySelector('.item.active');
|
||||
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
|
||||
const active = suggest.querySelector(".item.active");
|
||||
if (active) {
|
||||
applySuggestion(active.textContent.slice(1));
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (e.key === "Escape") {
|
||||
hideSuggest();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
});
|
||||
input.addEventListener('blur', () => {
|
||||
input.addEventListener("blur", () => {
|
||||
// Defer so a click on a suggestion item (mousedown) lands first.
|
||||
setTimeout(hideSuggest, 100);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -57,13 +57,17 @@ body.home-shell {
|
|||
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
|
||||
text-decoration: none;
|
||||
color: var(--fg);
|
||||
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease,
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
}
|
||||
.home-tile:hover {
|
||||
border-color: var(--purple);
|
||||
background: color-mix(in srgb, var(--purple) 6%, transparent);
|
||||
box-shadow: 0 -2px 14px -6px color-mix(in srgb, var(--purple) 50%, transparent);
|
||||
box-shadow: 0 -2px 14px -6px
|
||||
color-mix(in srgb, var(--purple) 50%, transparent);
|
||||
}
|
||||
|
||||
/* Icon + label share the top row of each tile; the description sits
|
||||
|
|
|
|||
|
|
@ -7,14 +7,14 @@
|
|||
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
|
||||
// see startMatrixRain() at the foot.
|
||||
|
||||
import { renderServerWarnings } from './common.js';
|
||||
import { renderServerWarnings } from "./common.js";
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function init() {
|
||||
let state;
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) return;
|
||||
state = await resp.json();
|
||||
} catch {
|
||||
|
|
@ -23,10 +23,10 @@ async function init() {
|
|||
|
||||
renderServerWarnings(state.server_warnings);
|
||||
|
||||
const ident = $('hive-identity');
|
||||
const ident = $("hive-identity");
|
||||
if (ident && (state.swarm_name || state.hive_name)) {
|
||||
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
|
||||
ident.textContent = parts.join(' / ');
|
||||
ident.textContent = parts.join(" / ");
|
||||
ident.hidden = false;
|
||||
}
|
||||
|
||||
|
|
@ -41,10 +41,12 @@ async function init() {
|
|||
// rather than truncating from the tail, so two different builds don't
|
||||
// render as the same string. Falls back to a plain head-slice for a
|
||||
// non-store path (e.g. a bare local dir during dev).
|
||||
const rev = $('hive-rev');
|
||||
const rev = $("hive-rev");
|
||||
if (rev && state.hyperhive_rev) {
|
||||
const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/);
|
||||
const short = storeMatch ? storeMatch[1].slice(0, 12) : state.hyperhive_rev.slice(0, 12);
|
||||
const short = storeMatch
|
||||
? storeMatch[1].slice(0, 12)
|
||||
: state.hyperhive_rev.slice(0, 12);
|
||||
rev.textContent = `rev ${short}`;
|
||||
rev.title = state.hyperhive_rev;
|
||||
rev.hidden = false;
|
||||
|
|
@ -61,34 +63,40 @@ init();
|
|||
// steppy cadence is the look and costs little CPU); paused while the tab is
|
||||
// hidden, and skipped entirely under prefers-reduced-motion.
|
||||
function startMatrixRain() {
|
||||
const canvas = $('matrix-rain');
|
||||
const canvas = $("matrix-rain");
|
||||
if (!canvas || !canvas.getContext) return;
|
||||
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
)
|
||||
return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
// getComputedStyle on a custom property returns its *declared* value
|
||||
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
|
||||
// probing a real `color` computation instead.
|
||||
function resolveColor(varName, fallback) {
|
||||
const probe = document.createElement('span');
|
||||
const probe = document.createElement("span");
|
||||
probe.style.color = `var(${varName})`;
|
||||
probe.style.display = 'none';
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
const c = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return /^rgb/.test(c) ? c : fallback;
|
||||
}
|
||||
const glyphColor = resolveColor('--green', 'rgb(166, 227, 161)');
|
||||
const glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
|
||||
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
|
||||
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
|
||||
// getComputedStyle hands back, so the trail always fades (never fills
|
||||
// solid) even if the format shifts.
|
||||
const bgRGB = resolveColor('--bg', 'rgb(30, 30, 46)').match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/);
|
||||
const bgRGB = resolveColor("--bg", "rgb(30, 30, 46)").match(
|
||||
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
|
||||
);
|
||||
const fadeColor = bgRGB
|
||||
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
|
||||
: 'rgba(30, 30, 46, 0.09)';
|
||||
: "rgba(30, 30, 46, 0.09)";
|
||||
|
||||
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦';
|
||||
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
|
||||
const CELL = 16; // glyph cell size (px)
|
||||
let cols = 0;
|
||||
let drops = [];
|
||||
|
|
@ -109,18 +117,28 @@ function startMatrixRain() {
|
|||
for (let i = 0; i < cols; i++) {
|
||||
const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0];
|
||||
ctx.fillText(ch, i * CELL, drops[i] * CELL);
|
||||
if (drops[i] * CELL > canvas.height && Math.random() > 0.975) drops[i] = 0;
|
||||
if (drops[i] * CELL > canvas.height && Math.random() > 0.975)
|
||||
drops[i] = 0;
|
||||
drops[i]++;
|
||||
}
|
||||
}
|
||||
|
||||
let timer = null;
|
||||
const play = () => { if (!timer) timer = setInterval(tick, 55); };
|
||||
const pause = () => { if (timer) { clearInterval(timer); timer = null; } };
|
||||
const play = () => {
|
||||
if (!timer) timer = setInterval(tick, 55);
|
||||
};
|
||||
const pause = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('visibilitychange', () => (document.hidden ? pause() : play()));
|
||||
window.addEventListener("resize", resize);
|
||||
document.addEventListener("visibilitychange", () =>
|
||||
document.hidden ? pause() : play(),
|
||||
);
|
||||
play();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,112 +1,122 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // h0m3</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/home.css">
|
||||
</head>
|
||||
<body class="home-shell">
|
||||
|
||||
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // h0m3</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/home.css" />
|
||||
</head>
|
||||
<body class="home-shell">
|
||||
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
|
||||
falling glyphs behind everything (home.js drives it). aria-hidden +
|
||||
pointer-events:none so it's purely cosmetic; glyph/bg colours are
|
||||
read from the stylix palette at runtime so a theme swap re-colours
|
||||
it, and it's disabled under prefers-reduced-motion. -->
|
||||
<canvas id="matrix-rain" aria-hidden="true"></canvas>
|
||||
<canvas id="matrix-rain" aria-hidden="true"></canvas>
|
||||
|
||||
<!-- H0M3: the menu hub. A plain grid of links to every top-level
|
||||
<!-- H0M3: the menu hub. A plain grid of links to every top-level
|
||||
surface. This is the page served at `/` — the landing page — with
|
||||
the dashboard relocated to /dashboard.html. No tabbar / SSE —
|
||||
it's a static portal. -->
|
||||
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||
<!-- Padded content wrapper: carries the 1.5em side gutter
|
||||
(.page-content, common.css) while <body> stays full-bleed so the
|
||||
server-warnings banner spans the full width. -->
|
||||
<div class="page-content">
|
||||
<header class="home-header">
|
||||
<p class="banner-thin" id="hive-identity" hidden></p>
|
||||
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre>
|
||||
<p class="banner-thin" id="hive-rev" hidden></p>
|
||||
</header>
|
||||
<div class="page-content">
|
||||
<header class="home-header">
|
||||
<p class="banner-thin" id="hive-identity" hidden></p>
|
||||
<pre class="banner">
|
||||
░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre
|
||||
>
|
||||
<p class="banner-thin" id="hive-rev" hidden></p>
|
||||
</header>
|
||||
|
||||
<main class="home-main">
|
||||
<nav class="home-menu" aria-label="hyperhive surfaces">
|
||||
<main class="home-main">
|
||||
<nav class="home-menu" aria-label="hyperhive surfaces">
|
||||
<a class="home-tile" href="/dashboard.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🖥</span>
|
||||
<span class="home-tile-label">Dashboard</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>containers · approvals · permissions · schedules · system</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/dashboard.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🖥</span>
|
||||
<span class="home-tile-label">Dashboard</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">containers · approvals · permissions · schedules · system</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/flow.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📡</span>
|
||||
<span class="home-tile-label">Flow</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">live all-agents message firehose</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/flow.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📡</span>
|
||||
<span class="home-tile-label">Flow</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">live all-agents message firehose</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/builds.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔨</span>
|
||||
<span class="home-tile-label">Builds</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>rebuild queue · meta inputs · build logs</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/builds.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔨</span>
|
||||
<span class="home-tile-label">Builds</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/logs.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📜</span>
|
||||
<span class="home-tile-label">Logs</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">agent · infra · system logs</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/logs.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📜</span>
|
||||
<span class="home-tile-label">Logs</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">agent · infra · system logs</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/stats.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📊</span>
|
||||
<span class="home-tile-label">Stats</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>hive-wide turn stats · cost · model mix</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/stats.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">📊</span>
|
||||
<span class="home-tile-label">Stats</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">hive-wide turn stats · cost · model mix</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/core.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">⬡</span>
|
||||
<span class="home-tile-label">Core</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">kept state · container load</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/core.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">⬡</span>
|
||||
<span class="home-tile-label">Core</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">kept state · container load</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/credentials.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔑</span>
|
||||
<span class="home-tile-label">Credentials</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>provision per-agent matrix + github accounts</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/credentials.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔑</span>
|
||||
<span class="home-tile-label">Credentials</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">provision per-agent matrix + github accounts</span>
|
||||
</a>
|
||||
|
||||
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
|
||||
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
|
||||
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
|
||||
endpoints), so this tile is never gated/hidden. -->
|
||||
<a class="home-tile" href="/api/docs">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🧬</span>
|
||||
<span class="home-tile-label">API</span>
|
||||
</span>
|
||||
<span class="home-tile-desc">interactive OpenAPI spec (Swagger UI)</span>
|
||||
</a>
|
||||
<a class="home-tile" href="/api/docs">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🧬</span>
|
||||
<span class="home-tile-label">API</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>interactive OpenAPI spec (Swagger UI)</span
|
||||
>
|
||||
</a>
|
||||
</nav>
|
||||
</main>
|
||||
</div>
|
||||
<!-- /.page-content -->
|
||||
|
||||
</nav>
|
||||
</main>
|
||||
</div><!-- /.page-content -->
|
||||
|
||||
<script type="module" src="/static/home.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/home.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ body.logs-shell {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logs-pane[hidden] { display: none; }
|
||||
.logs-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.logs-pane {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
|
|
@ -75,9 +77,15 @@ body.logs-shell {
|
|||
padding: 0.25em 0.5em;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
|
||||
.journal-refresh {
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
}
|
||||
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
|
||||
.logs-fetch-ts { font-size: 0.8em; color: var(--muted); }
|
||||
.logs-fetch-ts {
|
||||
font-size: 0.8em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.journal-output {
|
||||
margin: 0;
|
||||
background: var(--crust);
|
||||
|
|
@ -108,7 +116,10 @@ body.logs-shell {
|
|||
gap: 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
|
||||
.build-logs-refresh {
|
||||
font-size: 0.75em;
|
||||
padding: 0.15em 0.5em;
|
||||
}
|
||||
.build-logs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
@ -124,7 +135,9 @@ body.logs-shell {
|
|||
font-size: 0.85em;
|
||||
padding: 0.3em 0;
|
||||
}
|
||||
.build-logs-error { color: var(--red); }
|
||||
.build-logs-error {
|
||||
color: var(--red);
|
||||
}
|
||||
.build-logs-item {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.15em 0;
|
||||
|
|
@ -145,9 +158,15 @@ body.logs-shell {
|
|||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.build-logs-row-btn:hover { background: var(--bg-elev); }
|
||||
.build-logs-kind { font-weight: 600; }
|
||||
.build-logs-age { font-size: 0.88em; }
|
||||
.build-logs-row-btn:hover {
|
||||
background: var(--bg-elev);
|
||||
}
|
||||
.build-logs-kind {
|
||||
font-weight: 600;
|
||||
}
|
||||
.build-logs-age {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.build-logs-cmdline {
|
||||
color: var(--muted);
|
||||
font-size: 0.82em;
|
||||
|
|
@ -156,7 +175,9 @@ body.logs-shell {
|
|||
white-space: nowrap;
|
||||
max-width: 40em;
|
||||
}
|
||||
.build-logs-detail { padding: 0 0.4em 0.4em; }
|
||||
.build-logs-detail {
|
||||
padding: 0 0.4em 0.4em;
|
||||
}
|
||||
.build-logs-output {
|
||||
margin: 0.3em 0 0;
|
||||
padding: 0.4em 0.6em;
|
||||
|
|
@ -181,16 +202,32 @@ body.logs-shell {
|
|||
margin-top: 0.3em;
|
||||
display: none;
|
||||
}
|
||||
.build-logs-dl:not([hidden]) { display: inline-block; }
|
||||
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); }
|
||||
.build-logs-dl:not([hidden]) {
|
||||
display: inline-block;
|
||||
}
|
||||
.build-logs-dl:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--purple-dim);
|
||||
}
|
||||
|
||||
.build-logs-live-badge { margin-bottom: 0.4em; }
|
||||
.build-logs-live-badge {
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
@keyframes live-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
.build-logs-live-badge.badge-running {
|
||||
animation: live-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
|
||||
|
||||
.build-logs-runtime { font-size: 0.85em; color: var(--muted); }
|
||||
|
||||
.build-logs-runtime {
|
||||
font-size: 0.85em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,82 +1,112 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // LOGS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/logs.css">
|
||||
</head>
|
||||
<body class="logs-shell">
|
||||
|
||||
<!-- Minimal chrome: back link + sub-tab strip.
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // LOGS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/logs.css" />
|
||||
</head>
|
||||
<body class="logs-shell">
|
||||
<!-- Minimal chrome: back link + sub-tab strip.
|
||||
Same pattern as flow.html — no full dashboard tabbar. Back link
|
||||
points to the H0M3 hub (served at /), not the dashboard.
|
||||
Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
|
||||
moved to /builds.html (the build lifecycle hub). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip class="hive-tabbar logs-tabbar" id="logs-tabbar" prefix="logs"
|
||||
role="tablist"></hive-tab-strip>
|
||||
</header>
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar logs-tabbar"
|
||||
id="logs-tabbar"
|
||||
prefix="logs"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="logs-main">
|
||||
|
||||
<!-- AGENT: journald viewer for a specific agent container.
|
||||
<main class="logs-main">
|
||||
<!-- AGENT: journald viewer for a specific agent container.
|
||||
Agent selector + unit filter + line count. Backed by
|
||||
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
|
||||
<section class="logs-pane" id="logs-pane-agent" data-tab-pane="agent"
|
||||
role="tabpanel" aria-labelledby="logs-tab-agent">
|
||||
<div class="logs-toolbar">
|
||||
<select id="agent-select" class="journal-unit"></select>
|
||||
<select id="agent-unit-select" class="journal-unit">
|
||||
<option value="hive-agent.service">hive-agent.service</option>
|
||||
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
|
||||
<option value="hive-bash-daemon.service">hive-bash-daemon.service</option>
|
||||
<option value="hive-matrix-daemon.service">hive-matrix-daemon.service</option>
|
||||
<option value="">(full machine journal)</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button>
|
||||
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="agent-output" class="journal-output">select an agent above</pre>
|
||||
</section>
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-agent"
|
||||
data-tab-pane="agent"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-agent"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="agent-select" class="journal-unit"></select>
|
||||
<select id="agent-unit-select" class="journal-unit">
|
||||
<option value="hive-agent.service">hive-agent.service</option>
|
||||
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
|
||||
<option value="hive-bash-daemon.service">
|
||||
hive-bash-daemon.service
|
||||
</option>
|
||||
<option value="hive-matrix-daemon.service">
|
||||
hive-matrix-daemon.service
|
||||
</option>
|
||||
<option value="">(full machine journal)</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="agent-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="agent-output" class="journal-output">
|
||||
select an agent above</pre
|
||||
>
|
||||
</section>
|
||||
|
||||
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
|
||||
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
|
||||
hive-forge, hive-gateway, hive-matrix). Always fetches the full
|
||||
machine journal — no unit filter (infra containers don't run the
|
||||
per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N.
|
||||
Deep-link: ?agent=hive-ci routes here instead of the AGENT tab. -->
|
||||
<section class="logs-pane" id="logs-pane-infra" data-tab-pane="infra"
|
||||
role="tabpanel" aria-labelledby="logs-tab-infra">
|
||||
<div class="logs-toolbar">
|
||||
<select id="infra-select" class="journal-unit"></select>
|
||||
<button type="button" class="btn btn-restart" id="infra-refresh">↻ refresh</button>
|
||||
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="infra-output" class="journal-output">select a container above</pre>
|
||||
</section>
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-infra"
|
||||
data-tab-pane="infra"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-infra"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="infra-select" class="journal-unit"></select>
|
||||
<button type="button" class="btn btn-restart" id="infra-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="infra-output" class="journal-output">
|
||||
select a container above</pre
|
||||
>
|
||||
</section>
|
||||
|
||||
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
||||
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
|
||||
journal via GET /api/journal-host?unit=hive-c0re.service. -->
|
||||
<section class="logs-pane" id="logs-pane-system" data-tab-pane="system"
|
||||
role="tabpanel" aria-labelledby="logs-tab-system">
|
||||
<div class="logs-toolbar">
|
||||
<select id="system-unit-select" class="journal-unit">
|
||||
<option value="hive-c0re.service">hive-c0re.service</option>
|
||||
<option value="hive-priv.service">hive-priv.service</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button>
|
||||
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="system-output" class="journal-output">loading…</pre>
|
||||
</section>
|
||||
<section
|
||||
class="logs-pane"
|
||||
id="logs-pane-system"
|
||||
data-tab-pane="system"
|
||||
role="tabpanel"
|
||||
aria-labelledby="logs-tab-system"
|
||||
>
|
||||
<div class="logs-toolbar">
|
||||
<select id="system-unit-select" class="journal-unit">
|
||||
<option value="hive-c0re.service">hive-c0re.service</option>
|
||||
<option value="hive-priv.service">hive-priv.service</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-restart" id="system-refresh">
|
||||
↻ refresh
|
||||
</button>
|
||||
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
|
||||
</div>
|
||||
<pre id="system-output" class="journal-output">loading…</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/logs.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/logs.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,9 @@
|
|||
// Last-fetched timestamp is shown next to the refresh button on AGENT,
|
||||
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
|
||||
|
||||
import {
|
||||
$, fmtAgeSecs, initServerWarnings,
|
||||
} from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import '@hive/shared/hive-tab-strip.js';
|
||||
import { $, fmtAgeSecs, initServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
|
||||
(() => {
|
||||
initServerWarnings();
|
||||
|
|
@ -34,34 +32,43 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
|
||||
function fmtFetchTs(fetchedAt) {
|
||||
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
|
||||
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
|
||||
return (
|
||||
"fetched " + (ageSecs < 5 ? "just now" : fmtAgeSecs(ageSecs) + " ago")
|
||||
);
|
||||
}
|
||||
|
||||
// ─── AGENT tab ────────────────────────────────────────────────────────
|
||||
|
||||
const agentSelect = $('agent-select');
|
||||
const agentUnitSelect = $('agent-unit-select');
|
||||
const agentRefresh = $('agent-refresh');
|
||||
const agentOutput = $('agent-output');
|
||||
const agentFetchTs = $('agent-fetch-ts');
|
||||
const agentSelect = $("agent-select");
|
||||
const agentUnitSelect = $("agent-unit-select");
|
||||
const agentRefresh = $("agent-refresh");
|
||||
const agentOutput = $("agent-output");
|
||||
const agentFetchTs = $("agent-fetch-ts");
|
||||
|
||||
let agentFetching = false;
|
||||
let agentLastFetch = 0;
|
||||
async function fetchAgent() {
|
||||
if (!agentSelect || !agentOutput) return;
|
||||
const name = agentSelect.value;
|
||||
if (!name) { agentOutput.textContent = 'select an agent above'; return; }
|
||||
if (!name) {
|
||||
agentOutput.textContent = "select an agent above";
|
||||
return;
|
||||
}
|
||||
if (agentFetching) return;
|
||||
agentFetching = true;
|
||||
agentOutput.textContent = 'fetching…';
|
||||
agentOutput.textContent = "fetching…";
|
||||
if (agentFetchTs) agentFetchTs.hidden = true;
|
||||
const unit = agentUnitSelect ? agentUnitSelect.value : '';
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
if (unit) params.set('unit', unit);
|
||||
const unit = agentUnitSelect ? agentUnitSelect.value : "";
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
if (unit) params.set("unit", unit);
|
||||
try {
|
||||
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
|
||||
const resp = await fetch(
|
||||
"/api/journal/" + encodeURIComponent(name) + "?" + params,
|
||||
);
|
||||
const text = await resp.text();
|
||||
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
|
||||
agentOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
agentOutput.scrollTop = agentOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
agentLastFetch = Date.now();
|
||||
|
|
@ -71,41 +78,48 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
agentOutput.textContent = 'fetch failed: ' + err;
|
||||
agentOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
agentFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentSelect) agentSelect.addEventListener('change', fetchAgent);
|
||||
if (agentUnitSelect) agentUnitSelect.addEventListener('change', fetchAgent);
|
||||
if (agentRefresh) agentRefresh.addEventListener('click', fetchAgent);
|
||||
if (agentSelect) agentSelect.addEventListener("change", fetchAgent);
|
||||
if (agentUnitSelect) agentUnitSelect.addEventListener("change", fetchAgent);
|
||||
if (agentRefresh) agentRefresh.addEventListener("click", fetchAgent);
|
||||
|
||||
// ─── INFRA tab ────────────────────────────────────────────────────────
|
||||
// Infra containers (hive-ci, hive-forge, hive-gateway, hive-matrix) don't
|
||||
// run the per-agent hive daemons, so the unit filter is inapplicable. We
|
||||
// always fetch the full machine journal for them.
|
||||
|
||||
const infraSelect = $('infra-select');
|
||||
const infraRefresh = $('infra-refresh');
|
||||
const infraOutput = $('infra-output');
|
||||
const infraFetchTs = $('infra-fetch-ts');
|
||||
const infraSelect = $("infra-select");
|
||||
const infraRefresh = $("infra-refresh");
|
||||
const infraOutput = $("infra-output");
|
||||
const infraFetchTs = $("infra-fetch-ts");
|
||||
|
||||
let infraFetching = false;
|
||||
let infraLastFetch = 0;
|
||||
async function fetchInfra() {
|
||||
if (!infraSelect || !infraOutput) return;
|
||||
const name = infraSelect.value;
|
||||
if (!name) { infraOutput.textContent = 'select a container above'; return; }
|
||||
if (!name) {
|
||||
infraOutput.textContent = "select a container above";
|
||||
return;
|
||||
}
|
||||
if (infraFetching) return;
|
||||
infraFetching = true;
|
||||
infraOutput.textContent = 'fetching…';
|
||||
infraOutput.textContent = "fetching…";
|
||||
if (infraFetchTs) infraFetchTs.hidden = true;
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
try {
|
||||
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
|
||||
const resp = await fetch(
|
||||
"/api/journal/" + encodeURIComponent(name) + "?" + params,
|
||||
);
|
||||
const text = await resp.text();
|
||||
infraOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
|
||||
infraOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
infraOutput.scrollTop = infraOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
infraLastFetch = Date.now();
|
||||
|
|
@ -115,20 +129,20 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
infraOutput.textContent = 'fetch failed: ' + err;
|
||||
infraOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
infraFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (infraSelect) infraSelect.addEventListener('change', fetchInfra);
|
||||
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra);
|
||||
if (infraSelect) infraSelect.addEventListener("change", fetchInfra);
|
||||
if (infraRefresh) infraRefresh.addEventListener("click", fetchInfra);
|
||||
|
||||
// Fixed allowlist — the four hive infra services never change at
|
||||
// runtime, and there's no dashboard API exposing just the name list
|
||||
// (the one that used to, `/api/state`'s `infra_containers` field, was
|
||||
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
|
||||
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix'];
|
||||
const INFRA_NAMES = ["hive-ci", "hive-forge", "hive-gateway", "hive-matrix"];
|
||||
|
||||
// ─── container list init ──────────────────────────────────────────────
|
||||
// Fetch /api/state once to populate the AGENT selector (agents only);
|
||||
|
|
@ -138,59 +152,64 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
async function loadContainerLists() {
|
||||
if (infraSelect) {
|
||||
infraSelect.replaceChildren();
|
||||
infraSelect.append(el('option', { value: '' }, '— select container —'));
|
||||
infraSelect.append(el("option", { value: "" }, "— select container —"));
|
||||
for (const name of INFRA_NAMES) {
|
||||
infraSelect.append(el('option', { value: name }, name));
|
||||
infraSelect.append(el("option", { value: name }, name));
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) return;
|
||||
const state = await resp.json();
|
||||
|
||||
// Populate AGENT selector (agents only — no infra optgroup).
|
||||
if (agentSelect) {
|
||||
agentSelect.replaceChildren();
|
||||
agentSelect.append(el('option', { value: '' }, '— select agent —'));
|
||||
for (const c of (state.containers || [])) {
|
||||
agentSelect.append(el('option', { value: c.name }, c.name));
|
||||
agentSelect.append(el("option", { value: "" }, "— select agent —"));
|
||||
for (const c of state.containers || []) {
|
||||
agentSelect.append(el("option", { value: c.name }, c.name));
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-link: honour ?agent= and ?unit= URL params.
|
||||
const urlAgent = new URLSearchParams(location.search).get('agent');
|
||||
const urlUnit = new URLSearchParams(location.search).get('unit');
|
||||
const urlAgent = new URLSearchParams(location.search).get("agent");
|
||||
const urlUnit = new URLSearchParams(location.search).get("unit");
|
||||
if (urlAgent) {
|
||||
if (INFRA_NAMES.includes(urlAgent)) {
|
||||
// Route to INFRA tab.
|
||||
logTabs.show('infra');
|
||||
logTabs.show("infra");
|
||||
if (infraSelect) {
|
||||
infraSelect.value = urlAgent;
|
||||
fetchInfra();
|
||||
}
|
||||
} else if (agentSelect) {
|
||||
// Route to AGENT tab.
|
||||
const found = Array.from(agentSelect.options).some((o) => o.value === urlAgent);
|
||||
const found = Array.from(agentSelect.options).some(
|
||||
(o) => o.value === urlAgent,
|
||||
);
|
||||
if (found) {
|
||||
agentSelect.value = urlAgent;
|
||||
if (urlUnit && agentUnitSelect) {
|
||||
const unitFound = Array.from(agentUnitSelect.options)
|
||||
.some((o) => o.value === urlUnit);
|
||||
const unitFound = Array.from(agentUnitSelect.options).some(
|
||||
(o) => o.value === urlUnit,
|
||||
);
|
||||
if (unitFound) agentUnitSelect.value = urlUnit;
|
||||
}
|
||||
fetchAgent();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /**/ }
|
||||
} catch {
|
||||
/**/
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SYSTEM tab ───────────────────────────────────────────────────────
|
||||
|
||||
const systemUnitSelect = $('system-unit-select');
|
||||
const systemRefresh = $('system-refresh');
|
||||
const systemOutput = $('system-output');
|
||||
const systemFetchTs = $('system-fetch-ts');
|
||||
const systemUnitSelect = $("system-unit-select");
|
||||
const systemRefresh = $("system-refresh");
|
||||
const systemOutput = $("system-output");
|
||||
const systemFetchTs = $("system-fetch-ts");
|
||||
|
||||
let systemFetching = false;
|
||||
let systemLastFetch = 0;
|
||||
|
|
@ -198,15 +217,19 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
if (!systemOutput) return;
|
||||
if (systemFetching) return;
|
||||
systemFetching = true;
|
||||
systemOutput.textContent = 'fetching…';
|
||||
systemOutput.textContent = "fetching…";
|
||||
if (systemFetchTs) systemFetchTs.hidden = true;
|
||||
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
|
||||
const params = new URLSearchParams({ lines: '500' });
|
||||
if (unit) params.set('unit', unit);
|
||||
const unit = systemUnitSelect
|
||||
? systemUnitSelect.value
|
||||
: "hive-c0re.service";
|
||||
const params = new URLSearchParams({ lines: "500" });
|
||||
if (unit) params.set("unit", unit);
|
||||
try {
|
||||
const resp = await fetch('/api/journal-host?' + params);
|
||||
const resp = await fetch("/api/journal-host?" + params);
|
||||
const text = await resp.text();
|
||||
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
|
||||
systemOutput.textContent = resp.ok
|
||||
? text || "(empty)"
|
||||
: "error " + resp.status + "\n" + text;
|
||||
systemOutput.scrollTop = systemOutput.scrollHeight;
|
||||
if (resp.ok) {
|
||||
systemLastFetch = Date.now();
|
||||
|
|
@ -216,14 +239,15 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
}
|
||||
}
|
||||
} catch (err) {
|
||||
systemOutput.textContent = 'fetch failed: ' + err;
|
||||
systemOutput.textContent = "fetch failed: " + err;
|
||||
} finally {
|
||||
systemFetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
|
||||
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
|
||||
if (systemUnitSelect)
|
||||
systemUnitSelect.addEventListener("change", fetchSystem);
|
||||
if (systemRefresh) systemRefresh.addEventListener("click", fetchSystem);
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -231,15 +255,15 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
// needs are defined. Its initial show() paints the active pane and, if
|
||||
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
|
||||
// Default: AGENT.
|
||||
logTabs = document.getElementById('logs-tabbar').configure({
|
||||
logTabs = document.getElementById("logs-tabbar").configure({
|
||||
tabs: [
|
||||
{ id: 'agent', label: 'AGENT' },
|
||||
{ id: 'infra', label: 'INFRA' },
|
||||
{ id: 'system', label: 'SYSTEM' },
|
||||
{ id: "agent", label: "AGENT" },
|
||||
{ id: "infra", label: "INFRA" },
|
||||
{ id: "system", label: "SYSTEM" },
|
||||
],
|
||||
defaultId: 'agent',
|
||||
defaultId: "agent",
|
||||
onShow: (id) => {
|
||||
if (id === 'system') fetchSystem();
|
||||
if (id === "system") fetchSystem();
|
||||
},
|
||||
});
|
||||
loadContainerLists();
|
||||
|
|
@ -257,5 +281,4 @@ import '@hive/shared/hive-tab-strip.js';
|
|||
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -21,10 +21,10 @@
|
|||
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
|
||||
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
|
||||
|
||||
import { $ } from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { containersState } from './state.js';
|
||||
import { asyncBtn } from '@hive/shared/forms.js';
|
||||
import { $ } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { containersState } from "./state.js";
|
||||
import { asyncBtn } from "@hive/shared/forms.js";
|
||||
|
||||
// ── SSE re-render guards ────────────────────────────────────────────
|
||||
// Skip the live re-render when the operator has unsaved edits in that
|
||||
|
|
@ -33,14 +33,14 @@ import { asyncBtn } from '@hive/shared/forms.js';
|
|||
// and the post-save re-fetch are the recovery paths; both clear dirty.
|
||||
|
||||
export function applyCapabilitiesChanged(ev) {
|
||||
const root = $('capabilities-section');
|
||||
const root = $("capabilities-section");
|
||||
if (!root) return;
|
||||
if (root.contains(document.activeElement)) return;
|
||||
if (sectionHasDirty(root)) return;
|
||||
renderCapabilities(root, ev);
|
||||
}
|
||||
export function applyToolGroupsChanged(ev) {
|
||||
const root = $('tool-groups-section');
|
||||
const root = $("tool-groups-section");
|
||||
if (!root) return;
|
||||
if (root.contains(document.activeElement)) return;
|
||||
if (sectionHasDirty(root)) return;
|
||||
|
|
@ -50,25 +50,25 @@ export function applyToolGroupsChanged(ev) {
|
|||
// A section is dirty if any checkbox diverges from its render-time
|
||||
// baseline. Cheap DOM scan; no module-level mirror to drift.
|
||||
function sectionHasDirty(root) {
|
||||
for (const cb of root.querySelectorAll('input[type=checkbox]')) {
|
||||
if (cb.checked !== (cb.dataset.baseline === '1')) return true;
|
||||
for (const cb of root.querySelectorAll("input[type=checkbox]")) {
|
||||
if (cb.checked !== (cb.dataset.baseline === "1")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fetchAndRenderCapabilities() {
|
||||
const root = $('capabilities-section');
|
||||
const root = $("capabilities-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||||
root.append(el("p", { class: "meta" }, "loading…"));
|
||||
try {
|
||||
const resp = await fetch('/api/capabilities');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/capabilities");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const data = await resp.json();
|
||||
renderCapabilities(root, data);
|
||||
} catch (err) {
|
||||
root.replaceChildren();
|
||||
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ function renderCapabilities(root, data) {
|
|||
root.replaceChildren();
|
||||
const { caps, descriptions = {}, assignments, effective = {} } = data;
|
||||
if (!caps || !caps.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
|
||||
root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
|
||||
updateSaveBar();
|
||||
return;
|
||||
}
|
||||
|
|
@ -84,33 +84,36 @@ function renderCapabilities(root, data) {
|
|||
// Agent rows: prefer the backend roster (every manageable agent,
|
||||
// default-perms included). Fall back to the live-container ∪ explicit
|
||||
// union for older payloads that don't carry `agents`.
|
||||
const agentNames = (data.agents && data.agents.length)
|
||||
? [...data.agents]
|
||||
: [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
const agentNames =
|
||||
data.agents && data.agents.length
|
||||
? [...data.agents]
|
||||
: [
|
||||
...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
]),
|
||||
].sort();
|
||||
|
||||
if (!agentNames.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||
root.append(el("p", { class: "meta" }, "(no agents)"));
|
||||
updateSaveBar();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = el('div', { class: 'cap-table-wrap' });
|
||||
const table = el('table', { class: 'cap-table' });
|
||||
const wrap = el("div", { class: "cap-table-wrap" });
|
||||
const table = el("table", { class: "cap-table" });
|
||||
|
||||
// Header row.
|
||||
const thead = el('thead');
|
||||
const hrow = el('tr');
|
||||
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
|
||||
const thead = el("thead");
|
||||
const hrow = el("tr");
|
||||
hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
|
||||
for (const c of caps) {
|
||||
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c));
|
||||
hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
|
||||
}
|
||||
thead.append(hrow);
|
||||
table.append(thead);
|
||||
|
||||
const tbody = el('tbody');
|
||||
const tbody = el("tbody");
|
||||
for (const name of agentNames) {
|
||||
// Effective caps (explicit-or-default) drive the checkboxes so
|
||||
// default-perms agents show their real grants, not blank.
|
||||
|
|
@ -119,19 +122,26 @@ function renderCapabilities(root, data) {
|
|||
// includes stopped-but-configured containers — so a temporarily-stopped
|
||||
// agent is NOT stale. Only destroyed/renamed agents are absent here.
|
||||
const isStale = !containersState.has(name);
|
||||
const tr = el('tr', { class: 'cap-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
|
||||
const tr = el("tr", {
|
||||
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
|
||||
"data-agent": name,
|
||||
});
|
||||
|
||||
// Agent name cell.
|
||||
const nameTd = el('td', { class: 'cap-agent-col' });
|
||||
nameTd.append(el('span', { class: 'cap-agent-name' }, name));
|
||||
const nameTd = el("td", { class: "cap-agent-col" });
|
||||
nameTd.append(el("span", { class: "cap-agent-name" }, name));
|
||||
if (isStale) {
|
||||
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
|
||||
const removeBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'perm-remove-btn',
|
||||
title: 'remove stale permission entries for ' + name,
|
||||
}, '✕ remove');
|
||||
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
|
||||
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
|
||||
const removeBtn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "perm-remove-btn",
|
||||
title: "remove stale permission entries for " + name,
|
||||
},
|
||||
"✕ remove",
|
||||
);
|
||||
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
|
||||
nameTd.append(removeBtn);
|
||||
}
|
||||
tr.append(nameTd);
|
||||
|
|
@ -139,16 +149,16 @@ function renderCapabilities(root, data) {
|
|||
// One checkbox per capability.
|
||||
for (const c of caps) {
|
||||
const checked = assigned.includes(c);
|
||||
const td = el('td', { class: 'cap-col' });
|
||||
const cb = el('input', {
|
||||
type: 'checkbox',
|
||||
class: 'cap-cb',
|
||||
'data-cap': c,
|
||||
'data-baseline': checked ? '1' : '0',
|
||||
'aria-label': c,
|
||||
const td = el("td", { class: "cap-col" });
|
||||
const cb = el("input", {
|
||||
type: "checkbox",
|
||||
class: "cap-cb",
|
||||
"data-cap": c,
|
||||
"data-baseline": checked ? "1" : "0",
|
||||
"aria-label": c,
|
||||
});
|
||||
cb.checked = checked;
|
||||
cb.addEventListener('change', onCellToggle);
|
||||
cb.addEventListener("change", onCellToggle);
|
||||
td.append(cb);
|
||||
tr.append(td);
|
||||
}
|
||||
|
|
@ -162,18 +172,18 @@ function renderCapabilities(root, data) {
|
|||
}
|
||||
|
||||
export async function fetchAndRenderToolGroups() {
|
||||
const root = $('tool-groups-section');
|
||||
const root = $("tool-groups-section");
|
||||
if (!root) return;
|
||||
root.replaceChildren();
|
||||
root.append(el('p', { class: 'meta' }, 'loading…'));
|
||||
root.append(el("p", { class: "meta" }, "loading…"));
|
||||
try {
|
||||
const resp = await fetch('/api/tool-groups');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/tool-groups");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const data = await resp.json();
|
||||
renderToolGroups(root, data);
|
||||
} catch (err) {
|
||||
root.replaceChildren();
|
||||
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
|
||||
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,40 +191,45 @@ function renderToolGroups(root, data) {
|
|||
root.replaceChildren();
|
||||
const { groups, descriptions = {}, assignments, effective = {} } = data;
|
||||
if (!groups || !groups.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
|
||||
root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
|
||||
updateSaveBar();
|
||||
return;
|
||||
}
|
||||
|
||||
// Agent rows: prefer the backend roster (default-perms agents included);
|
||||
// fall back to the live-container ∪ explicit union for older payloads.
|
||||
const agentNames = (data.agents && data.agents.length)
|
||||
? [...data.agents]
|
||||
: [...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
])].sort();
|
||||
const agentNames =
|
||||
data.agents && data.agents.length
|
||||
? [...data.agents]
|
||||
: [
|
||||
...new Set([
|
||||
...Array.from(containersState.keys()),
|
||||
...Object.keys(assignments),
|
||||
]),
|
||||
].sort();
|
||||
|
||||
if (!agentNames.length) {
|
||||
root.append(el('p', { class: 'meta' }, '(no agents)'));
|
||||
root.append(el("p", { class: "meta" }, "(no agents)"));
|
||||
updateSaveBar();
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = el('div', { class: 'tg-table-wrap' });
|
||||
const table = el('table', { class: 'tg-table' });
|
||||
const wrap = el("div", { class: "tg-table-wrap" });
|
||||
const table = el("table", { class: "tg-table" });
|
||||
|
||||
// Header row.
|
||||
const thead = el('thead');
|
||||
const hrow = el('tr');
|
||||
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
|
||||
const thead = el("thead");
|
||||
const hrow = el("tr");
|
||||
hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
|
||||
for (const g of groups) {
|
||||
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g));
|
||||
hrow.append(
|
||||
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
|
||||
);
|
||||
}
|
||||
thead.append(hrow);
|
||||
table.append(thead);
|
||||
|
||||
const tbody = el('tbody');
|
||||
const tbody = el("tbody");
|
||||
for (const name of agentNames) {
|
||||
// Effective groups (explicit-or-role-default) drive the checkboxes so
|
||||
// a default agent shows its real groups, not blank — and saving keeps
|
||||
|
|
@ -226,38 +241,47 @@ function renderToolGroups(root, data) {
|
|||
// includes stopped-but-configured containers — only destroyed/renamed
|
||||
// agents are absent.
|
||||
const isStale = !containersState.has(name);
|
||||
const tr = el('tr', { class: 'tg-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
|
||||
const tr = el("tr", {
|
||||
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
|
||||
"data-agent": name,
|
||||
});
|
||||
|
||||
// Agent name cell.
|
||||
const nameTd = el('td', { class: 'tg-agent-col' });
|
||||
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
|
||||
const nameTd = el("td", { class: "tg-agent-col" });
|
||||
nameTd.append(el("span", { class: "tg-agent-name" }, name));
|
||||
if (isStale) {
|
||||
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
|
||||
const removeBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'perm-remove-btn',
|
||||
title: 'remove stale permission entries for ' + name,
|
||||
}, '✕ remove');
|
||||
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
|
||||
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
|
||||
const removeBtn = el(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "perm-remove-btn",
|
||||
title: "remove stale permission entries for " + name,
|
||||
},
|
||||
"✕ remove",
|
||||
);
|
||||
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
|
||||
nameTd.append(removeBtn);
|
||||
} else if (!hasExplicit) {
|
||||
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
|
||||
nameTd.append(
|
||||
el("span", { class: "meta tg-default-label" }, "(default)"),
|
||||
);
|
||||
}
|
||||
tr.append(nameTd);
|
||||
|
||||
// One checkbox per group.
|
||||
for (const g of groups) {
|
||||
const checked = assigned.includes(g);
|
||||
const td = el('td', { class: 'tg-group-col' });
|
||||
const cb = el('input', {
|
||||
type: 'checkbox',
|
||||
class: 'tg-cb',
|
||||
'data-group': g,
|
||||
'data-baseline': checked ? '1' : '0',
|
||||
'aria-label': g,
|
||||
const td = el("td", { class: "tg-group-col" });
|
||||
const cb = el("input", {
|
||||
type: "checkbox",
|
||||
class: "tg-cb",
|
||||
"data-group": g,
|
||||
"data-baseline": checked ? "1" : "0",
|
||||
"aria-label": g,
|
||||
});
|
||||
cb.checked = checked;
|
||||
cb.addEventListener('change', onCellToggle);
|
||||
cb.addEventListener("change", onCellToggle);
|
||||
td.append(cb);
|
||||
tr.append(td);
|
||||
}
|
||||
|
|
@ -285,8 +309,22 @@ function onCellToggle() {
|
|||
// a full replacement → omitting it leaves that file alone).
|
||||
function collectChanges() {
|
||||
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
|
||||
collectSection($('capabilities-section'), '.cap-row', '.cap-cb', 'cap', 'capabilities', byAgent);
|
||||
collectSection($('tool-groups-section'), '.tg-row', '.tg-cb', 'group', 'tool_groups', byAgent);
|
||||
collectSection(
|
||||
$("capabilities-section"),
|
||||
".cap-row",
|
||||
".cap-cb",
|
||||
"cap",
|
||||
"capabilities",
|
||||
byAgent,
|
||||
);
|
||||
collectSection(
|
||||
$("tool-groups-section"),
|
||||
".tg-row",
|
||||
".tg-cb",
|
||||
"group",
|
||||
"tool_groups",
|
||||
byAgent,
|
||||
);
|
||||
const changes = [];
|
||||
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
|
||||
return changes;
|
||||
|
|
@ -300,7 +338,7 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
|
|||
let dirty = false;
|
||||
const selected = [];
|
||||
for (const cb of tr.querySelectorAll(cbSel)) {
|
||||
if (cb.checked !== (cb.dataset.baseline === '1')) dirty = true;
|
||||
if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
|
||||
if (cb.checked) selected.push(cb.dataset[dataKey]);
|
||||
}
|
||||
if (dirty) {
|
||||
|
|
@ -312,13 +350,14 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
|
|||
}
|
||||
|
||||
function updateSaveBar() {
|
||||
const btn = $('perm-save-all');
|
||||
const btn = $("perm-save-all");
|
||||
if (!btn) return;
|
||||
// Don't stomp a transient saving/rebuilding label.
|
||||
if (btn.dataset.busy === '1') return;
|
||||
if (btn.dataset.busy === "1") return;
|
||||
const n = collectChanges().length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = n === 0 ? 'save all' : `save all (${n} agent${n === 1 ? '' : 's'})`;
|
||||
btn.textContent =
|
||||
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
|
||||
}
|
||||
|
||||
// Remove all explicit permission entries for a stale (non-running)
|
||||
|
|
@ -328,20 +367,27 @@ function updateSaveBar() {
|
|||
// the delete so the row disappears immediately.
|
||||
async function clearStaleAgent(name, sectionRoot) {
|
||||
const btn = sectionRoot
|
||||
? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`)
|
||||
? sectionRoot.querySelector(
|
||||
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
|
||||
)
|
||||
: null;
|
||||
const doDelete = async () => {
|
||||
try {
|
||||
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
|
||||
const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => resp.status);
|
||||
setSaveNote('failed to remove ' + name + ': ' + text, true);
|
||||
setSaveNote("failed to remove " + name + ": " + text, true);
|
||||
return;
|
||||
}
|
||||
// Re-fetch both sections so the stale row disappears.
|
||||
await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]);
|
||||
await Promise.all([
|
||||
fetchAndRenderCapabilities(),
|
||||
fetchAndRenderToolGroups(),
|
||||
]);
|
||||
} catch (err) {
|
||||
setSaveNote('failed to remove ' + name + ': ' + err, true);
|
||||
setSaveNote("failed to remove " + name + ": " + err, true);
|
||||
}
|
||||
};
|
||||
// asyncBtn guards double-submit; fall through without guard when there
|
||||
|
|
@ -352,38 +398,41 @@ async function clearStaleAgent(name, sectionRoot) {
|
|||
}
|
||||
|
||||
function clearSaveStatus() {
|
||||
const note = $('perm-save-note');
|
||||
if (note) { note.textContent = ''; note.classList.remove('perm-save-err'); }
|
||||
const note = $("perm-save-note");
|
||||
if (note) {
|
||||
note.textContent = "";
|
||||
note.classList.remove("perm-save-err");
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveNote(text, isErr) {
|
||||
const note = $('perm-save-note');
|
||||
const note = $("perm-save-note");
|
||||
if (!note) return;
|
||||
note.textContent = text;
|
||||
note.classList.toggle('perm-save-err', !!isErr);
|
||||
note.classList.toggle("perm-save-err", !!isErr);
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
const btn = $('perm-save-all');
|
||||
const btn = $("perm-save-all");
|
||||
if (!btn) return;
|
||||
const changes = collectChanges();
|
||||
if (!changes.length) return;
|
||||
btn.dataset.busy = '1';
|
||||
btn.dataset.busy = "1";
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'saving…';
|
||||
btn.textContent = "saving…";
|
||||
clearSaveStatus();
|
||||
try {
|
||||
const r = await fetch('/api/permissions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const r = await fetch("/api/permissions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ changes }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const txt = await r.text();
|
||||
btn.dataset.busy = '';
|
||||
btn.dataset.busy = "";
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'save all';
|
||||
setSaveNote('save failed: ' + (txt || ('http ' + r.status)), true);
|
||||
btn.textContent = "save all";
|
||||
setSaveNote("save failed: " + (txt || "http " + r.status), true);
|
||||
updateSaveBar();
|
||||
return;
|
||||
}
|
||||
|
|
@ -391,18 +440,18 @@ async function saveAll() {
|
|||
// re-fetch both tables once the queue worker has committed — that
|
||||
// resets baselines (dirty clears) and the bar disables itself.
|
||||
const n = changes.length;
|
||||
btn.textContent = 'queued ✓';
|
||||
setSaveNote(`rebuilding ${n} agent${n === 1 ? '' : 's'}…`, false);
|
||||
btn.textContent = "queued ✓";
|
||||
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}…`, false);
|
||||
setTimeout(() => {
|
||||
btn.dataset.busy = '';
|
||||
btn.dataset.busy = "";
|
||||
fetchAndRenderCapabilities();
|
||||
fetchAndRenderToolGroups();
|
||||
}, 900);
|
||||
} catch (err) {
|
||||
btn.dataset.busy = '';
|
||||
btn.dataset.busy = "";
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'save all';
|
||||
setSaveNote('save failed: ' + String(err), true);
|
||||
btn.textContent = "save all";
|
||||
setSaveNote("save failed: " + String(err), true);
|
||||
updateSaveBar();
|
||||
}
|
||||
}
|
||||
|
|
@ -411,9 +460,9 @@ async function saveAll() {
|
|||
// after the DOM is ready (the button lives in the static permissions
|
||||
// pane markup, so it exists before any fetch).
|
||||
export function initPermissions() {
|
||||
const btn = $('perm-save-all');
|
||||
const btn = $("perm-save-all");
|
||||
if (btn && !btn.dataset.bound) {
|
||||
btn.dataset.bound = '1';
|
||||
btn.addEventListener('click', saveAll);
|
||||
btn.dataset.bound = "1";
|
||||
btn.addEventListener("click", saveAll);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,55 +1,63 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>hyperhive // ST4TS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/colors.css">
|
||||
<link rel="stylesheet" href="/static/theme.css">
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/stats.css">
|
||||
</head>
|
||||
<body class="stats-shell">
|
||||
|
||||
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // ST4TS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/stats.css" />
|
||||
</head>
|
||||
<body class="stats-shell">
|
||||
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
|
||||
logs.html — no full dashboard tabbar. Back link points to the
|
||||
H0M3 hub (served at /), not the dashboard. -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">ST4TS</span>
|
||||
<!-- Window selector lives in the header so it behaves like a tab strip
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<span class="page-title">ST4TS</span>
|
||||
<!-- Window selector lives in the header so it behaves like a tab strip
|
||||
(matches the pattern on /logs.html). Hash-routed via createTabStrip. -->
|
||||
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
|
||||
<button type="button" class="btn" data-tab="1h">1h</button>
|
||||
<button type="button" class="btn" data-tab="4h">4h</button>
|
||||
<button type="button" class="btn" data-tab="24h">24h</button>
|
||||
<button type="button" class="btn" data-tab="3d">3d</button>
|
||||
<button type="button" class="btn" data-tab="7d">7d</button>
|
||||
<button type="button" class="btn" data-tab="30d">30d</button>
|
||||
<button type="button" class="btn" data-tab="all">all</button>
|
||||
</nav>
|
||||
</header>
|
||||
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
|
||||
<button type="button" class="btn" data-tab="1h">1h</button>
|
||||
<button type="button" class="btn" data-tab="4h">4h</button>
|
||||
<button type="button" class="btn" data-tab="24h">24h</button>
|
||||
<button type="button" class="btn" data-tab="3d">3d</button>
|
||||
<button type="button" class="btn" data-tab="7d">7d</button>
|
||||
<button type="button" class="btn" data-tab="30d">30d</button>
|
||||
<button type="button" class="btn" data-tab="all">all</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="stats-main">
|
||||
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
|
||||
<main class="stats-main">
|
||||
<p class="meta">
|
||||
hive-wide turn statistics, aggregated across every agent over the
|
||||
selected window. <strong>cost is a rough estimate</strong> from
|
||||
approximate per-model list prices — it drifts and is a ballpark, not a
|
||||
bill.
|
||||
</p>
|
||||
|
||||
<div class="hive-stats-chips" id="hive-stats-summary"></div>
|
||||
<h3>◇ busiest agents</h3>
|
||||
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
||||
<h3>◇ model mix (turns across the swarm)</h3>
|
||||
<div id="hive-stats-models"></div>
|
||||
<!-- "favorite tools": most-run bash commands across the swarm.
|
||||
<div class="hive-stats-chips" id="hive-stats-summary"></div>
|
||||
<h3>◇ busiest agents</h3>
|
||||
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
|
||||
<h3>◇ model mix (turns across the swarm)</h3>
|
||||
<div id="hive-stats-models"></div>
|
||||
<!-- "favorite tools": most-run bash commands across the swarm.
|
||||
Header + list hidden until the hive-bash-daemon capture has
|
||||
recorded data, so the section never shows an empty block. -->
|
||||
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
|
||||
<div id="hive-stats-bash" hidden></div>
|
||||
<!-- most-triggered skills across the swarm. Header + list hidden until
|
||||
<h3 id="hive-stats-bash-h" hidden>
|
||||
◇ favorite tools (bash commands across the swarm)
|
||||
</h3>
|
||||
<div id="hive-stats-bash" hidden></div>
|
||||
<!-- most-triggered skills across the swarm. Header + list hidden until
|
||||
at least one agent has actually invoked a skill. -->
|
||||
<h3 id="hive-stats-skills-h" hidden>◇ skill mix (invocations across the swarm)</h3>
|
||||
<div id="hive-stats-skills" hidden></div>
|
||||
</main>
|
||||
<h3 id="hive-stats-skills-h" hidden>
|
||||
◇ skill mix (invocations across the swarm)
|
||||
</h3>
|
||||
<div id="hive-stats-skills" hidden></div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/stats.js" defer></script>
|
||||
</body>
|
||||
<script type="module" src="/static/stats.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,39 +5,45 @@
|
|||
// this bundle has no chart lib; per-agent trend charts live on each
|
||||
// agent's own /stats page. The window selector is a hash-routed
|
||||
// createTabStrip (#1h / #24h / …), matching the per-agent stats page.
|
||||
import { $, initServerWarnings } from './common.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import { $, initServerWarnings } from "./common.js";
|
||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||
|
||||
let hiveStatsWindow = '24h';
|
||||
let hiveStatsWindow = "24h";
|
||||
|
||||
function hsFmtInt(n) {
|
||||
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
|
||||
return Number.isFinite(n)
|
||||
? new Intl.NumberFormat().format(Math.round(n))
|
||||
: "0";
|
||||
}
|
||||
function hsFmtTokens(n) {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
|
||||
if (!Number.isFinite(n)) return "0";
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
function hsFmtUsd(n) {
|
||||
if (!Number.isFinite(n)) return '$0';
|
||||
if (n >= 100) return '$' + n.toFixed(0);
|
||||
if (n >= 1) return '$' + n.toFixed(2);
|
||||
return '$' + n.toFixed(3);
|
||||
if (!Number.isFinite(n)) return "$0";
|
||||
if (n >= 100) return "$" + n.toFixed(0);
|
||||
if (n >= 1) return "$" + n.toFixed(2);
|
||||
return "$" + n.toFixed(3);
|
||||
}
|
||||
function hsChip(parent, label, value, est) {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'hive-stats-chip' + (est ? ' est' : '');
|
||||
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
|
||||
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
|
||||
const c = document.createElement("span");
|
||||
c.className = "hive-stats-chip" + (est ? " est" : "");
|
||||
const k = document.createElement("span");
|
||||
k.className = "k";
|
||||
k.textContent = label;
|
||||
const v = document.createElement("span");
|
||||
v.className = "v";
|
||||
v.textContent = value;
|
||||
c.append(k, v);
|
||||
parent.append(c);
|
||||
}
|
||||
function hsMeta(parent, text) {
|
||||
parent.replaceChildren();
|
||||
const p = document.createElement('p');
|
||||
p.className = 'meta';
|
||||
const p = document.createElement("p");
|
||||
p.className = "meta";
|
||||
p.textContent = text;
|
||||
parent.append(p);
|
||||
}
|
||||
|
|
@ -46,13 +52,20 @@ function renderKeyCountBars(container, mix) {
|
|||
container.replaceChildren();
|
||||
const max = mix[0].count || 1;
|
||||
for (const kc of mix) {
|
||||
const row = document.createElement('div'); row.className = 'hive-stats-bar';
|
||||
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
|
||||
const track = document.createElement('span'); track.className = 'track';
|
||||
const fill = document.createElement('span'); fill.className = 'fill';
|
||||
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
|
||||
const row = document.createElement("div");
|
||||
row.className = "hive-stats-bar";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "lbl";
|
||||
lbl.textContent = kc.key;
|
||||
const track = document.createElement("span");
|
||||
track.className = "track";
|
||||
const fill = document.createElement("span");
|
||||
fill.className = "fill";
|
||||
fill.style.width = Math.max(2, Math.round((100 * kc.count) / max)) + "%";
|
||||
track.append(fill);
|
||||
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
|
||||
const cnt = document.createElement("span");
|
||||
cnt.className = "cnt";
|
||||
cnt.textContent = hsFmtInt(kc.count);
|
||||
row.append(lbl, track, cnt);
|
||||
container.append(row);
|
||||
}
|
||||
|
|
@ -76,43 +89,50 @@ function renderOptionalMix(containerId, headerId, mix) {
|
|||
}
|
||||
|
||||
function renderHiveStats(s) {
|
||||
const sum = $('hive-stats-summary');
|
||||
const sum = $("hive-stats-summary");
|
||||
if (sum) {
|
||||
sum.replaceChildren();
|
||||
hsChip(sum, 'window', s.window);
|
||||
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
|
||||
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
|
||||
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
|
||||
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
|
||||
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
|
||||
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
|
||||
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
|
||||
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
|
||||
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
|
||||
hsChip(sum, "window", s.window);
|
||||
hsChip(sum, "active agents", hsFmtInt(s.active_agents));
|
||||
hsChip(sum, "turns", hsFmtInt(s.total_turns));
|
||||
const totalTok =
|
||||
(s.total_input_tokens || 0) +
|
||||
(s.total_output_tokens || 0) +
|
||||
(s.total_cache_read_tokens || 0) +
|
||||
(s.total_cache_creation_tokens || 0);
|
||||
hsChip(sum, "tokens", hsFmtTokens(totalTok));
|
||||
hsChip(sum, "input", hsFmtTokens(s.total_input_tokens));
|
||||
hsChip(sum, "output", hsFmtTokens(s.total_output_tokens));
|
||||
hsChip(sum, "cache read", hsFmtTokens(s.total_cache_read_tokens));
|
||||
hsChip(sum, "est cost", hsFmtUsd(s.est_cost_usd), true);
|
||||
}
|
||||
|
||||
const at = $('hive-stats-agents');
|
||||
const at = $("hive-stats-agents");
|
||||
if (at) {
|
||||
const agents = s.agents || [];
|
||||
if (!agents.length) {
|
||||
hsMeta(at, 'no turns in window');
|
||||
hsMeta(at, "no turns in window");
|
||||
} else {
|
||||
at.replaceChildren();
|
||||
const table = document.createElement('table');
|
||||
table.className = 'hive-stats-table';
|
||||
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
|
||||
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
|
||||
const tb = document.createElement('tbody');
|
||||
const table = document.createElement("table");
|
||||
table.className = "hive-stats-table";
|
||||
table.innerHTML =
|
||||
"<thead><tr><th>agent</th><th>turns</th><th>input</th>" +
|
||||
"<th>output</th><th>cache read</th><th>est cost</th></tr></thead>";
|
||||
const tb = document.createElement("tbody");
|
||||
for (const a of agents) {
|
||||
const tr = document.createElement('tr');
|
||||
const tr = document.createElement("tr");
|
||||
const cells = [
|
||||
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
|
||||
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
|
||||
a.name,
|
||||
hsFmtInt(a.turns),
|
||||
hsFmtTokens(a.input_tokens),
|
||||
hsFmtTokens(a.output_tokens),
|
||||
hsFmtTokens(a.cache_read_tokens),
|
||||
hsFmtUsd(a.est_cost_usd),
|
||||
];
|
||||
cells.forEach((txt, i) => {
|
||||
const td = document.createElement('td');
|
||||
if (i > 0) td.className = 'num';
|
||||
const td = document.createElement("td");
|
||||
if (i > 0) td.className = "num";
|
||||
td.textContent = txt;
|
||||
tr.append(td);
|
||||
});
|
||||
|
|
@ -123,27 +143,33 @@ function renderHiveStats(s) {
|
|||
}
|
||||
}
|
||||
|
||||
const mm = $('hive-stats-models');
|
||||
const mm = $("hive-stats-models");
|
||||
if (mm) {
|
||||
const mix = s.model_mix || [];
|
||||
if (!mix.length) hsMeta(mm, 'no turns in window');
|
||||
if (!mix.length) hsMeta(mm, "no turns in window");
|
||||
else renderKeyCountBars(mm, mix);
|
||||
}
|
||||
|
||||
// "favorite tools": most-run bash commands across the swarm.
|
||||
renderOptionalMix('hive-stats-bash', 'hive-stats-bash-h', s.bash_mix || []);
|
||||
renderOptionalMix("hive-stats-bash", "hive-stats-bash-h", s.bash_mix || []);
|
||||
// Most-triggered skills across the swarm.
|
||||
renderOptionalMix('hive-stats-skills', 'hive-stats-skills-h', s.skill_mix || []);
|
||||
renderOptionalMix(
|
||||
"hive-stats-skills",
|
||||
"hive-stats-skills-h",
|
||||
s.skill_mix || [],
|
||||
);
|
||||
}
|
||||
|
||||
async function refreshHiveStats() {
|
||||
try {
|
||||
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch(
|
||||
"/api/stats-hive?window=" + encodeURIComponent(hiveStatsWindow),
|
||||
);
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
renderHiveStats(await resp.json());
|
||||
} catch (e) {
|
||||
const at = $('hive-stats-agents');
|
||||
if (at) hsMeta(at, 'stats fetch failed: ' + e);
|
||||
const at = $("hive-stats-agents");
|
||||
if (at) hsMeta(at, "stats fetch failed: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +180,10 @@ initServerWarnings();
|
|||
// tab and the strip's `if (pane)` guard handles it. The initial show()
|
||||
// fires onShow once → sets the window + does the first fetch, so no
|
||||
// separate refreshHiveStats() call is needed.
|
||||
createTabStrip(document.getElementById('hive-stats-windows'), {
|
||||
createTabStrip(document.getElementById("hive-stats-windows"), {
|
||||
defaultId: hiveStatsWindow,
|
||||
onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); },
|
||||
onShow: (w) => {
|
||||
hiveStatsWindow = w;
|
||||
refreshHiveStats();
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,8 +33,11 @@ const allPorts = new Set();
|
|||
const PING_INTERVAL_MS = 30_000;
|
||||
setInterval(() => {
|
||||
for (const port of allPorts) {
|
||||
try { port.postMessage({ kind: 'ping' }); }
|
||||
catch { /* port dead — left in the Set; see onconnect's closing comment */ }
|
||||
try {
|
||||
port.postMessage({ kind: "ping" });
|
||||
} catch {
|
||||
/* port dead — left in the Set; see onconnect's closing comment */
|
||||
}
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
|
||||
|
|
@ -45,20 +48,29 @@ function getOrCreateStream(url) {
|
|||
entry = { es, url, ports: new Set() };
|
||||
es.onopen = () => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'open', url }); }
|
||||
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
|
||||
try {
|
||||
port.postMessage({ kind: "open", url });
|
||||
} catch {
|
||||
/* port dead — cleanup happens on unsubscribe / next subscribe */
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'message', url, data: e.data }); }
|
||||
catch { /* same */ }
|
||||
try {
|
||||
port.postMessage({ kind: "message", url, data: e.data });
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
for (const port of entry.ports) {
|
||||
try { port.postMessage({ kind: 'error', url }); }
|
||||
catch { /* same */ }
|
||||
try {
|
||||
port.postMessage({ kind: "error", url });
|
||||
} catch {
|
||||
/* same */
|
||||
}
|
||||
}
|
||||
};
|
||||
streams.set(url, entry);
|
||||
|
|
@ -81,8 +93,8 @@ self.onconnect = (connectEvent) => {
|
|||
const subscribedUrls = new Set();
|
||||
port.onmessage = (e) => {
|
||||
const msg = e.data;
|
||||
if (!msg || typeof msg.url !== 'string') return;
|
||||
if (msg.kind === 'subscribe') {
|
||||
if (!msg || typeof msg.url !== "string") return;
|
||||
if (msg.kind === "subscribe") {
|
||||
if (subscribedUrls.has(msg.url)) return; // idempotent
|
||||
const entry = getOrCreateStream(msg.url);
|
||||
entry.ports.add(port);
|
||||
|
|
@ -93,10 +105,13 @@ self.onconnect = (connectEvent) => {
|
|||
// the next reconnect. Hand the new tab the open event explicitly
|
||||
// so its onStreamOpen handler runs.
|
||||
if (entry.es.readyState === EventSource.OPEN) {
|
||||
try { port.postMessage({ kind: 'open', url: msg.url }); }
|
||||
catch { /* port dead immediately — give up */ }
|
||||
try {
|
||||
port.postMessage({ kind: "open", url: msg.url });
|
||||
} catch {
|
||||
/* port dead immediately — give up */
|
||||
}
|
||||
}
|
||||
} else if (msg.kind === 'unsubscribe') {
|
||||
} else if (msg.kind === "unsubscribe") {
|
||||
if (!subscribedUrls.has(msg.url)) return;
|
||||
unsubscribe(port, msg.url);
|
||||
subscribedUrls.delete(msg.url);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -23,9 +23,17 @@
|
|||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.meta-input-name { color: var(--amber); font-weight: bold; }
|
||||
.meta-input-rev { color: var(--muted); }
|
||||
.meta-input-ts { color: var(--muted); font-size: 0.85em; }
|
||||
.meta-input-name {
|
||||
color: var(--amber);
|
||||
font-weight: bold;
|
||||
}
|
||||
.meta-input-rev {
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta-input-ts {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.meta-input-url {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
|
|
@ -67,7 +75,9 @@
|
|||
font-size: 0.85em;
|
||||
letter-spacing: 0.08em;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, background 0.15s ease;
|
||||
transition:
|
||||
box-shadow 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
.btn-meta-update:hover:not([disabled]) {
|
||||
background: color-mix(in srgb, var(--purple) 22%, transparent);
|
||||
|
|
@ -105,8 +115,13 @@
|
|||
align-items: baseline;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.rqe-kind { color: var(--cyan); }
|
||||
.rqe-agent { color: var(--amber); font-weight: bold; }
|
||||
.rqe-kind {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.rqe-agent {
|
||||
color: var(--amber);
|
||||
font-weight: bold;
|
||||
}
|
||||
.rqe-source {
|
||||
font-size: 0.75em;
|
||||
padding: 0.05em 0.45em;
|
||||
|
|
@ -116,11 +131,25 @@
|
|||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); }
|
||||
.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); }
|
||||
.rqe-source-auto_update { color: var(--muted); }
|
||||
.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
|
||||
.rqe-source-approval { color: var(--green); border-color: var(--green); }
|
||||
.rqe-source-manual {
|
||||
color: var(--cyan);
|
||||
border-color: var(--cyan);
|
||||
}
|
||||
.rqe-source-meta_update {
|
||||
color: var(--purple);
|
||||
border-color: var(--purple);
|
||||
}
|
||||
.rqe-source-auto_update {
|
||||
color: var(--muted);
|
||||
}
|
||||
.rqe-source-crash_recover {
|
||||
color: var(--amber);
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.rqe-source-approval {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
/* ─── running-rebuild live log (renderRebuildLiveLog in builds.js) ──────────
|
||||
Streams the currently-running rebuild's build log inline under the queue.
|
||||
Hidden (native `hidden` attr) when nothing is building. Used on
|
||||
|
|
@ -149,15 +178,21 @@
|
|||
font-size: 1em;
|
||||
padding: 0 0.2em;
|
||||
}
|
||||
.rebuild-live-log-toggle:hover { color: var(--fg); }
|
||||
.rebuild-live-log-title { color: var(--muted); }
|
||||
.rebuild-live-log-toggle:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.rebuild-live-log-title {
|
||||
color: var(--muted);
|
||||
}
|
||||
.rebuild-live-log-raw {
|
||||
margin-left: auto;
|
||||
color: var(--purple);
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.rebuild-live-log-raw:hover { text-decoration: underline; }
|
||||
.rebuild-live-log-raw:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rebuild-live-log-badge {
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
|
|
@ -184,7 +219,8 @@
|
|||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-family:
|
||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
color: var(--fg);
|
||||
|
|
|
|||
|
|
@ -11,41 +11,47 @@
|
|||
// `dashboard.html` each load their own bundle.
|
||||
// SW4RM (containers) domain lives in `./swarm.js`.
|
||||
|
||||
import { marked } from 'marked';
|
||||
import { marked } from "marked";
|
||||
import { $, NOTIF, openStream, renderServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import { bindAsyncForms } from "@hive/shared/forms.js";
|
||||
import { createTabStrip } from "@hive/shared/tabs.js";
|
||||
import { containersState, syncContainersFromSnapshot } from "./state.js";
|
||||
import { fmtAgo, fmtDuration } from "./util.js";
|
||||
import {
|
||||
$,
|
||||
NOTIF,
|
||||
openStream, renderServerWarnings,
|
||||
} from './common.js';
|
||||
import { el } from '@hive/shared/dom.js';
|
||||
import { bindAsyncForms } from '@hive/shared/forms.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
import {
|
||||
containersState, syncContainersFromSnapshot,
|
||||
} from './state.js';
|
||||
import { fmtAgo, fmtDuration } from './util.js';
|
||||
import {
|
||||
applyCapabilitiesChanged, applyToolGroupsChanged,
|
||||
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
|
||||
applyCapabilitiesChanged,
|
||||
applyToolGroupsChanged,
|
||||
fetchAndRenderCapabilities,
|
||||
fetchAndRenderToolGroups,
|
||||
initPermissions,
|
||||
} from './permissions.js';
|
||||
} from "./permissions.js";
|
||||
import {
|
||||
applySchedulesChanged,
|
||||
refreshSchedules, activeScheduleCount,
|
||||
} from './schedules.js';
|
||||
refreshSchedules,
|
||||
activeScheduleCount,
|
||||
} from "./schedules.js";
|
||||
import {
|
||||
initCall,
|
||||
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
|
||||
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
|
||||
renderApprovals, activeApprovalCount,
|
||||
} from './call.js';
|
||||
refreshOperatorInbox,
|
||||
operatorInboxAppendFromEvent,
|
||||
operatorInboxCount,
|
||||
syncApprovalsFromSnapshot,
|
||||
applyApprovalAdded,
|
||||
applyApprovalResolved,
|
||||
renderApprovals,
|
||||
activeApprovalCount,
|
||||
} from "./call.js";
|
||||
import {
|
||||
initJobqRollup, syncTransientsFromSnapshot,
|
||||
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
|
||||
applyTransientSet, applyTransientCleared,
|
||||
initJobqRollup,
|
||||
syncTransientsFromSnapshot,
|
||||
applyRebuildQueueChanged,
|
||||
applyContainerStateChanged,
|
||||
applyContainerRemoved,
|
||||
applyTransientSet,
|
||||
applyTransientCleared,
|
||||
renderContainers,
|
||||
renderSelectionBar,
|
||||
} from './swarm.js';
|
||||
} from "./swarm.js";
|
||||
|
||||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||
// panel preview path. Set it here on the dashboard entry so file
|
||||
|
|
@ -56,7 +62,7 @@ window.marked = marked;
|
|||
// Track which items we've already notified about so a re-render
|
||||
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
||||
// when the page reloads.
|
||||
const seenApprovals = new Set();
|
||||
const seenApprovals = new Set();
|
||||
let seededNotify = false;
|
||||
|
||||
function notifyDeltas(s) {
|
||||
|
|
@ -75,11 +81,17 @@ window.marked = marked;
|
|||
for (const a of approvals) {
|
||||
if (seenApprovals.has(a.id)) continue;
|
||||
seenApprovals.add(a.id);
|
||||
const verb = a.kind === 'spawn' ? 'spawn approval'
|
||||
: a.kind === 'init_config' ? 'config-init approval'
|
||||
: 'config commit';
|
||||
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
|
||||
'hyperhive:approval:' + a.id);
|
||||
const verb =
|
||||
a.kind === "spawn"
|
||||
? "spawn approval"
|
||||
: a.kind === "init_config"
|
||||
? "config-init approval"
|
||||
: "config commit";
|
||||
NOTIF.show(
|
||||
"◆ approval #" + a.id,
|
||||
`${verb} for ${a.agent}`,
|
||||
"hyperhive:approval:" + a.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -96,20 +108,21 @@ window.marked = marked;
|
|||
// the renderers so this loop can refresh them without a full re-render).
|
||||
setInterval(() => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
|
||||
const requestedAt = Number(node.getAttribute('data-requested-at'));
|
||||
if (!Number.isFinite(requestedAt)) return;
|
||||
const ageSec = Math.max(0, now - requestedAt);
|
||||
node.textContent = 'requested ' + fmtAgo(requestedAt);
|
||||
node.classList.toggle('stale', ageSec >= 3600);
|
||||
});
|
||||
document.querySelectorAll('.sched-due[data-due-at]').forEach((node) => {
|
||||
const dueAt = Number(node.getAttribute('data-due-at'));
|
||||
document
|
||||
.querySelectorAll(".approval-ts[data-requested-at]")
|
||||
.forEach((node) => {
|
||||
const requestedAt = Number(node.getAttribute("data-requested-at"));
|
||||
if (!Number.isFinite(requestedAt)) return;
|
||||
const ageSec = Math.max(0, now - requestedAt);
|
||||
node.textContent = "requested " + fmtAgo(requestedAt);
|
||||
node.classList.toggle("stale", ageSec >= 3600);
|
||||
});
|
||||
document.querySelectorAll(".sched-due[data-due-at]").forEach((node) => {
|
||||
const dueAt = Number(node.getAttribute("data-due-at"));
|
||||
if (!Number.isFinite(dueAt)) return;
|
||||
const dueIn = dueAt - now;
|
||||
node.textContent = dueIn <= 0
|
||||
? 'overdue ' + fmtAgo(dueAt)
|
||||
: fmtDuration(dueIn);
|
||||
node.textContent =
|
||||
dueIn <= 0 ? "overdue " + fmtAgo(dueAt) : fmtDuration(dueIn);
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
|
|
@ -119,12 +132,12 @@ window.marked = marked;
|
|||
// operator is typing in one of them, skip the refresh — the next
|
||||
// tick (or a manual action) will pick it up after they blur.
|
||||
const MANAGED_SECTION_IDS = [
|
||||
'containers-section',
|
||||
'inbox-section',
|
||||
'approvals-section',
|
||||
'schedules-section',
|
||||
'capabilities-section',
|
||||
'tool-groups-section',
|
||||
"containers-section",
|
||||
"inbox-section",
|
||||
"approvals-section",
|
||||
"schedules-section",
|
||||
"capabilities-section",
|
||||
"tool-groups-section",
|
||||
];
|
||||
// <details> sections that should survive a refresh need a stable
|
||||
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
||||
|
|
@ -138,7 +151,7 @@ window.marked = marked;
|
|||
for (const id of MANAGED_SECTION_IDS) {
|
||||
const sect = document.getElementById(id);
|
||||
if (!sect) continue;
|
||||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||||
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
|
||||
if (d.open) open.add(d.dataset.restoreKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -149,7 +162,7 @@ window.marked = marked;
|
|||
for (const id of MANAGED_SECTION_IDS) {
|
||||
const sect = document.getElementById(id);
|
||||
if (!sect) continue;
|
||||
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
||||
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
|
||||
if (open.has(d.dataset.restoreKey)) d.open = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -159,7 +172,7 @@ window.marked = marked;
|
|||
const el_ = document.activeElement;
|
||||
if (!el_ || el_ === document.body) return false;
|
||||
const tag = el_.tagName;
|
||||
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
||||
if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") return false;
|
||||
return MANAGED_SECTION_IDS.some((id) => {
|
||||
const sect = document.getElementById(id);
|
||||
return sect && sect.contains(el_);
|
||||
|
|
@ -176,8 +189,8 @@ window.marked = marked;
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) throw new Error("http " + resp.status);
|
||||
const s = await resp.json();
|
||||
// Stash the latest snapshot for any sub-widget that wants a
|
||||
// synchronous read (e.g. the compose autocomplete pulls agent
|
||||
|
|
@ -190,19 +203,18 @@ window.marked = marked;
|
|||
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
|
||||
// (set by services.hyperhive.{hiveName,swarmName} nix options).
|
||||
// When unset we fall back gracefully — the headline stays hidden.
|
||||
const hiveId = $('swarm-identity');
|
||||
const hiveId = $("swarm-identity");
|
||||
if (hiveId) {
|
||||
const hive = s.hive_name;
|
||||
const swarm = s.swarm_name;
|
||||
if (hive || swarm) {
|
||||
const label = swarm && hive ? `${swarm} / ${hive}`
|
||||
: hive || swarm;
|
||||
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
|
||||
hiveId.textContent = label;
|
||||
hiveId.hidden = false;
|
||||
// Preserve any (N) call-count prefix already applied by
|
||||
// refreshTabCounts so the title doesn't flicker on reload.
|
||||
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
|
||||
document.title = existingPrefix + label + ' // h1ve-c0re';
|
||||
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || "";
|
||||
document.title = existingPrefix + label + " // h1ve-c0re";
|
||||
}
|
||||
}
|
||||
const openDetails = snapshotOpenDetails();
|
||||
|
|
@ -235,9 +247,12 @@ window.marked = marked;
|
|||
// /api/state fetches are the initial cold load and the
|
||||
// post-submit refetch on forms without `data-no-refresh`
|
||||
// (tombstones, meta-input updates).
|
||||
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('refreshState failed', err);
|
||||
console.error("refreshState failed", err);
|
||||
// Schedule a single retry on transient errors so the page
|
||||
// recovers from a brief network blip without making the
|
||||
// operator reload.
|
||||
|
|
@ -275,20 +290,20 @@ window.marked = marked;
|
|||
// and filter client-side — the dashboard ignores broker traffic
|
||||
// and the inbox ignores mutation events.
|
||||
const MUTATION_HANDLERS = {
|
||||
approval_added: applyApprovalAdded,
|
||||
approval_added: applyApprovalAdded,
|
||||
approval_resolved: applyApprovalResolved,
|
||||
transient_set: applyTransientSet,
|
||||
transient_set: applyTransientSet,
|
||||
transient_cleared: applyTransientCleared,
|
||||
container_state_changed: applyContainerStateChanged,
|
||||
container_removed: applyContainerRemoved,
|
||||
container_removed: applyContainerRemoved,
|
||||
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner
|
||||
// (see swarm.js) — a payload-less push trigger, same treatment
|
||||
// /builds.html gives it for its JobqGraph mount handle's .refresh()
|
||||
// (its own separate subscription).
|
||||
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||
schedules_changed: applySchedulesChanged,
|
||||
capabilities_changed: applyCapabilitiesChanged,
|
||||
tool_groups_changed: applyToolGroupsChanged,
|
||||
rebuild_queue_changed: applyRebuildQueueChanged,
|
||||
schedules_changed: applySchedulesChanged,
|
||||
capabilities_changed: applyCapabilitiesChanged,
|
||||
tool_groups_changed: applyToolGroupsChanged,
|
||||
};
|
||||
(function bindDashboardStream() {
|
||||
// Route through the SharedWorker so all open hyperhive tabs on the
|
||||
|
|
@ -305,24 +320,31 @@ window.marked = marked;
|
|||
// `/api/dashboard/stream` subscribers before this (subscription
|
||||
// discipline, part 1 of the dashboard-event-stream-split issue).
|
||||
const es = openStream(
|
||||
'/api/dashboard/stream?kinds=sent,approval_added,approval_resolved,' +
|
||||
'transient_set,transient_cleared,' +
|
||||
'container_state_changed,container_removed,rebuild_queue_changed,' +
|
||||
'schedules_changed,capabilities_changed,tool_groups_changed',
|
||||
"/api/dashboard/stream?kinds=sent,approval_added,approval_resolved," +
|
||||
"transient_set,transient_cleared," +
|
||||
"container_state_changed,container_removed,rebuild_queue_changed," +
|
||||
"schedules_changed,capabilities_changed,tool_groups_changed",
|
||||
);
|
||||
es.onmessage = (e) => {
|
||||
let ev;
|
||||
try { ev = JSON.parse(e.data); } catch { return; }
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Broker `sent` frames aren't mutation events, but the operator
|
||||
// inbox cares about ones addressed to "operator".
|
||||
if (ev.kind === 'sent' && ev.to === 'operator') {
|
||||
if (ev.kind === "sent" && ev.to === "operator") {
|
||||
operatorInboxAppendFromEvent(ev);
|
||||
return;
|
||||
}
|
||||
const h = MUTATION_HANDLERS[ev.kind];
|
||||
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
||||
try { h(ev); }
|
||||
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
|
||||
try {
|
||||
h(ev);
|
||||
} catch (err) {
|
||||
console.error("dashboard SSE handler", ev.kind, err);
|
||||
}
|
||||
};
|
||||
es.onopen = () => {
|
||||
// Re-sync to recover events that fired during the SSE disconnect
|
||||
|
|
@ -334,7 +356,7 @@ window.marked = marked;
|
|||
};
|
||||
es.onerror = () => {
|
||||
// EventSource auto-reconnects; nothing to do beyond logging.
|
||||
console.debug('dashboard SSE error, will retry');
|
||||
console.debug("dashboard SSE error, will retry");
|
||||
};
|
||||
})();
|
||||
|
||||
|
|
@ -360,38 +382,41 @@ window.marked = marked;
|
|||
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
||||
// re-sync covers disconnect windows / approval-path inserts that
|
||||
// don't yet emit.
|
||||
if (target === 'schedules') { refreshSchedules(); }
|
||||
if (target === "schedules") {
|
||||
refreshSchedules();
|
||||
}
|
||||
// Permissions tables: SSE covers worker-applied changes
|
||||
// (capabilities_changed / tool_groups_changed); re-fetch on
|
||||
// activation as a safety net for any gap between SSE events and
|
||||
// the cold-load snapshot.
|
||||
if (target === 'permissions') {
|
||||
if (target === "permissions") {
|
||||
initPermissions();
|
||||
fetchAndRenderCapabilities();
|
||||
fetchAndRenderToolGroups();
|
||||
}
|
||||
if (target === 'call') { refreshOperatorInbox(); }
|
||||
if (target === "call") {
|
||||
refreshOperatorInbox();
|
||||
}
|
||||
}
|
||||
|
||||
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
||||
// calls are defined. The strip resolves the active tab from the hash
|
||||
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
||||
// fires activateTab for the per-tab side-effects on every change.
|
||||
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
|
||||
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
|
||||
|
||||
// Register the Y3R C4LL domain's count callback (call.js) — its live
|
||||
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
|
||||
// through this instead of reaching back into the coordinator directly.
|
||||
initCall({ onCountsChanged: refreshTabCounts });
|
||||
|
||||
|
||||
// Tab count pills — pure derived data from the existing state
|
||||
// stores so SSE-driven updates flow through without extra plumbing.
|
||||
// Set `hidden` when the count is zero so the pill doesn't draw
|
||||
// attention to an empty room.
|
||||
|
||||
function setTabCount(tab, n) {
|
||||
const el_ = $('tab-count-' + tab);
|
||||
const el_ = $("tab-count-" + tab);
|
||||
if (!el_) return;
|
||||
el_.textContent = String(n);
|
||||
el_.hidden = n <= 0;
|
||||
|
|
@ -406,22 +431,20 @@ window.marked = marked;
|
|||
for (const c of containersState.values()) {
|
||||
if (c.needs_update) swarm++;
|
||||
}
|
||||
setTabCount('swarm', swarm);
|
||||
setTabCount("swarm", swarm);
|
||||
// Y3R C4LL — pending approvals + unread agent->operator messages.
|
||||
const callCount =
|
||||
activeApprovalCount() +
|
||||
operatorInboxCount();
|
||||
setTabCount('call', callCount);
|
||||
const callCount = activeApprovalCount() + operatorInboxCount();
|
||||
setTabCount("call", callCount);
|
||||
// Browser tab title prefix — lets the operator see the pending
|
||||
// call count without switching to the window. Strips any existing
|
||||
// `(N) ` prefix before re-applying so identity-title updates
|
||||
// (which run once on state load, not every tick) compose cleanly.
|
||||
const rawTitle = document.title.replace(/^\(\d+\) /, '');
|
||||
const rawTitle = document.title.replace(/^\(\d+\) /, "");
|
||||
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
|
||||
// SCH3DUL3S — count of schedules with at least one still-active
|
||||
// target (whole-schedule cancellation or all-targets-cancelled
|
||||
// means "not waiting on the worker"; those don't pull attention).
|
||||
setTabCount('schedules', activeScheduleCount());
|
||||
setTabCount("schedules", activeScheduleCount());
|
||||
}
|
||||
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||
// sync. The state stores are mutated synchronously by every SSE
|
||||
|
|
|
|||
|
|
@ -24,35 +24,46 @@ export function paintAtomic(liveRoot, build) {
|
|||
// meta inputs, turn stats) still carry unix-second numbers, so
|
||||
// numbers pass through unchanged.
|
||||
export function epochSec(ts) {
|
||||
return typeof ts === 'number' ? ts : Math.floor(Date.parse(ts) / 1000);
|
||||
return typeof ts === "number" ? ts : Math.floor(Date.parse(ts) / 1000);
|
||||
}
|
||||
|
||||
// Relative age of a timestamp (RFC 3339 string or unix seconds),
|
||||
// coarsened to one unit ("5m ago").
|
||||
export function fmtAgo(ts) {
|
||||
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - epochSec(ts)));
|
||||
if (ageSec < 60) return ageSec + 's ago';
|
||||
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
|
||||
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
|
||||
return Math.floor(ageSec / 86400) + 'd ago';
|
||||
if (ageSec < 60) return ageSec + "s ago";
|
||||
if (ageSec < 3600) return Math.floor(ageSec / 60) + "m ago";
|
||||
if (ageSec < 86400) return Math.floor(ageSec / 3600) + "h ago";
|
||||
return Math.floor(ageSec / 86400) + "d ago";
|
||||
}
|
||||
|
||||
// Truncate a string to `n` chars, appending an ellipsis when clipped.
|
||||
export function truncate(s, n) {
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + '…';
|
||||
return s.length <= n ? s : s.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
// Running-duration label for in-flight items ("3m 12s running").
|
||||
export function fmtElapsed(secs) {
|
||||
if (secs < 60) return secs + 's running';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
|
||||
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
|
||||
if (secs < 60) return secs + "s running";
|
||||
if (secs < 3600)
|
||||
return Math.floor(secs / 60) + "m " + (secs % 60) + "s running";
|
||||
return (
|
||||
Math.floor(secs / 3600) +
|
||||
"h " +
|
||||
Math.floor((secs % 3600) / 60) +
|
||||
"m running"
|
||||
);
|
||||
}
|
||||
|
||||
// Compact duration label, two units deep ("1h 5m", "2d 3h").
|
||||
export function fmtDuration(secs) {
|
||||
if (secs < 60) return secs + 's';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
|
||||
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
|
||||
if (secs < 60) return secs + "s";
|
||||
if (secs < 3600) return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
|
||||
if (secs < 86400)
|
||||
return (
|
||||
Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m"
|
||||
);
|
||||
return (
|
||||
Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
|
||||
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
|
||||
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
|
||||
import { useState } from 'preact/hooks';
|
||||
import { WarnBanner } from '../warn-banner/WarnBanner.js';
|
||||
import type { ProblemDetails } from '../api-error.js';
|
||||
import './api-error-panel.css';
|
||||
import { useState } from "preact/hooks";
|
||||
import { WarnBanner } from "../warn-banner/WarnBanner.js";
|
||||
import type { ProblemDetails } from "../api-error.js";
|
||||
import "./api-error-panel.css";
|
||||
|
||||
export interface ApiErrorPanelProps {
|
||||
problem: ProblemDetails;
|
||||
|
|
@ -39,12 +39,16 @@ function formatForCopy(p: ProblemDetails): string {
|
|||
if (p.title) lines.push(`title: ${p.title}`);
|
||||
if (p.type) lines.push(`type: ${p.type}`);
|
||||
if (p.detail) lines.push(`detail: ${p.detail}`);
|
||||
return lines.length ? lines.join('\n') : 'request failed';
|
||||
return lines.length ? lines.join("\n") : "request failed";
|
||||
}
|
||||
|
||||
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
|
||||
const heading =
|
||||
problem.title ||
|
||||
(problem.status !== undefined
|
||||
? `http ${problem.status}`
|
||||
: "request failed");
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
|
|
@ -62,11 +66,11 @@ export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
|
|||
<WarnBanner level="error" class="api-error-panel">
|
||||
<div class="api-error-heading">
|
||||
<span class="api-error-title">
|
||||
{context ? `${context}: ` : ''}
|
||||
{context ? `${context}: ` : ""}
|
||||
{heading}
|
||||
</span>
|
||||
<button type="button" class="api-error-copy" onClick={copy}>
|
||||
{copied ? 'copied' : 'copy'}
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
</div>
|
||||
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}
|
||||
|
|
|
|||
|
|
@ -33,17 +33,17 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
|
|||
} catch {
|
||||
return { status };
|
||||
}
|
||||
if (raw && (raw[0] === '{' || raw[0] === '[')) {
|
||||
if (raw && (raw[0] === "{" || raw[0] === "[")) {
|
||||
try {
|
||||
const body = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
type: typeof body.type === 'string' ? body.type : undefined,
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
status: typeof body.status === 'number' ? body.status : status,
|
||||
type: typeof body.type === "string" ? body.type : undefined,
|
||||
title: typeof body.title === "string" ? body.title : undefined,
|
||||
status: typeof body.status === "number" ? body.status : status,
|
||||
detail:
|
||||
typeof body.detail === 'string'
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: typeof body.error === 'string'
|
||||
: typeof body.error === "string"
|
||||
? body.error
|
||||
: undefined,
|
||||
};
|
||||
|
|
@ -59,5 +59,9 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
|
|||
// same fallback order `readErrorBody` used: detail (or its `error` alias,
|
||||
// already folded in above) → title → a bare status line.
|
||||
export function problemMessage(p: ProblemDetails): string {
|
||||
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
|
||||
return (
|
||||
p.detail ||
|
||||
p.title ||
|
||||
(p.status !== undefined ? `http ${p.status}` : "request failed")
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,12 +51,12 @@
|
|||
the settings/links "should not have the badge bg" bug on those
|
||||
triggers. Verified against a real repro before landing, not just
|
||||
the specificity arithmetic. */
|
||||
:root[data-theme='light'] .ui-badge:not(.ui-badge-quiet) {
|
||||
:root[data-theme="light"] .ui-badge:not(.ui-badge-quiet) {
|
||||
background: var(--purple-dim);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
:root[data-theme='dark'] .ui-badge:not(.ui-badge-quiet) {
|
||||
:root[data-theme="dark"] .ui-badge:not(.ui-badge-quiet) {
|
||||
background: color-mix(in srgb, var(--purple-dim) 65%, transparent);
|
||||
-webkit-backdrop-filter: blur(6px) saturate(140%);
|
||||
backdrop-filter: blur(6px) saturate(140%);
|
||||
|
|
@ -81,12 +81,14 @@
|
|||
.ui-badge-quiet {
|
||||
background: none;
|
||||
}
|
||||
.ui-badge-interactive[aria-expanded='true'] {
|
||||
.ui-badge-interactive[aria-expanded="true"] {
|
||||
background: var(--bg-elev);
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
.ui-badge-label {
|
||||
color: var(--muted-on-dim); /* --muted alone is too low-contrast on this fill, see theme.css */
|
||||
color: var(
|
||||
--muted-on-dim
|
||||
); /* --muted alone is too low-contrast on this fill, see theme.css */
|
||||
}
|
||||
.ui-badge-value {
|
||||
color: var(--fg);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,15 @@
|
|||
// swarm-ui *and* the per-agent page need the interactive shape, and
|
||||
// `shared` is the one package both already depend on (see
|
||||
// `docs/web-ui/design-guide.md`'s component-first + junk-drawer rules).
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import './Badge.css';
|
||||
import type { ComponentChildren } from "preact";
|
||||
import "./Badge.css";
|
||||
|
||||
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
|
||||
export type BadgeTone =
|
||||
| "neutral"
|
||||
| "positive"
|
||||
| "warning"
|
||||
| "negative"
|
||||
| "accent";
|
||||
/**
|
||||
* `'default'` — the filled pill every status/picker badge has always
|
||||
* been. `'quiet'` — no permanent fill, only a background on hover/
|
||||
|
|
@ -34,7 +39,7 @@ export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accen
|
|||
* weight is orthogonal to `tone` (color semantics), so it's its own prop
|
||||
* rather than a new `tone` value.
|
||||
*/
|
||||
export type BadgeVariant = 'default' | 'quiet';
|
||||
export type BadgeVariant = "default" | "quiet";
|
||||
|
||||
export interface BadgeProps {
|
||||
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
|
||||
|
|
@ -63,8 +68,8 @@ export interface BadgeProps {
|
|||
export function Badge({
|
||||
label,
|
||||
value,
|
||||
tone = 'neutral',
|
||||
variant = 'default',
|
||||
tone = "neutral",
|
||||
variant = "default",
|
||||
icon,
|
||||
onClick,
|
||||
expanded,
|
||||
|
|
@ -73,14 +78,14 @@ export function Badge({
|
|||
title,
|
||||
}: BadgeProps) {
|
||||
const classes = [
|
||||
'ui-badge',
|
||||
"ui-badge",
|
||||
`ui-badge-${tone}`,
|
||||
variant === 'quiet' && 'ui-badge-quiet',
|
||||
onClick && 'ui-badge-interactive',
|
||||
variant === "quiet" && "ui-badge-quiet",
|
||||
onClick && "ui-badge-interactive",
|
||||
extraClass,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
.join(" ");
|
||||
const content = (
|
||||
<>
|
||||
{icon ? (
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ body {
|
|||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
font-family:
|
||||
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
|
@ -33,4 +34,11 @@ body {
|
|||
animation: spin 1s linear infinite;
|
||||
color: var(--amber);
|
||||
}
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@
|
|||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
.page-back:hover { text-decoration: underline; }
|
||||
.page-back:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: var(--subtext0);
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@
|
|||
both blocks below just re-point at the same `--mocha-baseNN` /
|
||||
`--latte-baseNN` custom properties declared once at the top of this
|
||||
file. */
|
||||
:root[data-theme='light'] {
|
||||
:root[data-theme="light"] {
|
||||
--base00: var(--latte-base00);
|
||||
--base01: var(--latte-base01);
|
||||
--base02: var(--latte-base02);
|
||||
|
|
@ -160,7 +160,7 @@
|
|||
--base0E: var(--latte-base0E);
|
||||
--base0F: var(--latte-base0F);
|
||||
}
|
||||
:root[data-theme='dark'] {
|
||||
:root[data-theme="dark"] {
|
||||
--base00: var(--mocha-base00);
|
||||
--base01: var(--mocha-base01);
|
||||
--base02: var(--mocha-base02);
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@
|
|||
export const el = (tag, attrs = {}, ...children) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'html') e.innerHTML = v;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "html") e.innerHTML = v;
|
||||
else e.setAttribute(k, v);
|
||||
}
|
||||
for (const c of children) {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
background: var(--purple-dim);
|
||||
}
|
||||
.ui-dropdown-item-active .ui-dropdown-item-label::before {
|
||||
content: '✓ ';
|
||||
content: "✓ ";
|
||||
color: var(--purple);
|
||||
}
|
||||
/* Destructive action row (cancel turn) — ported from the old standalone
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
// to that box's bottom-left via CSS. No portal — a badge in a normal
|
||||
// document-flow header never needs one, and skipping it keeps focus
|
||||
// management simple (no re-parenting to `<body>` to reason about).
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import type { ComponentChildren, RefObject } from 'preact';
|
||||
import './Dropdown.css';
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import type { ComponentChildren, RefObject } from "preact";
|
||||
import "./Dropdown.css";
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
|
|
@ -53,7 +53,15 @@ export interface DropdownProps {
|
|||
anchorRef?: RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export function Dropdown({ open, options, activeValue, onSelect, onClose, label, anchorRef }: DropdownProps) {
|
||||
export function Dropdown({
|
||||
open,
|
||||
options,
|
||||
activeValue,
|
||||
onSelect,
|
||||
onClose,
|
||||
label,
|
||||
anchorRef,
|
||||
}: DropdownProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -65,16 +73,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
|
|||
onClose();
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
// `pointerdown` (not `click`) so a drag-to-select that ends outside
|
||||
// still closes; capture phase so this sees the event before a
|
||||
// stopPropagation() elsewhere in the tree could swallow it.
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
|
|
@ -89,14 +97,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
|
|||
role="menuitemradio"
|
||||
aria-checked={opt.value === activeValue}
|
||||
class={
|
||||
'ui-dropdown-item' +
|
||||
(opt.value === activeValue ? ' ui-dropdown-item-active' : '') +
|
||||
(opt.danger ? ' ui-dropdown-item-danger' : '')
|
||||
"ui-dropdown-item" +
|
||||
(opt.value === activeValue ? " ui-dropdown-item-active" : "") +
|
||||
(opt.danger ? " ui-dropdown-item-danger" : "")
|
||||
}
|
||||
onClick={() => onSelect(opt.value)}
|
||||
>
|
||||
<span class="ui-dropdown-item-label">{opt.label}</span>
|
||||
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
|
||||
{opt.description ? (
|
||||
<span class="ui-dropdown-item-desc">{opt.description}</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -51,30 +51,37 @@ export function asyncBtn(btn, fn) {
|
|||
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
|
||||
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
|
||||
// rather than `alert()`, so every page gets the same in-theme experience.
|
||||
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
|
||||
import { themedConfirm, themedPrompt, themedToast } from "./modal.js";
|
||||
|
||||
export function bindAsyncForms(onSuccess) {
|
||||
document.addEventListener('submit', async (e) => {
|
||||
document.addEventListener("submit", async (e) => {
|
||||
const f = e.target;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
|
||||
if (!(f instanceof HTMLFormElement) || !f.hasAttribute("data-async"))
|
||||
return;
|
||||
e.preventDefault();
|
||||
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
|
||||
if (
|
||||
f.dataset.confirm &&
|
||||
!(await themedConfirm({ message: f.dataset.confirm }))
|
||||
)
|
||||
return;
|
||||
if (f.dataset.prompt) {
|
||||
const ans = await themedPrompt({ message: f.dataset.prompt });
|
||||
if (ans === null) return; // operator hit Cancel
|
||||
// Drop into a hidden input named after `data-prompt-field` (or
|
||||
// 'note' by default) so the value rides along on the POST.
|
||||
const field = f.dataset.promptField || 'note';
|
||||
const field = f.dataset.promptField || "note";
|
||||
let input = f.querySelector(`input[name="${field}"]`);
|
||||
if (!input) {
|
||||
input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = field;
|
||||
f.append(input);
|
||||
}
|
||||
input.value = ans;
|
||||
}
|
||||
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
|
||||
const btn = f.querySelector(
|
||||
'button[type="submit"], button:not([type]), .btn-inline',
|
||||
);
|
||||
// Inner action: POST, clear inputs, call onSuccess.
|
||||
// Errors are surfaced via themedToast; the caller does not re-throw
|
||||
// so asyncBtn's finally always runs (restoring the button).
|
||||
|
|
@ -82,25 +89,37 @@ export function bindAsyncForms(onSuccess) {
|
|||
let resp;
|
||||
try {
|
||||
resp = await fetch(f.action, {
|
||||
method: f.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
method: f.method || "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(new FormData(f)),
|
||||
redirect: 'manual',
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
themedToast('action failed: ' + err, { type: 'error' });
|
||||
themedToast("action failed: " + err, { type: "error" });
|
||||
return;
|
||||
}
|
||||
const ok = resp.ok || resp.type === 'opaqueredirect'
|
||||
|| (resp.status >= 200 && resp.status < 400);
|
||||
const ok =
|
||||
resp.ok ||
|
||||
resp.type === "opaqueredirect" ||
|
||||
(resp.status >= 200 && resp.status < 400);
|
||||
if (!ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
|
||||
const text = await resp.text().catch(() => "");
|
||||
themedToast(
|
||||
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
|
||||
{ type: "error" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Clear text inputs whose value was just submitted.
|
||||
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
|
||||
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
|
||||
f.querySelectorAll(
|
||||
'input[type="text"], input:not([type]), textarea',
|
||||
).forEach((i) => {
|
||||
i.value = "";
|
||||
});
|
||||
if (
|
||||
!f.hasAttribute("data-no-refresh") &&
|
||||
typeof onSuccess === "function"
|
||||
) {
|
||||
onSuccess();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,9 +12,15 @@
|
|||
:host {
|
||||
display: contents;
|
||||
}
|
||||
:host([variant="cancel"]) { color: var(--subtext0); }
|
||||
:host([variant="confirm"]) { color: var(--green); }
|
||||
:host([variant="danger"]) { color: var(--red); }
|
||||
:host([variant="cancel"]) {
|
||||
color: var(--subtext0);
|
||||
}
|
||||
:host([variant="confirm"]) {
|
||||
color: var(--green);
|
||||
}
|
||||
:host([variant="danger"]) {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
|
|
|
|||
|
|
@ -20,10 +20,10 @@
|
|||
// `disabled`/`type` are likewise plain attributes on the host, mirrored
|
||||
// onto the inner button on connect and on every attribute change.
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveBtnCss from './hive-btn.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveBtnCss from "./hive-btn.css";
|
||||
|
||||
const OBSERVED = ['disabled', 'type'];
|
||||
const OBSERVED = ["disabled", "type"];
|
||||
|
||||
class HiveBtn extends HTMLElement {
|
||||
static get observedAttributes() {
|
||||
|
|
@ -36,8 +36,8 @@ class HiveBtn extends HTMLElement {
|
|||
return; // already built (e.g. re-parenting re-fires connectedCallback)
|
||||
}
|
||||
const root = attachShadowCss(this, hiveBtnCss, { delegatesFocus: true });
|
||||
const btn = document.createElement('button');
|
||||
btn.append(document.createElement('slot'));
|
||||
const btn = document.createElement("button");
|
||||
btn.append(document.createElement("slot"));
|
||||
root.append(btn);
|
||||
this._btn = btn;
|
||||
this._sync();
|
||||
|
|
@ -49,8 +49,8 @@ class HiveBtn extends HTMLElement {
|
|||
|
||||
_sync() {
|
||||
if (!this._btn) return;
|
||||
this._btn.disabled = this.hasAttribute('disabled');
|
||||
this._btn.type = this.getAttribute('type') || 'button';
|
||||
this._btn.disabled = this.hasAttribute("disabled");
|
||||
this._btn.type = this.getAttribute("type") || "button";
|
||||
}
|
||||
}
|
||||
customElements.define('hive-btn', HiveBtn);
|
||||
customElements.define("hive-btn", HiveBtn);
|
||||
|
|
|
|||
|
|
@ -35,8 +35,15 @@
|
|||
letter-spacing: 0.08em;
|
||||
color: var(--subtext0);
|
||||
}
|
||||
.message { color: var(--fg); line-height: 1.45; }
|
||||
.checks { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.message {
|
||||
color: var(--fg);
|
||||
line-height: 1.45;
|
||||
}
|
||||
.checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.checkrow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -46,15 +53,25 @@
|
|||
font-size: 0.92em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
|
||||
.checkrow .check {
|
||||
margin-top: 0.2em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.6em;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
|
||||
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
|
||||
.promptfield {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.promptlabel {
|
||||
color: var(--subtext0);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.input {
|
||||
font-family: inherit;
|
||||
font-size: 1em;
|
||||
|
|
@ -65,7 +82,9 @@
|
|||
border: 1px solid var(--purple-dim);
|
||||
padding: 0.4em 0.6em;
|
||||
}
|
||||
.input:focus { outline: 1px solid var(--green); }
|
||||
.input:focus {
|
||||
outline: 1px solid var(--green);
|
||||
}
|
||||
.textarea {
|
||||
resize: vertical;
|
||||
min-height: 4.5em;
|
||||
|
|
|
|||
|
|
@ -14,17 +14,20 @@
|
|||
// Imports `../hive-btn/hive-btn.js` for its side effect (registers
|
||||
// `<hive-btn>`) since the dialog's own buttons are `<hive-btn>` elements.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import '../hive-btn/hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
|
||||
import dialogCss from './hive-dialog.css';
|
||||
import { el } from "../dom.js";
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import "../hive-btn/hive-btn.js"; // registers <hive-btn> — side-effect import, no named export needed
|
||||
import dialogCss from "./hive-dialog.css";
|
||||
|
||||
class HiveDialog extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const {
|
||||
title = '', message = '', content = null,
|
||||
buttons = [{ label: 'ok', value: true }],
|
||||
danger = false, dismissable = true,
|
||||
title = "",
|
||||
message = "",
|
||||
content = null,
|
||||
buttons = [{ label: "ok", value: true }],
|
||||
danger = false,
|
||||
dismissable = true,
|
||||
} = this._opts || {};
|
||||
|
||||
const root = attachShadowCss(this, dialogCss);
|
||||
|
|
@ -33,12 +36,14 @@ class HiveDialog extends HTMLElement {
|
|||
const done = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value }));
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hive-dialog-close", { detail: value }),
|
||||
);
|
||||
this.remove();
|
||||
};
|
||||
const onKey = (e) => {
|
||||
if (dismissable && e.key === 'Escape') {
|
||||
if (dismissable && e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
done(null);
|
||||
|
|
@ -50,12 +55,16 @@ class HiveDialog extends HTMLElement {
|
|||
// `b.class` names the variant ('cancel' | 'confirm'); `b.danger`
|
||||
// overrides it to the 'danger' look regardless (a destructive
|
||||
// confirm button reads as danger, not as a plain confirm).
|
||||
const variant = b.danger ? 'danger' : b.class;
|
||||
const btn = el('hive-btn', {
|
||||
type: 'button',
|
||||
...(variant ? { variant } : {}),
|
||||
}, b.label);
|
||||
btn.addEventListener('click', () => done(b.value));
|
||||
const variant = b.danger ? "danger" : b.class;
|
||||
const btn = el(
|
||||
"hive-btn",
|
||||
{
|
||||
type: "button",
|
||||
...(variant ? { variant } : {}),
|
||||
},
|
||||
b.label,
|
||||
);
|
||||
btn.addEventListener("click", () => done(b.value));
|
||||
return { spec: b, btn };
|
||||
});
|
||||
|
||||
|
|
@ -65,21 +74,30 @@ class HiveDialog extends HTMLElement {
|
|||
// this shadow root, so no cross-instance collision risk even without
|
||||
// the random suffix — kept anyway since it costs nothing and guards
|
||||
// against a future shared-DOM edge case (e.g. `::part()` piercing).
|
||||
const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9);
|
||||
const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null;
|
||||
const messageEl = message
|
||||
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
|
||||
const labelId = "dlg-" + Math.random().toString(36).slice(2, 9);
|
||||
const titleEl = title
|
||||
? el("div", { class: "title", id: labelId }, title)
|
||||
: null;
|
||||
const boxAttrs = { class: 'box', role: 'dialog', 'aria-modal': 'true' };
|
||||
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
|
||||
const box = el('div', boxAttrs,
|
||||
const messageEl = message
|
||||
? el(
|
||||
"div",
|
||||
title ? { class: "message" } : { class: "message", id: labelId },
|
||||
message,
|
||||
)
|
||||
: null;
|
||||
const boxAttrs = { class: "box", role: "dialog", "aria-modal": "true" };
|
||||
if (titleEl || messageEl) boxAttrs["aria-labelledby"] = labelId;
|
||||
const box = el(
|
||||
"div",
|
||||
boxAttrs,
|
||||
titleEl,
|
||||
messageEl,
|
||||
content || null,
|
||||
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
|
||||
el("div", { class: "actions" }, ...btnEls.map((b) => b.btn)),
|
||||
);
|
||||
root.append(box);
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
this.addEventListener("click", (e) => {
|
||||
// `e.target` is retargeted to `this` (the host) for ANY click that
|
||||
// originated inside the shadow tree, once it bubbles out to a
|
||||
// listener attached on the host itself — per spec, retargeting
|
||||
|
|
@ -94,12 +112,13 @@ class HiveDialog extends HTMLElement {
|
|||
// tree was actually under the cursor, i.e. a real backdrop click.
|
||||
if (dismissable && e.composedPath()[0] === this) done(null);
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener("keydown", onKey, true);
|
||||
|
||||
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|
||||
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|
||||
|| btnEls[btnEls.length - 1];
|
||||
const focusTarget =
|
||||
btnEls.find((b) => b.spec.autofocus) ||
|
||||
(danger ? btnEls.find((b) => !b.spec.danger) : null) ||
|
||||
btnEls[btnEls.length - 1];
|
||||
if (focusTarget) focusTarget.btn.focus();
|
||||
}
|
||||
}
|
||||
customElements.define('hive-dialog', HiveDialog);
|
||||
customElements.define("hive-dialog", HiveDialog);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
/* The trigger is the top-level `slot="trigger"` node — reachable, so its
|
||||
base icon-button chrome (invisible until hover/open, via
|
||||
`--menu-btn-opacity`) lives here rather than duplicated per caller. */
|
||||
::slotted([slot='trigger']) {
|
||||
::slotted([slot="trigger"]) {
|
||||
display: block;
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
@ -45,14 +45,17 @@
|
|||
padding: 0.1em 0.4em;
|
||||
border-radius: 4px;
|
||||
opacity: var(--menu-btn-opacity, 0);
|
||||
transition: opacity 120ms, background 120ms, color 120ms;
|
||||
transition:
|
||||
opacity 120ms,
|
||||
background 120ms,
|
||||
color 120ms;
|
||||
}
|
||||
::slotted([slot='trigger']:hover),
|
||||
::slotted([slot='trigger']:focus-visible) {
|
||||
::slotted([slot="trigger"]:hover),
|
||||
::slotted([slot="trigger"]:focus-visible) {
|
||||
background: color-mix(in srgb, var(--purple) 10%, transparent);
|
||||
color: var(--purple);
|
||||
outline: none;
|
||||
}
|
||||
::slotted([slot='trigger']:focus-visible) {
|
||||
::slotted([slot="trigger"]:focus-visible) {
|
||||
outline: 1px solid var(--purple);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,9 @@
|
|||
// slotted content) tests "did this land inside any open menu" — same
|
||||
// reasoning as `<hive-dialog>`'s backdrop-click check.
|
||||
|
||||
import { el } from '../dom.js';
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveMenuCss from './hive-menu.css';
|
||||
import { el } from "../dom.js";
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveMenuCss from "./hive-menu.css";
|
||||
|
||||
const openInstances = new Set();
|
||||
|
||||
|
|
@ -46,22 +46,30 @@ export function closeAllMenus() {
|
|||
}
|
||||
|
||||
// Close on any click outside every currently-open instance.
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!openInstances.size) return;
|
||||
const path = e.composedPath();
|
||||
for (const inst of [...openInstances]) {
|
||||
if (!path.includes(inst)) inst.close();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!openInstances.size) return;
|
||||
const path = e.composedPath();
|
||||
for (const inst of [...openInstances]) {
|
||||
if (!path.includes(inst)) inst.close();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Close on Escape. stopImmediatePropagation so a caller's own
|
||||
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
|
||||
// while a menu is open.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && openInstances.size) {
|
||||
closeAll();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape" && openInstances.size) {
|
||||
closeAll();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
class HiveMenu extends HTMLElement {
|
||||
connectedCallback() {
|
||||
|
|
@ -80,18 +88,18 @@ class HiveMenu extends HTMLElement {
|
|||
|
||||
// Project the caller's opaque nodes via named slots — see module
|
||||
// header for why this has to be slotting, not a shadow-root append.
|
||||
trigger.slot = 'trigger';
|
||||
content.slot = 'content';
|
||||
trigger.slot = "trigger";
|
||||
content.slot = "content";
|
||||
this.append(trigger, content);
|
||||
|
||||
const dropdown = el('div', { class: 'menu-dropdown', hidden: true });
|
||||
dropdown.append(el('slot', { name: 'content' }));
|
||||
root.append(el('slot', { name: 'trigger' }), dropdown);
|
||||
const dropdown = el("div", { class: "menu-dropdown", hidden: true });
|
||||
dropdown.append(el("slot", { name: "content" }));
|
||||
root.append(el("slot", { name: "trigger" }), dropdown);
|
||||
|
||||
this._trigger = trigger;
|
||||
this._dropdown = dropdown;
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const wasOpen = openInstances.has(this);
|
||||
closeAll();
|
||||
|
|
@ -107,17 +115,17 @@ class HiveMenu extends HTMLElement {
|
|||
|
||||
open() {
|
||||
this._dropdown.hidden = false;
|
||||
this._trigger.setAttribute('aria-expanded', 'true');
|
||||
this.style.setProperty('--menu-btn-opacity', '1');
|
||||
this._trigger.setAttribute("aria-expanded", "true");
|
||||
this.style.setProperty("--menu-btn-opacity", "1");
|
||||
openInstances.add(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!openInstances.has(this)) return;
|
||||
this._dropdown.hidden = true;
|
||||
this._trigger.setAttribute('aria-expanded', 'false');
|
||||
this.style.removeProperty('--menu-btn-opacity');
|
||||
this._trigger.setAttribute("aria-expanded", "false");
|
||||
this.style.removeProperty("--menu-btn-opacity");
|
||||
openInstances.delete(this);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-menu', HiveMenu);
|
||||
customElements.define("hive-menu", HiveMenu);
|
||||
|
|
|
|||
|
|
@ -18,9 +18,20 @@
|
|||
color: var(--fg);
|
||||
white-space: pre-wrap;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
:host(.out) {
|
||||
opacity: 0;
|
||||
transform: translateX(0.5em);
|
||||
}
|
||||
:host(.error) {
|
||||
border-left-color: var(--red);
|
||||
}
|
||||
:host(.info) {
|
||||
border-left-color: var(--purple-dim);
|
||||
}
|
||||
:host(.ok) {
|
||||
border-left-color: var(--green);
|
||||
}
|
||||
:host(.out) { opacity: 0; transform: translateX(0.5em); }
|
||||
:host(.error) { border-left-color: var(--red); }
|
||||
:host(.info) { border-left-color: var(--purple-dim); }
|
||||
:host(.ok) { border-left-color: var(--green); }
|
||||
|
|
|
|||
|
|
@ -9,29 +9,29 @@
|
|||
// directly in the shadow root rather than via a `<slot>`, since there's no
|
||||
// external light-DOM content to project.
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import toastCss from './hive-toast.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import toastCss from "./hive-toast.css";
|
||||
|
||||
class HiveToast extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const { type = 'info', duration } = this._opts || {};
|
||||
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
|
||||
const { type = "info", duration } = this._opts || {};
|
||||
const ms = duration != null ? duration : type === "error" ? 8000 : 4000;
|
||||
const root = attachShadowCss(this, toastCss);
|
||||
this.classList.add(type);
|
||||
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
|
||||
root.textContent = this._message || '';
|
||||
this.setAttribute("role", type === "error" ? "alert" : "status");
|
||||
root.textContent = this._message || "";
|
||||
let removed = false;
|
||||
this._remove = () => {
|
||||
if (removed) return;
|
||||
removed = true;
|
||||
this.classList.add('out');
|
||||
this.classList.add("out");
|
||||
setTimeout(() => this.remove(), 200);
|
||||
};
|
||||
this.addEventListener('click', this._remove);
|
||||
this.addEventListener("click", this._remove);
|
||||
if (ms > 0) this._timer = setTimeout(this._remove, ms);
|
||||
}
|
||||
disconnectedCallback() {
|
||||
clearTimeout(this._timer);
|
||||
}
|
||||
}
|
||||
customElements.define('hive-toast', HiveToast);
|
||||
customElements.define("hive-toast", HiveToast);
|
||||
|
|
|
|||
|
|
@ -42,8 +42,13 @@
|
|||
animation: hive-warn-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes hive-warn-pulse {
|
||||
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
|
||||
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* Explicit rather than relying on plain inheritance — matches what
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@
|
|||
// `el('hive-warn', { level: 'warning' }, ...)` (JS-built)
|
||||
// `el('hive-warn', { level: 'error' }, ...)` (active incident, pulses)
|
||||
|
||||
import { attachShadowCss } from '../shadow-css.js';
|
||||
import hiveWarnCss from './hive-warn.css';
|
||||
import { attachShadowCss } from "../shadow-css.js";
|
||||
import hiveWarnCss from "./hive-warn.css";
|
||||
|
||||
class HiveWarn extends HTMLElement {
|
||||
connectedCallback() {
|
||||
if (this._built) return; // re-parenting re-fires connectedCallback
|
||||
const root = attachShadowCss(this, hiveWarnCss);
|
||||
root.append(document.createElement('slot'));
|
||||
root.append(document.createElement("slot"));
|
||||
this._built = true;
|
||||
}
|
||||
}
|
||||
customElements.define('hive-warn', HiveWarn);
|
||||
customElements.define("hive-warn", HiveWarn);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
// Convenience re-export so consumers can `import { create, linkify }
|
||||
// from '@hive/shared'` without naming the sub-module path.
|
||||
export { create, linkify } from './terminal/terminal.js';
|
||||
export { create, linkify } from "./terminal/terminal.js";
|
||||
|
|
|
|||
|
|
@ -28,21 +28,28 @@
|
|||
// loader is `text` (for unrelated shadow-DOM components' CSS-as-string
|
||||
// needs), and that loader is global per call, not per-module.
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
||||
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
|
||||
|
||||
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
|
||||
// `rename_all`) — see that enum's own doc comment for why it's kept in
|
||||
// an exhaustive match on the Rust side; this union is this file's
|
||||
// equivalent contract.
|
||||
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type NodeState =
|
||||
| "Pending"
|
||||
| "Running"
|
||||
| "Finishing"
|
||||
| "Done"
|
||||
| "Failed"
|
||||
| "Cancelled"
|
||||
| "Skipped";
|
||||
|
||||
type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type TerminalState = "Done" | "Failed" | "Cancelled" | "Skipped";
|
||||
|
||||
// Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`,
|
||||
// values are the Rust variant names verbatim.
|
||||
type GraphDep =
|
||||
| { kind: 'Node'; id: number; accepts: TerminalState[] }
|
||||
| { kind: 'Resource'; name: string; count: number };
|
||||
| { kind: "Node"; id: number; accepts: TerminalState[] }
|
||||
| { kind: "Resource"; name: string; count: number };
|
||||
|
||||
interface NodePayload {
|
||||
label: string;
|
||||
|
|
@ -69,13 +76,13 @@ interface TreeNode extends GraphNode {
|
|||
}
|
||||
|
||||
const STATE_GLYPH: Record<NodeState, string> = {
|
||||
Pending: '⏸',
|
||||
Running: '▶',
|
||||
Finishing: '◐',
|
||||
Done: '✔',
|
||||
Failed: '✖',
|
||||
Cancelled: '⊘',
|
||||
Skipped: '·',
|
||||
Pending: "⏸",
|
||||
Running: "▶",
|
||||
Finishing: "◐",
|
||||
Done: "✔",
|
||||
Failed: "✖",
|
||||
Cancelled: "⊘",
|
||||
Skipped: "·",
|
||||
};
|
||||
|
||||
// Declaration order doubles as render order for the filter checkboxes —
|
||||
|
|
@ -84,12 +91,16 @@ const STATE_GLYPH: Record<NodeState, string> = {
|
|||
const ALL_STATES = Object.keys(STATE_GLYPH) as NodeState[];
|
||||
|
||||
// Product call: "default selection filters out skipped and done."
|
||||
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(['Done', 'Skipped']);
|
||||
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(["Done", "Skipped"]);
|
||||
|
||||
// Non-terminal states a cancel button makes sense on. Finishing is
|
||||
// included — "own logic done, children still running" is still a subtree
|
||||
// worth stopping early.
|
||||
const CANCELLABLE_STATES = new Set<NodeState>(['Pending', 'Running', 'Finishing']);
|
||||
const CANCELLABLE_STATES = new Set<NodeState>([
|
||||
"Pending",
|
||||
"Running",
|
||||
"Finishing",
|
||||
]);
|
||||
|
||||
// Build a parent/child tree from the flat wire array. `parent` (structural
|
||||
// grouping) defines tree shape. Sibling order follows array order, which
|
||||
|
|
@ -117,7 +128,9 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
|
|||
}
|
||||
for (const n of byId.values()) {
|
||||
n._waitsOn = (n.deps || [])
|
||||
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
|
||||
.filter(
|
||||
(d): d is Extract<GraphDep, { kind: "Node" }> => d.kind === "Node",
|
||||
)
|
||||
.map((d) => byId.get(d.id))
|
||||
.filter((dep): dep is TreeNode => dep != null)
|
||||
.map((dep) => dep.payload.label);
|
||||
|
|
@ -131,17 +144,19 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
|
|||
// single stringified row rather than silently dropping it).
|
||||
function DataList({ data }: { data: unknown }) {
|
||||
if (data == null) return null;
|
||||
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
|
||||
const isPlainObject = typeof data === "object" && !Array.isArray(data);
|
||||
const entries: [string, unknown][] = isPlainObject
|
||||
? Object.entries(data as Record<string, unknown>)
|
||||
: [['data', data]];
|
||||
: [["data", data]];
|
||||
if (!entries.length) return null;
|
||||
return (
|
||||
<dl class="jg-data">
|
||||
{entries.map(([k, v]) => (
|
||||
<>
|
||||
<dt key={k + '-dt'}>{k}</dt>
|
||||
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
|
||||
<dt key={k + "-dt"}>{k}</dt>
|
||||
<dd key={k + "-dd"}>
|
||||
{typeof v === "string" ? v : JSON.stringify(v)}
|
||||
</dd>
|
||||
</>
|
||||
))}
|
||||
</dl>
|
||||
|
|
@ -157,7 +172,7 @@ function NodeView({
|
|||
cancellable: boolean;
|
||||
onCancel?: (id: number) => void;
|
||||
}) {
|
||||
const glyph = STATE_GLYPH[n.state] || '?';
|
||||
const glyph = STATE_GLYPH[n.state] || "?";
|
||||
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
|
||||
// Flash the state glyph on a genuine state change (Pending → Running,
|
||||
// etc.), not on mount — `prevState` starts at the node's own initial
|
||||
|
|
@ -176,27 +191,41 @@ function NodeView({
|
|||
return (
|
||||
<div class="jg-node">
|
||||
<div class="jg-row">
|
||||
<span class={'jg-state jg-state-' + n.state.toLowerCase() + (flashing ? ' jg-state-flash' : '')}
|
||||
title={n.state + (n.error ? ' — ' + n.error : '')}
|
||||
onAnimationEnd={() => setFlashing(false)}>
|
||||
<span
|
||||
class={
|
||||
"jg-state jg-state-" +
|
||||
n.state.toLowerCase() +
|
||||
(flashing ? " jg-state-flash" : "")
|
||||
}
|
||||
title={n.state + (n.error ? " — " + n.error : "")}
|
||||
onAnimationEnd={() => setFlashing(false)}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
{' '}
|
||||
</span>{" "}
|
||||
<span class="jg-label">{n.payload.label}</span>
|
||||
{showCancel && (
|
||||
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
|
||||
onClick={() => onCancel && onCancel(n.id)}>
|
||||
<button
|
||||
type="button"
|
||||
class="jg-cancel-btn"
|
||||
title={"cancel " + n.payload.label}
|
||||
onClick={() => onCancel && onCancel(n.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{n._waitsOn && n._waitsOn.length > 0 && (
|
||||
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
|
||||
<div class="jg-waits-on">waits on: {n._waitsOn.join(", ")}</div>
|
||||
)}
|
||||
<DataList data={n.payload.data} />
|
||||
{n.error && <pre class="jg-error">{n.error}</pre>}
|
||||
{n._children.map((child) => (
|
||||
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
|
||||
<NodeView
|
||||
key={child.id}
|
||||
n={child}
|
||||
cancellable={cancellable}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -212,12 +241,20 @@ function FilterBar({
|
|||
return (
|
||||
<div class="jg-filter">
|
||||
{ALL_STATES.map((state) => {
|
||||
const id = 'jg-filter-' + state.toLowerCase();
|
||||
const id = "jg-filter-" + state.toLowerCase();
|
||||
return (
|
||||
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
|
||||
<input type="checkbox" id={id} checked={selectedStates.has(state)}
|
||||
onChange={() => onToggle(state)} />
|
||||
{' '}{STATE_GLYPH[state] + ' ' + state}
|
||||
<label
|
||||
key={state}
|
||||
for={id}
|
||||
class={"jg-filter-label jg-state-" + state.toLowerCase()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={selectedStates.has(state)}
|
||||
onChange={() => onToggle(state)}
|
||||
/>{" "}
|
||||
{STATE_GLYPH[state] + " " + state}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
|
|
@ -229,11 +266,14 @@ function FilterBar({
|
|||
// param — omitted entirely when every state is checked, so the
|
||||
// unfiltered default case sends the exact same request as before this
|
||||
// filter existed.
|
||||
function fetchUrl(endpoint: string | undefined, selectedStates: Set<NodeState>): string | null {
|
||||
function fetchUrl(
|
||||
endpoint: string | undefined,
|
||||
selectedStates: Set<NodeState>,
|
||||
): string | null {
|
||||
if (!endpoint) return null;
|
||||
if (selectedStates.size >= ALL_STATES.length) return endpoint;
|
||||
const url = new URL(endpoint, window.location.origin);
|
||||
url.searchParams.set('states', Array.from(selectedStates).join(','));
|
||||
url.searchParams.set("states", Array.from(selectedStates).join(","));
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
|
|
@ -249,7 +289,13 @@ export interface JobqGraphProps {
|
|||
// change identity so the effect below re-runs, giving a host an
|
||||
// explicit "refetch now" lever (bump it and re-render) without an
|
||||
// imperative ref into this component.
|
||||
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
|
||||
export function JobqGraph({
|
||||
endpoint,
|
||||
cancellable = false,
|
||||
onUpdate,
|
||||
onCancel,
|
||||
refreshToken = 0,
|
||||
}: JobqGraphProps) {
|
||||
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
|
||||
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
|
||||
);
|
||||
|
|
@ -259,7 +305,8 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
const toggleState = useCallback((state: NodeState) => {
|
||||
setSelectedStates((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(state)) next.delete(state); else next.add(state);
|
||||
if (next.has(state)) next.delete(state);
|
||||
else next.add(state);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
|
@ -271,7 +318,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
(async () => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
if (!r.ok) throw new Error("http " + r.status);
|
||||
const data = (await r.json()) as GraphNode[];
|
||||
if (cancelled) return;
|
||||
setNodes(data);
|
||||
|
|
@ -282,11 +329,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
setError(String(err));
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
|
||||
// its *contents* are what should retrigger the fetch, not its identity, and the
|
||||
// string form below already changes identity exactly when contents do.
|
||||
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
|
||||
}, [endpoint, Array.from(selectedStates).sort().join(","), refreshToken]);
|
||||
|
||||
return (
|
||||
<div class="jg-root">
|
||||
|
|
@ -300,7 +349,12 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
|
|||
<p class="jg-empty">empty</p>
|
||||
) : (
|
||||
buildTree(nodes).map((root) => (
|
||||
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
|
||||
<NodeView
|
||||
key={root.id}
|
||||
n={root}
|
||||
cancellable={cancellable}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -65,10 +65,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .jg-node {
|
||||
:root[data-motion="reduce"] .jg-node {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .jg-node {
|
||||
:root[data-motion="allow"] .jg-node {
|
||||
animation: jg-node-enter 160ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -93,13 +93,30 @@
|
|||
min-width: 1.2em;
|
||||
text-align: center;
|
||||
}
|
||||
.jg-state-pending { color: var(--muted); }
|
||||
.jg-state-running { color: var(--cyan); }
|
||||
.jg-state-finishing { color: var(--cyan); opacity: 0.75; }
|
||||
.jg-state-done { color: var(--green); }
|
||||
.jg-state-failed { color: var(--red); }
|
||||
.jg-state-cancelled { color: var(--muted); text-decoration: line-through; }
|
||||
.jg-state-skipped { color: var(--muted); opacity: 0.5; }
|
||||
.jg-state-pending {
|
||||
color: var(--muted);
|
||||
}
|
||||
.jg-state-running {
|
||||
color: var(--cyan);
|
||||
}
|
||||
.jg-state-finishing {
|
||||
color: var(--cyan);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.jg-state-done {
|
||||
color: var(--green);
|
||||
}
|
||||
.jg-state-failed {
|
||||
color: var(--red);
|
||||
}
|
||||
.jg-state-cancelled {
|
||||
color: var(--muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.jg-state-skipped {
|
||||
color: var(--muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Short-lived pulse applied by `NodeView` (JobqGraph.tsx) exactly when
|
||||
a node's own `state` value changes on an existing DOM node — a mount
|
||||
|
|
@ -123,10 +140,10 @@
|
|||
animation: none;
|
||||
}
|
||||
}
|
||||
:root[data-motion='reduce'] .jg-state-flash {
|
||||
:root[data-motion="reduce"] .jg-state-flash {
|
||||
animation: none;
|
||||
}
|
||||
:root[data-motion='allow'] .jg-state-flash {
|
||||
:root[data-motion="allow"] .jg-state-flash {
|
||||
animation: jg-state-flash 350ms ease;
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +166,9 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.jg-cancel-btn:hover,
|
||||
.jg-cancel-btn:focus-visible {
|
||||
|
|
@ -166,8 +185,13 @@
|
|||
grid-template-columns: auto 1fr;
|
||||
gap: 0 0.5em;
|
||||
}
|
||||
.jg-data dt { font-weight: 600; }
|
||||
.jg-data dd { margin: 0; word-break: break-word; }
|
||||
.jg-data dt {
|
||||
font-weight: 600;
|
||||
}
|
||||
.jg-data dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.jg-waits-on {
|
||||
margin: 0.1em 0 0 1.6em;
|
||||
|
|
|
|||
|
|
@ -28,12 +28,19 @@
|
|||
// `refreshToken` to force a refetch. No mount wrapper: `render` is
|
||||
// already the re-render/diff entry point.
|
||||
|
||||
import { useState, useEffect } from 'preact/hooks';
|
||||
import { useState, useEffect } from "preact/hooks";
|
||||
|
||||
// Mirrors `hive_jobq_wire::StateSchema` — only the subset this banner
|
||||
// cares about, not the full union `JobqGraph.tsx` mirrors, since a
|
||||
// rollup row's `state` is read by exact string match, not rendered.
|
||||
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
|
||||
type NodeState =
|
||||
| "Pending"
|
||||
| "Running"
|
||||
| "Finishing"
|
||||
| "Done"
|
||||
| "Failed"
|
||||
| "Cancelled"
|
||||
| "Skipped";
|
||||
|
||||
interface StateCount {
|
||||
state: NodeState;
|
||||
|
|
@ -47,7 +54,11 @@ export interface JobqRollupProps {
|
|||
refreshToken?: number;
|
||||
}
|
||||
|
||||
export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollupProps) {
|
||||
export function JobqRollup({
|
||||
endpoint,
|
||||
queueHref,
|
||||
refreshToken = 0,
|
||||
}: JobqRollupProps) {
|
||||
const [counts, setCounts] = useState<StateCount[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -66,12 +77,16 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
|
|||
// ignore — keep the previous snapshot
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [endpoint, refreshToken]);
|
||||
|
||||
const byState = new Map(counts.map((c) => [c.state, c]));
|
||||
const running = (byState.get('Running')?.roots ?? 0) + (byState.get('Finishing')?.roots ?? 0);
|
||||
const queued = byState.get('Pending')?.roots ?? 0;
|
||||
const running =
|
||||
(byState.get("Running")?.roots ?? 0) +
|
||||
(byState.get("Finishing")?.roots ?? 0);
|
||||
const queued = byState.get("Pending")?.roots ?? 0;
|
||||
if (!running && !queued) return null;
|
||||
|
||||
const parts: string[] = [];
|
||||
|
|
@ -80,10 +95,12 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
|
|||
|
||||
return (
|
||||
<div class="jqr-summary">
|
||||
<span class="jqr-glyph spinner">◐</span>{' '}
|
||||
<strong>build queue</strong> — {parts.join(' · ')}{' '}
|
||||
<span class="jqr-glyph spinner">◐</span> <strong>build queue</strong> —{" "}
|
||||
{parts.join(" · ")}{" "}
|
||||
{queueHref && (
|
||||
<a class="jqr-link" href={queueHref}>view queue →</a>
|
||||
<a class="jqr-link" href={queueHref}>
|
||||
view queue →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
margin-bottom: 0.6em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.jqr-summary strong { color: var(--amber); }
|
||||
.jqr-summary strong {
|
||||
color: var(--amber);
|
||||
}
|
||||
.jqr-link {
|
||||
margin-left: auto;
|
||||
color: var(--amber);
|
||||
|
|
@ -22,4 +24,6 @@
|
|||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jqr-link:hover { text-decoration: underline; }
|
||||
.jqr-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
// Other `.btn` consumers across the app stay on the light-DOM `.btn`
|
||||
// class for now — migrating them is a separate follow-up.
|
||||
|
||||
import { el } from './dom.js';
|
||||
import './hive-dialog/hive-dialog.js'; // registers <hive-dialog> — side-effect import
|
||||
import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect import
|
||||
import { el } from "./dom.js";
|
||||
import "./hive-dialog/hive-dialog.js"; // registers <hive-dialog> — side-effect import
|
||||
import "./hive-toast/hive-toast.js"; // registers <hive-toast> — side-effect import
|
||||
|
||||
// openDialog({ title, message, content, buttons, danger, dismissable })
|
||||
// → Promise resolving to the clicked button's `value`, or `null` when the
|
||||
|
|
@ -37,9 +37,11 @@ import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect i
|
|||
// stray Enter can't fire the destructive path), else the last button.
|
||||
export function openDialog(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const dlg = document.createElement('hive-dialog');
|
||||
const dlg = document.createElement("hive-dialog");
|
||||
dlg._opts = opts;
|
||||
dlg.addEventListener('hive-dialog-close', (e) => resolve(e.detail), { once: true });
|
||||
dlg.addEventListener("hive-dialog-close", (e) => resolve(e.detail), {
|
||||
once: true,
|
||||
});
|
||||
document.body.append(dlg);
|
||||
});
|
||||
}
|
||||
|
|
@ -54,19 +56,31 @@ export function openDialog(opts = {}) {
|
|||
// doStop(r.graceful);
|
||||
export function themedConfirm(opts = {}) {
|
||||
const {
|
||||
title = '', message = '', danger = false,
|
||||
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
|
||||
title = "",
|
||||
message = "",
|
||||
danger = false,
|
||||
confirmLabel = "confirm",
|
||||
cancelLabel = "cancel",
|
||||
checkboxes = [],
|
||||
} = opts;
|
||||
|
||||
const boxes = checkboxes.map((cb) => {
|
||||
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
|
||||
const input = el("input", {
|
||||
type: "checkbox",
|
||||
class: "check",
|
||||
name: cb.name,
|
||||
});
|
||||
if (cb.checked) input.checked = true;
|
||||
const row = el('label', { class: 'checkrow' },
|
||||
input, el('span', {}, cb.label || cb.name));
|
||||
const row = el(
|
||||
"label",
|
||||
{ class: "checkrow" },
|
||||
input,
|
||||
el("span", {}, cb.label || cb.name),
|
||||
);
|
||||
return { input, row };
|
||||
});
|
||||
const content = boxes.length
|
||||
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
|
||||
? el("div", { class: "checks" }, ...boxes.map((b) => b.row))
|
||||
: null;
|
||||
|
||||
return openDialog({
|
||||
|
|
@ -75,13 +89,20 @@ export function themedConfirm(opts = {}) {
|
|||
content,
|
||||
danger,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
|
||||
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
|
||||
{ label: cancelLabel, value: null, class: "cancel", autofocus: danger },
|
||||
{
|
||||
label: confirmLabel,
|
||||
value: "confirm",
|
||||
danger,
|
||||
class: "confirm",
|
||||
autofocus: !danger,
|
||||
},
|
||||
],
|
||||
}).then((v) => {
|
||||
if (v !== 'confirm') return null;
|
||||
if (v !== "confirm") return null;
|
||||
const out = {};
|
||||
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
|
||||
for (let i = 0; i < boxes.length; i++)
|
||||
out[checkboxes[i].name] = boxes[i].input.checked;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
|
@ -95,10 +116,19 @@ export function themedConfirm(opts = {}) {
|
|||
// (via openDialog).
|
||||
export function themedPrompt(opts = {}) {
|
||||
const {
|
||||
title = '', message = '', label = '', placeholder = '', value = '',
|
||||
confirmLabel = 'ok', cancelLabel = 'cancel',
|
||||
title = "",
|
||||
message = "",
|
||||
label = "",
|
||||
placeholder = "",
|
||||
value = "",
|
||||
confirmLabel = "ok",
|
||||
cancelLabel = "cancel",
|
||||
} = opts;
|
||||
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
|
||||
const input = el("textarea", {
|
||||
class: "input textarea",
|
||||
rows: "3",
|
||||
placeholder,
|
||||
});
|
||||
if (value) input.value = value;
|
||||
// Enter submits (clicks the confirm button mounted by openDialog);
|
||||
// Shift+Enter falls through to the textarea's default newline insert.
|
||||
|
|
@ -106,24 +136,32 @@ export function themedPrompt(opts = {}) {
|
|||
// lives in, so this resolves to the dialog's own `.box`/confirm button
|
||||
// without leaking across instances. The confirm button is selected by
|
||||
// its `variant` attribute now (hive-btn.js), not a CSS class.
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
|
||||
input.closest(".box")?.querySelector('[variant="confirm"]')?.click();
|
||||
}
|
||||
});
|
||||
const content = el('div', { class: 'promptfield' },
|
||||
label ? el('label', { class: 'promptlabel' }, label) : null,
|
||||
input);
|
||||
const content = el(
|
||||
"div",
|
||||
{ class: "promptfield" },
|
||||
label ? el("label", { class: "promptlabel" }, label) : null,
|
||||
input,
|
||||
);
|
||||
const result = openDialog({
|
||||
title,
|
||||
message,
|
||||
content,
|
||||
buttons: [
|
||||
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
|
||||
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
|
||||
{ label: cancelLabel, value: "__cancel__", class: "cancel" },
|
||||
{
|
||||
label: confirmLabel,
|
||||
value: "__ok__",
|
||||
class: "confirm",
|
||||
autofocus: true,
|
||||
},
|
||||
],
|
||||
}).then((v) => (v === '__ok__' ? input.value : null));
|
||||
}).then((v) => (v === "__ok__" ? input.value : null));
|
||||
// Prefer focusing the field over the OK button once the dialog has mounted.
|
||||
setTimeout(() => input.focus(), 0);
|
||||
return result;
|
||||
|
|
@ -138,24 +176,24 @@ export function themedPrompt(opts = {}) {
|
|||
// component — no theming, no encapsulation need), so it's styled with a
|
||||
// one-off inline style rather than a stylesheet.
|
||||
export function themedToast(message, opts = {}) {
|
||||
let container = document.getElementById('tc-toasts');
|
||||
let container = document.getElementById("tc-toasts");
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'tc-toasts';
|
||||
container = document.createElement("div");
|
||||
container.id = "tc-toasts";
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
top: '1em',
|
||||
right: '1em',
|
||||
zIndex: '1100',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5em',
|
||||
maxWidth: 'min(28em, 92vw)',
|
||||
pointerEvents: 'none',
|
||||
position: "fixed",
|
||||
top: "1em",
|
||||
right: "1em",
|
||||
zIndex: "1100",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.5em",
|
||||
maxWidth: "min(28em, 92vw)",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
document.body.append(container);
|
||||
}
|
||||
const toast = document.createElement('hive-toast');
|
||||
const toast = document.createElement("hive-toast");
|
||||
toast._message = message;
|
||||
toast._opts = opts;
|
||||
container.append(toast);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// `ExpandDetailsSetting` (the per-agent page's own settings popover) is
|
||||
// the only writer today; `Row.tsx` is the only reader.
|
||||
|
||||
const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
|
||||
const EXPAND_DETAILS_KEY = "hive-agent-expand-details";
|
||||
|
||||
// Whether a per-agent terminal's otherwise-collapsed `<details>` panels
|
||||
// (long tool-results, Write/Edit diffs, …) should default open. Pure
|
||||
|
|
@ -16,14 +16,14 @@ const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
|
|||
// any already-open agent tab without a reload.
|
||||
export function getExpandDetailsPref(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(EXPAND_DETAILS_KEY) === '1';
|
||||
return localStorage.getItem(EXPAND_DETAILS_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function setExpandDetailsPref(v: boolean): void {
|
||||
try {
|
||||
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, '1');
|
||||
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, "1");
|
||||
else localStorage.removeItem(EXPAND_DETAILS_KEY);
|
||||
} catch {
|
||||
/* localStorage unavailable — preference is session-only */
|
||||
|
|
|
|||
|
|
@ -23,16 +23,16 @@
|
|||
// once, high in its tree, with the SAME key strings passed to this
|
||||
// component, so the two stay in sync without this component owning any
|
||||
// page-specific naming decision.
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { Badge } from '../badge/Badge.js';
|
||||
import { GearIcon } from '../icons.js';
|
||||
import { useThemeOverride, type ThemeOverride } from './theme-apply.js';
|
||||
import { useMotionOverride, type MotionOverride } from './motion-apply.js';
|
||||
import './SettingsMenu.css';
|
||||
import { useEffect, useRef, useState } from "preact/hooks";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { Badge } from "../badge/Badge.js";
|
||||
import { GearIcon } from "../icons.js";
|
||||
import { useThemeOverride, type ThemeOverride } from "./theme-apply.js";
|
||||
import { useMotionOverride, type MotionOverride } from "./motion-apply.js";
|
||||
import "./SettingsMenu.css";
|
||||
|
||||
const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark'];
|
||||
const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce'];
|
||||
const THEME_OPTIONS: ThemeOverride[] = ["system", "light", "dark"];
|
||||
const MOTION_OPTIONS: MotionOverride[] = ["system", "allow", "reduce"];
|
||||
|
||||
export interface SettingsMenuProps {
|
||||
themeKey: string;
|
||||
|
|
@ -51,7 +51,11 @@ export interface SettingsMenuProps {
|
|||
children?: ComponentChildren;
|
||||
}
|
||||
|
||||
export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProps) {
|
||||
export function SettingsMenu({
|
||||
themeKey,
|
||||
motionKey,
|
||||
children,
|
||||
}: SettingsMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const [theme, setTheme] = useThemeOverride(themeKey);
|
||||
|
|
@ -61,16 +65,21 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
if (
|
||||
rootRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!rootRef.current.contains(e.target)
|
||||
)
|
||||
setOpen(false);
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener("pointerdown", onPointerDown, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener("pointerdown", onPointerDown, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -89,7 +98,9 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
<span>theme</span>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
|
||||
onChange={(e) =>
|
||||
setTheme((e.target as HTMLSelectElement).value as ThemeOverride)
|
||||
}
|
||||
>
|
||||
{THEME_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
|
|
@ -102,7 +113,11 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
|
|||
<span>motion</span>
|
||||
<select
|
||||
value={motion}
|
||||
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
|
||||
onChange={(e) =>
|
||||
setMotion(
|
||||
(e.target as HTMLSelectElement).value as MotionOverride,
|
||||
)
|
||||
}
|
||||
>
|
||||
{MOTION_OPTIONS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
|
|
|
|||
|
|
@ -12,22 +12,28 @@
|
|||
// (see swarm-ui's `shell/Shell.css` file-top comment for a worked
|
||||
// example). A page with no motion-gated animation yet can still mount
|
||||
// this — the attribute is simply inert until something reads it.
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { useLocalSetting } from './settings-storage.js';
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useLocalSetting } from "./settings-storage.js";
|
||||
|
||||
export type MotionOverride = 'system' | 'reduce' | 'allow';
|
||||
export type MotionOverride = "system" | "reduce" | "allow";
|
||||
|
||||
export function useMotionOverride(key: string, fallback: MotionOverride = 'system') {
|
||||
export function useMotionOverride(
|
||||
key: string,
|
||||
fallback: MotionOverride = "system",
|
||||
) {
|
||||
return useLocalSetting<MotionOverride>(key, fallback);
|
||||
}
|
||||
|
||||
// Mounted once alongside `useApplyThemeOverride` — same single-mount-
|
||||
// point rationale.
|
||||
export function useApplyMotionOverride(key: string, fallback: MotionOverride = 'system'): void {
|
||||
export function useApplyMotionOverride(
|
||||
key: string,
|
||||
fallback: MotionOverride = "system",
|
||||
): void {
|
||||
const [override] = useMotionOverride(key, fallback);
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (override === 'system') {
|
||||
if (override === "system") {
|
||||
delete root.dataset.motion;
|
||||
} else {
|
||||
root.dataset.motion = override;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
// reading) need their own same-tab signal. The tiny module-level pub/sub
|
||||
// below is that signal; it's deliberately not exported, callers only see
|
||||
// the hook.
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
|
|
@ -70,10 +70,16 @@ function writeLocalSetting<T>(key: string, value: T): void {
|
|||
// this component's state through the identical "react to a change"
|
||||
// path every other subscriber uses — one path, not two that have to
|
||||
// agree.
|
||||
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
|
||||
export function useLocalSetting<T>(
|
||||
key: string,
|
||||
fallback: T,
|
||||
): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
|
||||
|
||||
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
|
||||
useEffect(
|
||||
() => subscribe(key, () => setValue(readLocalSetting(key, fallback))),
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, (next: T) => writeLocalSetting(key, next)];
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue