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
|
|
@ -33,7 +33,7 @@ claude --name <memorable> --model <cheaper-than-you> --dangerously-skip-permissi
|
|||
by name for follow-ups.
|
||||
- **`--model <cheaper-than-you>`** - think about which model the task
|
||||
actually needs; don't spend a bigger model's tokens than your own on
|
||||
mechanical work a cheaper one handles fine. Never use a *bigger* model
|
||||
mechanical work a cheaper one handles fine. Never use a _bigger_ model
|
||||
than yourself for a sub-agent - if the task needs that much capability,
|
||||
it's not the "mechanical batch" case this skill is for.
|
||||
- **`--dangerously-skip-permissions`** - required in headless (`-p`)
|
||||
|
|
@ -68,7 +68,7 @@ work while the sub-agent grinds.
|
|||
cases: "leave it as-is where genuinely unclear; do not guess."
|
||||
- **Ask for a report file** - per-item results + anything skipped and
|
||||
why, so you can verify without re-deriving.
|
||||
- **Tune on ONE item first**, eyeball the result, fix the prompt, *then*
|
||||
- **Tune on ONE item first**, eyeball the result, fix the prompt, _then_
|
||||
turn it loose on the full batch. A prompt bug replicated across 100
|
||||
items is 100 cleanups.
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ running at once instead of handing the whole thing to one.
|
|||
loop.
|
||||
- **Mind the container's memory cap before picking N.** Your whole
|
||||
container shares one `MemoryMax` (a few GB by default) with every
|
||||
subagent you spawn *and* your own process. A `claude` process plus its
|
||||
subagent you spawn _and_ your own process. A `claude` process plus its
|
||||
MCP servers can hold several hundred MB to ~1GB depending on the task;
|
||||
spawning a dozen at once on a small container doesn't just slow
|
||||
things down, it can OOM the whole container — taking your own
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ steps in the right order.
|
|||
## 1. Check for a duplicate before filing
|
||||
|
||||
`hive-forge list` has a server-side full-text `--search` flag that covers
|
||||
title *and* body - use it before writing anything:
|
||||
title _and_ body - use it before writing anything:
|
||||
|
||||
```sh
|
||||
hive-forge list --search "keyword or two" --state all
|
||||
|
|
@ -43,8 +43,8 @@ Prefer naming the broken behavior or missing capability over a specific
|
|||
error string or one-off symptom - "hive-forge list has no way to filter by
|
||||
milestone" beats "list command confusing" or "can't find issues for
|
||||
milestone 11 easily". If you're not sure which framing is more durable,
|
||||
lean toward whatever a colleague hitting the *same underlying gap* through
|
||||
a *different* symptom would still recognize.
|
||||
lean toward whatever a colleague hitting the _same underlying gap_ through
|
||||
a _different_ symptom would still recognize.
|
||||
|
||||
## 3. Body: what's wrong, not how to fix it
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ dimension directly.
|
|||
hardcoded, so check `repo-labels` first if you don't already know the
|
||||
scopes in use.
|
||||
- **PRs with no formally requested reviewer** - `hive-forge lint
|
||||
no-reviewer` flags PRs with zero requested reviewers at all; add
|
||||
no-reviewer` flags PRs with zero requested reviewers at all; add
|
||||
`--reviewer <name>` to instead flag PRs where that specific person
|
||||
isn't among the requested reviewers. This checks the forge's actual
|
||||
reviewer-request state, not a text `@name` mention anywhere in the
|
||||
|
|
@ -42,12 +42,13 @@ dimension directly.
|
|||
|
||||
Run the checks relevant to what you're verifying rather than assuming
|
||||
one covers everything - "is triage caught up" usually means unassigned
|
||||
+ unlabeled at minimum, with no-reviewer added if reviews matter for
|
||||
your workflow. Each verb supports `--json` if you want to fold the
|
||||
results into something else instead of reading the human-readable output.
|
||||
|
||||
- unlabeled at minimum, with no-reviewer added if reviews matter for
|
||||
your workflow. Each verb supports `--json` if you want to fold the
|
||||
results into something else instead of reading the human-readable output.
|
||||
|
||||
This is reactive/on-demand coverage checking, not a scheduled rollup or
|
||||
historical trend - if you need to track compliance *over time* (was
|
||||
historical trend - if you need to track compliance _over time_ (was
|
||||
triage worse last week than this week), that's a different, bigger
|
||||
ask than running these checks - don't build that unprompted just
|
||||
because the checks exist.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ description: Take a real screenshot of an HTML/CSS page from inside a container
|
|||
# Headless Screenshot
|
||||
|
||||
"No browser in this container" usually means no browser is
|
||||
*pre-installed*, not that one is unreachable. `nixpkgs#chromium` is a
|
||||
_pre-installed_, not that one is unreachable. `nixpkgs#chromium` is a
|
||||
`nix shell` away, and a single headless invocation renders a page and
|
||||
writes a PNG without a running server, a display, or any scripted
|
||||
interaction library.
|
||||
|
|
@ -65,7 +65,7 @@ peer agent's GUI-testing capability, try rendering it yourself first:
|
|||
This is a static render, not a browser automation tool. Verifying an
|
||||
interaction (a click, a hover state, a form submission) needs
|
||||
puppeteer/playwright or similar - reach for those only when the
|
||||
question is genuinely about *behavior*, not appearance. A missing
|
||||
question is genuinely about _behavior_, not appearance. A missing
|
||||
emoji/glyph rendering as a box in the screenshot is usually a font
|
||||
availability artifact of the headless environment, not a real bug -
|
||||
don't mistake one for the other when reporting results.
|
||||
|
|
@ -80,7 +80,7 @@ judge icon/glyph layout, not just to note "there'd be an icon here").
|
|||
- **Tofu boxes instead of emoji.** Plain `nixpkgs#chromium` has no
|
||||
color-emoji font, so any 🖥/📡/🔨-style glyph renders as an empty
|
||||
box. Add `nixpkgs#noto-fonts-color-emoji` to the `nix shell` - but a
|
||||
`FONTCONFIG_FILE` that lists *only* the emoji dir throws away the
|
||||
`FONTCONFIG_FILE` that lists _only_ the emoji dir throws away the
|
||||
base fonts and blanks all normal text instead, which is its own
|
||||
false alarm. Merge, don't override:
|
||||
```
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ work in your own style. That framing should hold throughout.
|
|||
|
||||
## Before you start
|
||||
|
||||
1. **Read the linked issue/task** - understand *why* the change exists,
|
||||
1. **Read the linked issue/task** - understand _why_ the change exists,
|
||||
not just what it does.
|
||||
2. **Check CI status first.** A failing build or test suite is an
|
||||
automatic block - don't write a detailed review of code that doesn't
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ requester-as-parent edge written at its `init_config` approval (so
|
|||
agent, including the bootstrap container (`ruth`) — it's just another
|
||||
root. The manager is reparentable like any other agent; there's no
|
||||
"structurally root" carve-out. Its privileges live on its MCP socket,
|
||||
not its tree position (see *Manager special-casing today* below).
|
||||
not its tree position (see _Manager special-casing today_ below).
|
||||
|
||||
### Reparenting
|
||||
|
||||
|
|
@ -88,15 +88,15 @@ umount-old / mount-new / restart-cascade step.
|
|||
|
||||
## Planned topology semantics (once ancestor-based enforcement lands)
|
||||
|
||||
| operation | who can do it |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
|
||||
| `request_init_config` (spawn a new child) | any agent, child added under self |
|
||||
| config change via forge PR (any descendant's config) | any ancestor |
|
||||
| `get_logs` (any descendant) | any ancestor |
|
||||
| moderate reminders (cancel any open thread of a descendant) | any ancestor |
|
||||
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
|
||||
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
|
||||
| operation | who can do it |
|
||||
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `kill` / `start` / `restart` / `update` (any descendant) | any ancestor |
|
||||
| `request_init_config` (spawn a new child) | any agent, child added under self |
|
||||
| config change via forge PR (any descendant's config) | any ancestor |
|
||||
| `get_logs` (any descendant) | any ancestor |
|
||||
| moderate reminders (cancel any open thread of a descendant) | any ancestor |
|
||||
| `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else |
|
||||
| `request_update_meta_inputs` (bump meta lock) | root agents only (today: just `manager`) |
|
||||
|
||||
"Ancestor" walks `ContainerView.parent` chains; cycles are guarded by a
|
||||
visited-set at dispatch time (a malformed `topology.json` can't lock
|
||||
|
|
@ -138,11 +138,11 @@ other agents don't:
|
|||
capability.
|
||||
- **Prompt/tools** — the system prompt uses `<!-- role:agent -->` /
|
||||
`<!-- role:manager -->` marker blocks, and a `Flavor::{Agent,
|
||||
Manager}` switch picks the MCP tool allow-list claude sees. Both are
|
||||
Manager}` switch picks the MCP tool allow-list claude sees. Both are
|
||||
already parametrised on a single flavour value, so the planned
|
||||
per-capability-group version (`cap:<group>` prompt blocks + a
|
||||
matching tool allow-list) is additive rather than a rewrite.
|
||||
- **State dirs** — *not* special-cased: `HYPERHIVE_STATE_DIR` is
|
||||
- **State dirs** — _not_ special-cased: `HYPERHIVE_STATE_DIR` is
|
||||
injected uniformly via `systemd.globalEnvironment` for every
|
||||
container including the manager, so all token/state paths resolve
|
||||
through it the same way everywhere.
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ Nothing to run — it is served on the swarm apex
|
|||
decide whether you can actually open it:
|
||||
|
||||
- **You are in `admins`** (step 3). The gateway asks authelia whether
|
||||
you have a session; the rule that makes it mean *operator* wants the
|
||||
you have a session; the rule that makes it mean _operator_ wants the
|
||||
group. Without it you log in and still get bounced.
|
||||
- **The name resolves to this host.** It is published to the hive's own
|
||||
resolver and to `/etc/hosts` when `gateway.localHostsEntry` is on; from
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ On a **successful** broker delivery, `forge_notify` marks the thread
|
|||
read on forge straight away (`PATCH /notifications/threads/{id}`). The
|
||||
broker inbox is the durable work queue now — each delivered wake is a
|
||||
sqlite row with its own ack lifecycle — so the forge unread flag no
|
||||
longer needs to track whether the agent has *processed* a
|
||||
longer needs to track whether the agent has _processed_ a
|
||||
notification. Clearing it on delivery keeps forge's unread set **tiny
|
||||
by construction**: at rest it holds only threads that failed to
|
||||
deliver plus whatever arrived since the last 30s poll.
|
||||
|
|
@ -132,19 +132,19 @@ own read-state is the only durable record this design needs.
|
|||
**Read-before-comment guard doesn't block a fresh wake.** hive-forge's
|
||||
read-before-comment guard (which keys off forge unread-state) does not
|
||||
force the agent to view a thread before commenting on it: the broker
|
||||
wake already carries the notification body, so *delivery is the read*.
|
||||
wake already carries the notification body, so _delivery is the read_.
|
||||
An agent that wants the full thread still runs `hive-forge comments` /
|
||||
`view`.
|
||||
|
||||
**In-process dedupe (tiny, ephemeral).** A single-process map (thread
|
||||
id → last-delivered `updated_at`) guards the narrow window where a
|
||||
mark-read call *transiently fails* and the thread reappears unread in
|
||||
mark-read call _transiently fails_ and the thread reappears unread in
|
||||
the next poll before its `updated_at` bumps — so a flaky PATCH doesn't
|
||||
re-fire the wake. It is **not persisted** and resets on restart (forge
|
||||
read-state covers the durable case). Each poll prunes it to the ids in
|
||||
the single `limit=UNREAD_FETCH_LIMIT` (50) fetch page, so it can never
|
||||
exceed that many entries (a debug assertion pins the invariant; the
|
||||
fetch limit and the bound are the same constant). A failed *delivery*
|
||||
fetch limit and the bound are the same constant). A failed _delivery_
|
||||
is left unread and out of the map, so it resurfaces next tick.
|
||||
|
||||
Self-echo notifications (the agent's own writes, see below) are marked
|
||||
|
|
@ -225,13 +225,13 @@ lookahead.
|
|||
|
||||
Five shapes, distinguished by the notification's classification:
|
||||
|
||||
| Trigger | Wrapper |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Trigger | Wrapper |
|
||||
| ----------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| Comment on issue / PR | `[comment on PR #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
|
||||
| Review submission | `[PR approved #N owner/repo] title\nurl: ...\n\nauthor: body\nassignee: ...` |
|
||||
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
| New issue / PR | `[new PR #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
| Later activity (open, not creation) | `[activity on PR #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
| State change | `[PR merged #N owner/repo] title\nurl: ...\nassignee: ...` |
|
||||
|
||||
The issue/PR's own description is deliberately never embedded here (only a
|
||||
comment/review body is — that's the actual new content a notification
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ Forgejo's notifications API is modelled on GitHub's, so one tolerant
|
|||
parse serves both: `id`, `repository.full_name`,
|
||||
`subject {title,url,latest_comment_url}` and `updated_at` line up field
|
||||
for field. The two real differences are absorbed by lenient
|
||||
deserializers — GitHub sends the thread id as a *string* where Forgejo
|
||||
deserializers — GitHub sends the thread id as a _string_ where Forgejo
|
||||
sends a number, and says `PullRequest` where Forgejo says `Pull`. Todo
|
||||
keys are prefixed `gh:<id>` so a github thread id cannot collide with a
|
||||
forge one.
|
||||
|
|
@ -118,16 +118,16 @@ Two host differences worth knowing before touching this code:
|
|||
|
||||
- **Auth scheme, not just value.** Forgejo takes
|
||||
`Authorization: token <t>`; GitHub wants `Bearer <t>` plus `Accept:
|
||||
application/vnd.github+json`, `X-GitHub-Api-Version` and a
|
||||
application/vnd.github+json`, `X-GitHub-Api-Version` and a
|
||||
`User-Agent`. Sending Forgejo's form to GitHub does not error — it
|
||||
authenticates as *nobody* and silently drops to the unauthenticated
|
||||
authenticates as _nobody_ and silently drops to the unauthenticated
|
||||
rate limit. The cheap way to tell the two apart is the rate-limit
|
||||
header: `x-ratelimit-remaining` near 5000 is an authenticated user,
|
||||
near 60 is anonymous.
|
||||
- **GitHub sets the cadence.** It returns `X-Poll-Interval` (60s in
|
||||
practice, slower than our own tick) and rate-limits callers who ignore
|
||||
it, so the loop re-arms to the server's interval whenever that is
|
||||
*slower* than ours. A hint faster than our own tick is not a reason to
|
||||
_slower_ than ours. A hint faster than our own tick is not a reason to
|
||||
poll harder.
|
||||
|
||||
⚠️ **This needs the `notifications` scope on the PAT.** A token minted
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ hive-c0re maintains the local clone at
|
|||
`internal/knowledge` (see `docs/swarm/README.md` § Swarm-wide forge
|
||||
webhooks). On any push to main, including merge commits, it sends an
|
||||
event to every hive over the swarm queue and each hive runs `git
|
||||
pull`, so agents see the new content on their next turn.
|
||||
pull`, so agents see the new content on their next turn.
|
||||
|
||||
A hive that is offline when the event is sent does not get it on
|
||||
reconnect — the periodic pull below is what closes that gap. So one
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@ untouched by any of it.
|
|||
└─────────┘ └─────────┘ └─────────┘
|
||||
```
|
||||
|
||||
| container | netns | IPv4 | listens / reached via |
|
||||
| -------------- | ----------------------- | -------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `hive-gateway` | host (shared) | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge |
|
||||
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
|
||||
| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost |
|
||||
| `hive-ci` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
|
||||
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 |
|
||||
| container | netns | IPv4 | listens / reached via |
|
||||
| -------------- | ----------------------- | -------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `hive-gateway` | host (shared) | host addresses | nginx `:80`/`:443` (every vhost); dnsmasq `bridgeIp:53` + DHCP `:67` on the bridge |
|
||||
| `hive-forge` | host (shared) | host addresses | forgejo `:3000` http, `:2222` git-ssh; fronted by the `forge.<swarm-domain>` vhost |
|
||||
| `hive-matrix` | host (shared) | host addresses | tuwunel `:8008` (+ optional federation port); fronted by the matrix vhost |
|
||||
| `hive-ci` | private, veth on bridge | DHCP pool | outbound only (runner → forge); no inbound surface |
|
||||
| `h-<agent>` | private, veth on bridge | DHCP pool | web UI via UDS `/run/hive-agent/<name>` → nginx sub-path; in-container UI port hashed 8100–8999 |
|
||||
|
||||
The flows, end to end:
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ The flows, end to end:
|
|||
host firewall (udp 67 hole) → dnsmasq pool → lease + router option.
|
||||
- **DNS** — agents and the service containers query `bridgeIp:53`; hive
|
||||
zones are answered authoritatively with the bridge IP, everything else
|
||||
forwards to the host's resolvers (see *Resolver behaviour* below). Each
|
||||
forwards to the host's resolvers (see _Resolver behaviour_ below). Each
|
||||
container points its own `resolv.conf` there, and one that instead
|
||||
inherits the host's resolves no swarm name at all — those records exist
|
||||
only on the bridge.
|
||||
|
|
@ -169,7 +169,7 @@ agent containers.
|
|||
The **host** firewall is the only firewall. The shared-netns infra
|
||||
containers (gateway, forge, matrix) set
|
||||
`networking.firewall.enable = false`: a NixOS firewall inside a
|
||||
shared-netns container runs against the *host* ruleset — at container
|
||||
shared-netns container runs against the _host_ ruleset — at container
|
||||
boot its `firewall-start` flushes the `nixos-fw` chains, rebuilds them
|
||||
from the container's (empty) port list, and deletes the host's
|
||||
`nixos-nat-*` chains without recreating them, silently wiping the
|
||||
|
|
@ -183,7 +183,7 @@ By default agents can only reach the host on 80/443 (+53 DNS), so a
|
|||
host-side service on another port — e.g. a dev OTLP collector you want
|
||||
agents to reach directly — is unreachable. (hyperhive's own telemetry
|
||||
needs none of this: `otel.enable` opens its collector's port itself, and
|
||||
`otel.endpoint` is the *upstream*, which no agent ever dials. See
|
||||
`otel.endpoint` is the _upstream_, which no agent ever dials. See
|
||||
`docs/scheduler/observability.md`.)
|
||||
|
||||
`services.hyperhive.network.exposeHostPorts = [ 4318 ];` opens each
|
||||
|
|
@ -206,12 +206,12 @@ Each agent container runs in a private network namespace with a dedicated
|
|||
veth pair attached to the bridge. The following table summarises what
|
||||
the nix side sets up unconditionally:
|
||||
|
||||
| effect | mechanism |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` |
|
||||
| Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC |
|
||||
| Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks |
|
||||
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host |
|
||||
| effect | mechanism |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| IP forwarding | `boot.kernel.sysctl."net.ipv4.ip_forward" = 1` |
|
||||
| Internet NAT | `networking.nat { enable = true; internalInterfaces = [ bridgeName ]; }` — MASQUERADE on packets leaving via any external NIC |
|
||||
| Loopback DROP | `networking.firewall.extraInputRules` — drops bridge-subnet → `127.0.0.0/8` traffic; defence-in-depth against routing table leaks |
|
||||
| Gateway access | `networking.firewall.interfaces.<bridge>.allowedTCPPorts = [ 80 443 ]` — lets isolated agents (private netns, veth on bridge) reach nginx on the host |
|
||||
| c0re signal | `HIVE_NETWORK_BRIDGE`, `HIVE_NETWORK_SUBNET` in `systemd.services.hive-c0re.environment` — both **required**; `hive-c0re` refuses to start without them |
|
||||
|
||||
`HIVE_NETWORK_SUBNET` is the host-side bridge IP + prefix (e.g.
|
||||
|
|
@ -262,7 +262,7 @@ wiring is runtime:
|
|||
|
||||
**Why isolation is safe**: hive-c0re's control-plane sockets are unix
|
||||
domain sockets bind-mounted into containers, not network listeners — see
|
||||
the *Control plane (no network)* bullet under [Network
|
||||
the _Control plane (no network)_ bullet under [Network
|
||||
map](#network-map) above. `PRIVATE_NETWORK=1` has no effect on a path
|
||||
that never touches the network stack.
|
||||
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ Note that the mesh is gated on `swarm.wireguard.enable`, **not** on
|
|||
|
||||
## Pointing a hive at it
|
||||
|
||||
The block above configures the host that *receives*. Every hive that
|
||||
*pushes* separately needs to be told where the store is:
|
||||
The block above configures the host that _receives_. Every hive that
|
||||
_pushes_ separately needs to be told where the store is:
|
||||
|
||||
```nix
|
||||
services.hyperhive.swarm.snapshotStore = {
|
||||
|
|
@ -65,9 +65,9 @@ inconsistency:
|
|||
|
||||
Note the option lives under `swarm.*` while the receiving host's lives
|
||||
under `services.hyperhive.snapshotStore`. That is the distinction the
|
||||
two namespaces carry throughout: `swarm.*` describes *the swarm* as seen
|
||||
from this host, and a bare `services.hyperhive.<service>` describes *a
|
||||
role this host performs*. A store host sets both --- one to run the
|
||||
two namespaces carry throughout: `swarm.*` describes _the swarm_ as seen
|
||||
from this host, and a bare `services.hyperhive.<service>` describes _a
|
||||
role this host performs_. A store host sets both --- one to run the
|
||||
receiver, one only if it also runs a hive that pushes.
|
||||
|
||||
With it set, `hivectl agent <name> subvol snapshot push <label>
|
||||
|
|
@ -88,7 +88,7 @@ that address provably came from the holder of that private key. A
|
|||
packet that reaches the receiver has therefore already been
|
||||
authenticated by the kernel.
|
||||
|
||||
Layering TLS client certs on top would authenticate *the same fact* a
|
||||
Layering TLS client certs on top would authenticate _the same fact_ a
|
||||
second time, and add a credential with an expiry --- a migration that
|
||||
fails because a renewal quietly didn't happen, discovered on the day
|
||||
you need to move an agent.
|
||||
|
|
@ -98,14 +98,14 @@ you need to move an agent.
|
|||
The destination is keyed by **agent**.
|
||||
|
||||
This is not cosmetic. After a migration, an agent's next incremental
|
||||
send arrives from a *different* hive than the previous one. Keying by
|
||||
send arrives from a _different_ hive than the previous one. Keying by
|
||||
hive would split that agent's snapshot chain across two directories,
|
||||
and `btrfs send -p` would fail to find its parent --- breaking exactly
|
||||
the case the store exists to serve.
|
||||
|
||||
## What the sender can and cannot choose
|
||||
|
||||
A `btrfs send` stream carries no notion of *which agent* it belongs to,
|
||||
A `btrfs send` stream carries no notion of _which agent_ it belongs to,
|
||||
and the subvolume name inside it is chosen by the sender. So the
|
||||
protocol is one `agent <name>` header line, then the raw stream.
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ address being set are assertions, not documentation --- bound to
|
|||
state.
|
||||
|
||||
Binding is not sufficient on its own. NixOS's firewall is default-deny
|
||||
and filters in netfilter, *before* a packet reaches a bound socket, so
|
||||
and filters in netfilter, _before_ a packet reaches a bound socket, so
|
||||
the port is opened explicitly --- and scoped to the mesh interface:
|
||||
|
||||
```nix
|
||||
|
|
@ -176,8 +176,8 @@ and means it should get the treatment a backup host gets --- restricted
|
|||
access, and a decision (rather than an omission) on encryption at rest.
|
||||
|
||||
The trap is the label: this box holds backup-grade data while not being
|
||||
called a backup, so it can end up with backup-grade *exposure* and
|
||||
non-backup-grade *controls*. Nobody puts a migration staging area on
|
||||
called a backup, so it can end up with backup-grade _exposure_ and
|
||||
non-backup-grade _controls_. Nobody puts a migration staging area on
|
||||
the access-review list.
|
||||
|
||||
### What a snapshot contains
|
||||
|
|
@ -202,7 +202,7 @@ gap is tracked separately.
|
|||
|
||||
### Retention
|
||||
|
||||
Retention lives on the *sending* side (last-N by count, swept
|
||||
Retention lives on the _sending_ side (last-N by count, swept
|
||||
periodically), not here. Count rather than age is deliberate: a count
|
||||
is bounded by construction, whereas an age policy silently scales disk
|
||||
usage with how hot a hive runs.
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ existing containers can't be started.
|
|||
### `RestrictAddressFamilies` fails as "Address family not supported by protocol"
|
||||
|
||||
A unit whose `RestrictAddressFamilies` omits a family gets `EAFNOSUPPORT`
|
||||
(errno 97) back from `socket()`. Clients surface that as *"tcp open error:
|
||||
Address family not supported by protocol"* — the message names the
|
||||
(errno 97) back from `socket()`. Clients surface that as _"tcp open error:
|
||||
Address family not supported by protocol"_ — the message names the
|
||||
**protocol** and never the **sandbox**, so it reads like a dead network, a
|
||||
missing route, or an IPv6 problem.
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ re-checks it when the program changes.** A unit that only served a unix
|
|||
socket when it was written is correct at `[ "AF_UNIX" ]` and silently wrong
|
||||
the day someone adds an HTTP client. Check the unit in the same commit as
|
||||
the client — and when narrowing it, prefer a test that derives the required
|
||||
families from the code (which fails on the *next* client too) over one that
|
||||
families from the code (which fails on the _next_ client too) over one that
|
||||
asserts today's list.
|
||||
|
||||
### `register_agent` is idempotent
|
||||
|
|
@ -120,7 +120,7 @@ operator's host-level `allowUnfree` does **not** propagate in.
|
|||
Operators don't need to set anything on their side.
|
||||
|
||||
That same isolation is why an agent can't pick a claude out of a
|
||||
*different* nixpkgs by itself: a container only ever sees the one
|
||||
_different_ nixpkgs by itself: a container only ever sees the one
|
||||
nixpkgs the meta flake injects, so an `agent.nix` naming the host's
|
||||
`nixpkgs-unstable` has nothing to name. A release channel can trail
|
||||
unstable by weeks on this package, which is what
|
||||
|
|
@ -140,7 +140,7 @@ an input only because a docs tree has no runtime dependencies.
|
|||
|
||||
The `storePath` trap is worth spelling out, because it is not confined
|
||||
to options the operator writes: **any** option of type `package` fed a
|
||||
store-path *string* coerces through `lib.toDerivation`, i.e.
|
||||
store-path _string_ coerces through `lib.toDerivation`, i.e.
|
||||
`builtins.storePath`. `environment.systemPackages` and
|
||||
`systemd.services.<name>.path` both do it (the latter takes plain
|
||||
strings like `/run/wrappers` happily, but anything under
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ human review that already happened.
|
|||
|
||||
Whether a PR can merge, and what counts toward "can", is configured
|
||||
per repo in its branch-protection settings — not a fact true of every
|
||||
hive or every repo. The pieces a repo *can* require:
|
||||
hive or every repo. The pieces a repo _can_ require:
|
||||
|
||||
- **CI is green** — the repo's required status checks pass on the
|
||||
PR's current head commit, if the repo requires any.
|
||||
|
|
@ -40,8 +40,8 @@ independently.
|
|||
|
||||
Auto-merge isn't "no human ever looked at this." Whoever arms it has
|
||||
already judged the PR sound at a coarse level — the signal it sends is
|
||||
roughly *"apart from maybe minor tweaks a reviewer can still catch,
|
||||
I think this is fine."* That's the human-in-the-loop step, and it
|
||||
roughly _"apart from maybe minor tweaks a reviewer can still catch,
|
||||
I think this is fine."_ That's the human-in-the-loop step, and it
|
||||
already happened. No large changes are expected to surface after
|
||||
that point — a reviewer's job past that point is to flag it if one
|
||||
does, not to assume none ever will.
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ Two things worth knowing before you rely on it:
|
|||
already has forge access; if you take outside/fork contributions,
|
||||
gate CI behind Forgejo's fork-PR-approval setting or restrict the
|
||||
workflow to push-only triggers (forks can't push to your branches).
|
||||
See *Security* below for the full threat model.
|
||||
See _Security_ below for the full threat model.
|
||||
- **Watch your disk.** CI builds through the host's nix store with no
|
||||
automatic garbage collection of its own — a busy CI day can fill
|
||||
`/nix/store` until every job fails with `ENOSPC`. Add the daily +
|
||||
disk-pressure GC config from *Host store maintenance* below to your
|
||||
disk-pressure GC config from _Host store maintenance_ below to your
|
||||
host's NixOS config (not optional if you plan to actually use this).
|
||||
|
||||
Everything below this point is implementation detail: exact
|
||||
|
|
@ -36,11 +36,11 @@ defined in [`.forgejo/workflows/ci.yml`](../../.forgejo/workflows/ci.yml). All
|
|||
three are required checks (forge branch protection) — a hit on any of them
|
||||
blocks merge.
|
||||
|
||||
| Job | What it runs |
|
||||
| --- | --- |
|
||||
| **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation |
|
||||
| **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) |
|
||||
| **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) |
|
||||
| Job | What it runs |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **nix flake check** | treefmt + rustfmt formatting, `cargo clippy -D warnings`, `cargo test`, module evaluation |
|
||||
| **tracker-tag lint** | flags `#NNN` issue tags in source and comments (`scripts/check-issue-refs.sh`) |
|
||||
| **comment-block lint** | flags contiguous comment blocks over 30 lines (`scripts/check-comment-blocks.sh`) |
|
||||
|
||||
`hive-forge ci-rerun --pr N` dispatches a `workflow_dispatch` retrigger
|
||||
without an empty commit.
|
||||
|
|
@ -90,7 +90,7 @@ slow); run those manually before pushing Rust changes.
|
|||
|
||||
The internal forge is always present (mandatory), so the runner always has a
|
||||
hive-forge instance to register against — nothing extra to enable beyond
|
||||
`services.hyperhive.deploy.forgejo.ci.enable = true` (see *For operators* above).
|
||||
`services.hyperhive.deploy.forgejo.ci.enable = true` (see _For operators_ above).
|
||||
|
||||
Optional tuning: `services.hyperhive.deploy.forgejo.ci.name` (runner name in forge
|
||||
admin panel), `concurrency` (parallel job capacity), `labels` (workflow
|
||||
|
|
@ -131,7 +131,7 @@ Gated on `HYPERHIVE_FORGE_CI_ENABLED` (the nix module sets it on `hive-c0re.serv
|
|||
- The container boots immediately — nothing gates its start on registration.
|
||||
- tmpfiles seeds `/run/hive-ci/runner-token` with `TOKEN=placeholder` so the runner's `EnvironmentFile` always exists.
|
||||
- `gitea-runner-hive.service` has an `ExecStartPre` precond (ahead of the nix-daemon wait) that **fails fast** unless it is already registered (`.runner` present) or a real, non-placeholder token is in place. `Restart=on-failure` (no start-limit cap) self-heals it: a runner that precond-fails at boot keeps retrying until hive-c0re writes the token (c0re's explicit restart is the primary path; the retry is the safety net).
|
||||
- **Convergence**: because the token write targets the *host* file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own.
|
||||
- **Convergence**: because the token write targets the _host_ file, even if c0re's restart races the container being down, the container later starts, reads the now-real token, passes the precond, and registers on its own.
|
||||
|
||||
## Actions checkout mirror
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ changes) is submitted to the global job-DAG queue (`hive-c0re/src/job_queue/`)
|
|||
as a **DAG of primitive nodes**. One scheduler task drives all DAGs;
|
||||
concurrency comes from the resource classes below, not from multiple workers.
|
||||
Special cases like graceful stop, deferred starts, and the meta-update
|
||||
cascade need no bespoke code paths — each is expressed as a DAG *shape*
|
||||
cascade need no bespoke code paths — each is expressed as a DAG _shape_
|
||||
built from the same primitive nodes.
|
||||
|
||||
### Two levels: DAG and node
|
||||
|
|
@ -42,54 +42,54 @@ there is no malformed spec to reject.
|
|||
|
||||
Nix-heavy — hold one of the `buildSlots` permits for the node's duration:
|
||||
|
||||
| Node | Wraps |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead |
|
||||
| `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node |
|
||||
| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent in meta |
|
||||
| `MetaLock` | meta flake lock bump (`lock_update` / boot-sweep `lock_update_hyperhive`, commit fused — see below); fans out child `Rebuild` DAGs on completion |
|
||||
| `DeployWindow` | resource-holding root of the merge-config-PR deploy subtree — declares the build slot, the lease and the meta window, then completes immediately so its children run under them (see _Approvals_ below) |
|
||||
| `DeployApply` | the deploy's irreversible half: ff-merge the reviewed PR head, two-phase meta deploy, container rebuild |
|
||||
| Node | Wraps |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Prebuild` | `lifecycle::prebuild_toplevel` — build the toplevel out-of-band while the container keeps serving (its meta preamble is the upstream `MetaSync` node). Skipped when the container is already down; `Swap` builds inline instead |
|
||||
| `Swap` | drop-in rewrite + `nixos-container update` profile-swap (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node |
|
||||
| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent in meta |
|
||||
| `MetaLock` | meta flake lock bump (`lock_update` / boot-sweep `lock_update_hyperhive`, commit fused — see below); fans out child `Rebuild` DAGs on completion |
|
||||
| `DeployWindow` | resource-holding root of the merge-config-PR deploy subtree — declares the build slot, the lease and the meta window, then completes immediately so its children run under them (see _Approvals_ below) |
|
||||
| `DeployApply` | the deploy's irreversible half: ff-merge the reviewed PR head, two-phase meta deploy, container rebuild |
|
||||
|
||||
Cheap — no build slot:
|
||||
|
||||
| Node | Behavior |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation |
|
||||
| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction |
|
||||
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
|
||||
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet |
|
||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||
| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped |
|
||||
| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the `Rebuilt` manager event is emitted by the DAG's `EmitRebuilt` tail node, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace |
|
||||
| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ |
|
||||
| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn |
|
||||
| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way |
|
||||
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
|
||||
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
|
||||
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
|
||||
| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s |
|
||||
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
|
||||
| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree |
|
||||
| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent |
|
||||
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent *and* per outcome |
|
||||
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
|
||||
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
|
||||
| `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option<Ident>)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing |
|
||||
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
|
||||
| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node |
|
||||
| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless |
|
||||
| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop |
|
||||
| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless |
|
||||
| Node | Behavior |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MergeVerify` | the deploy's pre-merge gate — PR-head drift check, fetch, `verify_commit` eval. Mutates nothing, so a rejection here needs no compensation |
|
||||
| `DeployTail` | the deploy's `AfterAny` compensation + bookkeeping tail: (1) rolls `applied/main` back from the parked `refs/hyperhive/rollback/<id>` and aborts the staged meta lock when the deploy never confirmed good; (2) mirrors whichever deploy tag landed to the forge config repo, always, best-effort; (3) posts the failing build log back onto the config PR when the deploy failed. Named for (2)/(3), which run on the success path too — not `AbortDeploy`. Infallible by construction |
|
||||
| `MetaSync` | the rebuild's meta preamble — rebuild-dir prep, idempotent meta `sync_agents`, optional per-agent relock. Holds the `MetaWindow` resource (below); deliberately its own node so the window never covers `Prebuild`'s multi-minute build |
|
||||
| `Provision` | first-spawn pre-create provisioning — proposed/applied repos, state subvolume, meta registration (`sync_agents`); runs ahead of `Create` so the `nixos-container create --flake meta#<name>` ref resolves. Store/meta-only, no container yet |
|
||||
| `Reconcile` | idempotent power converge: read `wanted` (below) + observed state; start if `Up` & down (cold-start fallback included), stop if `Offline` & up, else noop |
|
||||
| `Start` | mechanical container start — runtime dir + drop-ins, `start_with_fallback`, MCP listener registration, the manager kick. Fanned out by a `Reconcile` that observed `wanted = Up` and the container down |
|
||||
| `Stop` | mechanical container stop — `nixos-container` kill, MCP listener unregister, the `Killed` manager notify. Fanned out by a `Reconcile` that observed `wanted = Offline` and up |
|
||||
| `StopForUpdate` | mechanical `nixos-container stop` for the profile swap; never touches `wanted`; noop if already stopped |
|
||||
| `RebuildBookkeeping` | the swap's Ok-only bookkeeping tail — rev marker, forge/matrix sync, manager kick, rescan, meta-inputs snapshot; `AfterOk(Swap)` so it runs only on a successful swap (the `Rebuilt` manager event is emitted by the DAG's `EmitRebuilt` tail node, not here). Split out of `Swap` for dashboard visibility + retry granularity, declares no resources of its own — a coordinated child of the `AgentWindow` brace |
|
||||
| `AgentWindow` | pure resource holder — the brace for one agent's rebuild. Declares the build slot + agent lease atomically and holds both for its whole subtree, so `Prebuild` and the `Signal`→`Drain` quiesce window run concurrently instead of one nested under the other. Performs no work; see _Braces_ |
|
||||
| `Signal` | set the graceful fence + kick, so the harness runs one stop-checkpoint turn |
|
||||
| `Drain` | await the harness clearing the fence, bounded by the 3-min graceful-stop timeout; resolves ok either way |
|
||||
| `PauseSignal` | write the pause marker + mark `pause_pending`. No kick, unlike `Signal` — the harness's between-turns poll is already responsive enough, and `Signal`'s kick-message body ("you were just (re)started") would be actively misleading here |
|
||||
| `PauseDrain` | await the harness reporting `PauseAcknowledged`, bounded timeout; best-effort like `Drain` |
|
||||
| `DestroyContainer` | `nixos-container destroy` + un-registration (drop from the roster, clear the ephemeral runtime dir). Runs downstream of a `Stop`, so deliberately excluded from `takes_container_down` — the container is already down by the time it claims |
|
||||
| `PurgeState` | the `purge = true` half of a destroy: delete the agent's state subvolume (via hive-priv) plus its state/applied dirs. Own node because it's conditional and the irreversible step |
|
||||
| `DestroyBookkeeping` | the post-destroy tail — meta sync, fail pending approvals, drop the power intent, notify the manager, rescan, re-emit the tombstone, resync tmpfiles. Same split rationale as `RebuildBookkeeping`/`Swap`. Its `purge` flag only selects the wording of the approval-failure reason and the manager notification — the destructive work is `PurgeState`'s |
|
||||
| `SetWanted` | write the durable power intent (`wanted = Up`/`Offline`) as the head node of a power-op DAG, replacing the old pre-submit side effect. Takes the agent lease even though it's a store write, so the intent write and the tail `Reconcile` are atomic per-agent — two racing power ops can't clobber each other's intent before either reconciles |
|
||||
| `FinalizeDeploy` | deploy phase 3 — drop the rollback ref, plant `deployed/<id>`, commit the staged `flake.lock`. The first two git steps are fatal on purpose, so a confirmed-good deploy's outcome and the repo's state can't disagree |
|
||||
| `ResolveApproval` | tail of an approval-carrying DAG — resolve the approval row from how the work ended (`AfterAny`, one node emitted per outcome). Agentless: the approval row already names its agent |
|
||||
| `EmitRebuilt` | tail of a rebuild/perm-change — emit the agent's `Rebuilt` manager event (ok/fail per outcome, nothing on cancel). One node per agent _and_ per outcome |
|
||||
| `WriteDropin` | `set_nspawn_flags` + `set_resource_limits` + daemon-reload |
|
||||
| `WritePermFile` | commit `tool-groups.json` / `capabilities.json` (single git commit under `META_LOCK`) + emit the P3RM1SS10NS snapshots |
|
||||
| `Reparent` | `set-parent` / `set-parent-bulk`: apply every `(child, new_parent)` move under one `META_LOCK` commit (`meta::bulk_commit_topology`), send the per-agent move notifications, rescan + diff-emit. Agentless like `MetaLock` — a bulk move can span multiple agents, and a reparent touches the meta repo, not any one container. `moves` is typed `(Ident, Option<Ident>)` pairs, not raw strings. Rides the existing `Template::MetaUpdate` variant rather than a dedicated one — it's internal-only (never reaches the graph wire), so the stand-in only affects `terminal_hook` dispatch (resolves to no hook either way) and history-retention bucketing |
|
||||
| `ForgeSweep` | one-shot boot-time forge user/token sweep for every container (`forge::ensure_all`) as a first-class node, so it shows as real work on the dashboard instead of running invisibly in a bare `tokio::spawn`. Agentless |
|
||||
| `MatrixSweep` | same as `ForgeSweep`, for matrix (`matrix::ensure_all`). The periodic 30-min re-sweep stays a background loop in `main.rs`; only the boot-time instance is a node |
|
||||
| `WebhookRegister` | one-shot boot-time Forgejo webhook registration (`internal/knowledge` push→pull, `agent-configs` PR→approval). No-op until the core token, hive domain, and HMAC secret are all available. Agentless |
|
||||
| `KnowledgePull` | one-shot boot-time `/knowledge` pull (`knowledge::pull`), reconciling commits that landed while `hive-c0re` was down. Same rationale as `MatrixSweep`: the periodic hourly re-pull stays a background loop |
|
||||
| `WantedPull` | one-shot boot-time pull of the agent set the swarm controller declares for this hive (`wanted::pull`), converging the agents it names. No background loop behind this one — boot is the whole cadence; the deploy event (`swarm_status`) is the fast path, this repairs a missed one. Agentless |
|
||||
|
||||
There is deliberately **no `GitCommit` node**: `meta.rs` fuses each mutation
|
||||
with its commit under its internal `META_LOCK` mutex, so a standalone commit
|
||||
node would open a dirty-working-tree window between nodes.
|
||||
|
||||
Two further layers protect the meta repo across *windows* that span multiple
|
||||
Two further layers protect the meta repo across _windows_ that span multiple
|
||||
`META_LOCK` acquisitions — above all the approval deploy's prepare→finalize
|
||||
span, which keeps a bumped `flake.lock` **staged uncommitted** for the whole
|
||||
container build:
|
||||
|
|
@ -104,7 +104,7 @@ container build:
|
|||
because a resource is held by a subtree root across its whole subtree, which
|
||||
a `MutexGuard` (bounded by one executor fn) cannot — that is what lets a
|
||||
multi-node deploy own one window. For the same reason the window must stay
|
||||
*off* long store-only work: the rebuild's meta preamble is its own
|
||||
_off_ long store-only work: the rebuild's meta preamble is its own
|
||||
`MetaSync` node, a sibling of (never a parent of) `Prebuild`, so the
|
||||
toplevel build runs outside the window and `buildSlots > 1` still gives
|
||||
concurrent rebuilds across agents.
|
||||
|
|
@ -119,10 +119,10 @@ container build:
|
|||
The `stop` / `start` power ops write the durable `wanted` intent via a head
|
||||
`SetWanted` node (not a pre-submit side effect) — it holds the agent lease,
|
||||
so intent-write + reconcile is atomic per-agent. `restart` is the exception:
|
||||
it writes *no* intent (no `SetWanted` head) — it bounces the container and
|
||||
it writes _no_ intent (no `SetWanted` head) — it bounces the container and
|
||||
lets the tail `Reconcile` converge to the agent's existing `wanted`, so a
|
||||
deliberately-stopped agent is not forced back up by a hive-wide restart. The
|
||||
hive-wide power ops — `restart`, `stop`, and `start` — take an agent *list*:
|
||||
hive-wide power ops — `restart`, `stop`, and `start` — take an agent _list_:
|
||||
a hive-wide `hivectl restart` / `stop` / `start` is ONE DAG with a per-agent
|
||||
subgraph each (independent roots, run concurrently on their own leases), not
|
||||
N separate DAGs.
|
||||
|
|
@ -132,12 +132,12 @@ async `lifecycle::is_running` read), so they live in `job_queue/power.rs`,
|
|||
not the pure/sync `templates.rs`. Per-agent shape rule: `stop`/`start` carry
|
||||
a head `SetWanted` (intent) — `restart` does not; the tail `Reconcile`
|
||||
(convergence guarantee — cheap, noops when already converged) is ALWAYS
|
||||
present; only the *mechanical* nodes (`Signal`/`Drain`/`StopForUpdate`) are
|
||||
state-conditional — skipped for a *down* agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape
|
||||
present; only the _mechanical_ nodes (`Signal`/`Drain`/`StopForUpdate`) are
|
||||
state-conditional — skipped for a _down_ agent (nothing to quiesce/stop). Keeping `Reconcile` in every shape
|
||||
closes the TOCTOU window: if an agent flips state between the `is_running`
|
||||
read and node exec, the tail `Reconcile` still converges it in-DAG (with
|
||||
`StopForUpdate`-noop as the backstop) — no reliance on an external reconcile
|
||||
sweep. `start` folds the per-agent stale-rev upgrade in (a *down + stale*
|
||||
sweep. `start` folds the per-agent stale-rev upgrade in (a _down + stale_
|
||||
agent's subgraph is a rebuild-then-start).
|
||||
|
||||
```text
|
||||
|
|
@ -163,7 +163,7 @@ Notable collapses:
|
|||
leaves it stopped).
|
||||
- **The swap-failure recovery-start is structural**: `Reconcile` deps on
|
||||
`Swap` with the one `AfterAny` edge in the system — it runs after `Swap`
|
||||
terminal ok *or* fail, bringing a wanted-up agent back on its old config.
|
||||
terminal ok _or_ fail, bringing a wanted-up agent back on its old config.
|
||||
- **Deferred start is automatic**: `Reconcile` holds no build slot, so the
|
||||
next DAG's `Prebuild` starts as soon as `Swap` frees the slot.
|
||||
- **Graceful stop needs no watcher thread**: `Signal`/`Drain` are cheap, so a
|
||||
|
|
@ -171,7 +171,7 @@ Notable collapses:
|
|||
drains overlap; each DAG's tail `Reconcile` does the actual stop.
|
||||
- **The meta-update cascade grows in the same DAG on completion**:
|
||||
`MetaLock`'s executor computes the affected agent set after the bump lands
|
||||
and grows one `rebuild` subgraph per agent into its *own* DAG via
|
||||
and grows one `rebuild` subgraph per agent into its _own_ DAG via
|
||||
`append_subgraph` (rooted on the `MetaLock`, `relock = false` so the cascade
|
||||
doesn't revert the bump). Not child DAGs — one DAG, no `parent_id`. A failed
|
||||
bump appends nothing (no cancel-children dance). Same shape as the startup
|
||||
|
|
@ -180,9 +180,9 @@ Notable collapses:
|
|||
|
||||
### Desired-state (spec vs status)
|
||||
|
||||
Per-agent power *intent* — `wanted: Up | Offline` — is durable as the
|
||||
Per-agent power _intent_ — `wanted: Up | Offline` — is durable as the
|
||||
`agent_power` table in the coordinator DB (`hive-c0re/src/stores/power.rs`).
|
||||
`container_view` remains the observed *status*; `Reconcile` nodes converge the
|
||||
`container_view` remains the observed _status_; `Reconcile` nodes converge the
|
||||
two. Setting `wanted` is never a queued node: the power layer
|
||||
(`job_queue/power.rs`) writes the row synchronously, then inserts the DAG
|
||||
whose `Reconcile` reads the fresh value — rapid toggles are last-writer-wins.
|
||||
|
|
@ -237,15 +237,15 @@ than to one DAG shape it happens to appear in. A **brace** is the one sanctioned
|
|||
exception: a pure-resource-holder root that declares on behalf of a subtree
|
||||
coordinated with itself, whose members then declare nothing.
|
||||
|
||||
It is forced rather than stylistic. Declaring a resource means *"I need this
|
||||
exclusively"*, and the agent lease is single-unit — so **two siblings that both
|
||||
It is forced rather than stylistic. Declaring a resource means _"I need this
|
||||
exclusively"_, and the agent lease is single-unit — so **two siblings that both
|
||||
declared it could never run concurrently.** For a subtree whose whole point is
|
||||
concurrency (`Prebuild` beside the `Signal` → `Drain` quiesce window), declaring
|
||||
the requirement truthfully on every node and running those nodes in parallel are
|
||||
mutually exclusive. One holder above them speaks for the subtree.
|
||||
|
||||
This is the opposite of the failure the declare-your-own rule exists to prevent,
|
||||
not a relapse into it: there the requirement was *implicit*, inferred from a
|
||||
not a relapse into it: there the requirement was _implicit_, inferred from a
|
||||
node's kind and true only by accident of placement. Here it is explicit, on one
|
||||
node, with the omission below it documented on the brace itself.
|
||||
|
||||
|
|
@ -257,7 +257,7 @@ Two consequences worth knowing:
|
|||
leave a gap another DAG could claim the agent in, mid-bounce. A brace supplies
|
||||
that continuity directly, so the nesting is no longer load-bearing.
|
||||
- **Observability is unaffected.** `running_transients` keys off a node's
|
||||
*payload* agent, not off a declared lease edge, so every child still lights its
|
||||
_payload_ agent, not off a declared lease edge, so every child still lights its
|
||||
own dashboard pill and still reports its own `takes_container_down` to the
|
||||
crash watcher. A brace itself reports `false`: it parents the stopping nodes
|
||||
but does not stop anything, and claiming otherwise would widen crash
|
||||
|
|
@ -323,12 +323,12 @@ cancelled-while-queued, which fails the approval instead of dangling it).
|
|||
### Wire shape
|
||||
|
||||
`RebuildQueueChanged { seq }` (event name kept) — **a bare trigger, no
|
||||
payload.** It says *the queue changed*; a client that wants to know how
|
||||
payload.** It says _the queue changed_; a client that wants to know how
|
||||
re-fetches `GET /api/jobq/graph`.
|
||||
|
||||
That endpoint serves the graph generically (`hive-jobq-wire`): every node
|
||||
carries `id`, `parent`, `deps`, `state`, `label` (the node kind's own wire
|
||||
string — the kind *is* the phase label, there is no separate sub-step
|
||||
string — the kind _is_ the phase label, there is no separate sub-step
|
||||
string) and free-form `data` for what only some kinds have (`agent`,
|
||||
`approval_id`, `inputs`, `build_log_id`). Group roots ride as ordinary
|
||||
nodes, so a group's state is just the root's own `state`.
|
||||
|
|
@ -341,7 +341,7 @@ live-log panel off the running node.
|
|||
The event carries no payload by design: shipping a typed projection of
|
||||
the whole queue in the event itself would be a second rendering of the
|
||||
same graph that has to be kept in agreement by hand with the endpoint
|
||||
every consumer actually reads. Telling a client *when* to refetch is
|
||||
every consumer actually reads. Telling a client _when_ to refetch is
|
||||
the event's whole job.
|
||||
|
||||
---
|
||||
|
|
@ -362,7 +362,7 @@ On startup, `auto_update::run` classifies every agent by rev freshness (the
|
|||
per-agent `.{name}.hyperhive-rev` marker under `/var/lib/hyperhive/applied/`
|
||||
vs the current flake path) and persisted `wanted` intent, then:
|
||||
|
||||
1. **Config path** — when *any* marker is stale, submit one `Boot`
|
||||
1. **Config path** — when _any_ marker is stale, submit one `Boot`
|
||||
DAG: a `MetaLock` (hyperhive input bump, non-fatal) that grows an in-DAG
|
||||
`Rebuild` subgraph for each stale agent whose `wanted = Up` (topology-sorted,
|
||||
parents first). Stale but wanted-offline agents get no boot-time nix work — their
|
||||
|
|
@ -453,7 +453,7 @@ graft's completion then plants the deploy tag — so "did the agent come back
|
|||
up?" is answered by `Reconcile` succeeding, the same way it is for every
|
||||
other rebuild, instead of by a fused inline start.
|
||||
|
||||
The grafted nodes land *inside* `DeployWindow`'s subtree, so they re-enter
|
||||
The grafted nodes land _inside_ `DeployWindow`'s subtree, so they re-enter
|
||||
the meta window and build slot it already holds rather than deadlocking
|
||||
against it.
|
||||
|
||||
|
|
@ -514,7 +514,7 @@ The same drop-in carries `CPUWeight=` / `IOWeight=` from
|
|||
`agentCpuWeight` / `agentIoWeight`. Those are a different kind of
|
||||
setting: the quota and the memory max are **hard caps** that throttle
|
||||
an agent even on a completely idle host, while the weights are cgroup
|
||||
v2 **relative shares** that only decide who yields *under contention*.
|
||||
v2 **relative shares** that only decide who yields _under contention_.
|
||||
A low-weight container still gets the whole machine when nothing else
|
||||
wants it.
|
||||
|
||||
|
|
@ -529,7 +529,7 @@ Two things to know about the weights:
|
|||
|
||||
- They are **hive-wide** — unlike the caps there is no per-agent
|
||||
override in `meta/resource-limits.json`, so every agent carries the
|
||||
same value and the weight does *not* rank agents against each other.
|
||||
same value and the weight does _not_ rank agents against each other.
|
||||
What `80` buys is that agents yield to everything **not** on this
|
||||
drop-in path: host services (nginx and dnsmasq among them) and the
|
||||
infra containers (`hive-ci`, `hive-forge`, `hive-gateway`,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Every container operation — rebuild, first-spawn, a config-PR deploy,
|
||||
power changes — runs through one shared job queue. This page explains
|
||||
what the job queue *is*, as a general idea, independent of what any one
|
||||
what the job queue _is_, as a general idea, independent of what any one
|
||||
subsystem uses it for. For the hive-c0re-specific step catalogue and the
|
||||
engineering internals (scheduler, leases, resource windows) see
|
||||
[`coordinator.md`](coordinator.md) instead.
|
||||
|
|
@ -23,7 +23,7 @@ Two ideas are all there is to it:
|
|||
that's the whole reason things queue instead of all firing at once.
|
||||
|
||||
The engine's whole job is: whenever a step's ordering and resource needs
|
||||
are both satisfied, run it. It has no opinion on what the steps *do* —
|
||||
are both satisfied, run it. It has no opinion on what the steps _do_ —
|
||||
that's supplied by whoever builds the graph. hive-c0re is the one thing
|
||||
building graphs on it today, but nothing about the engine is specific to
|
||||
containers or rebuilds; there's nothing stopping another subsystem from
|
||||
|
|
@ -38,14 +38,14 @@ nested under it are that job's steps, in order (occasionally a couple run
|
|||
side by side). A step shows one of:
|
||||
|
||||
| Glyph | Meaning |
|
||||
| ----- | -------------------------------------------------------- |
|
||||
| `⏸` | queued, waiting its turn |
|
||||
| `▶` | running |
|
||||
| `◐` | its own work is done, waiting on a step nested under it |
|
||||
| `✔` | finished successfully |
|
||||
| `✖` | failed |
|
||||
| `⊘` | cancelled |
|
||||
| `·` | skipped (not needed for this run) |
|
||||
| ----- | ------------------------------------------------------- |
|
||||
| `⏸` | queued, waiting its turn |
|
||||
| `▶` | running |
|
||||
| `◐` | its own work is done, waiting on a step nested under it |
|
||||
| `✔` | finished successfully |
|
||||
| `✖` | failed |
|
||||
| `⊘` | cancelled |
|
||||
| `·` | skipped (not needed for this run) |
|
||||
|
||||
A step that isn't needed for a given run shows as `·` rather than being
|
||||
left out of the tree entirely, so the same kind of operation keeps a
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ no longer survives independently of anything host-side.
|
|||
**It has no application-level auth.** The receiver takes any OTLP that reaches
|
||||
it; what bounds who can reach it is the firewall — `exposeHostPorts` opens the
|
||||
port on the bridge interface only. So "unauthenticated to a bridge address"
|
||||
means *reachable from an agent container*, not *presents a credential*.
|
||||
means _reachable from an agent container_, not _presents a credential_.
|
||||
|
||||
The consequence, stated because it is a choice rather than an oversight: **any
|
||||
agent can push arbitrary OTLP, and it is forwarded on under the operator's
|
||||
|
|
@ -56,10 +56,10 @@ smuggled out in resource attributes on an otherwise-legitimate export.
|
|||
|
||||
That is a **different risk from the one the collector fixes**, and strictly
|
||||
smaller than what preceded it: before, every agent held the upstream credential
|
||||
itself, so it could do all of the above *and* use the token anywhere else. The
|
||||
itself, so it could do all of the above _and_ use the token anywhere else. The
|
||||
collector removes the token and keeps the pipe. Agents are inside the trust
|
||||
boundary (`docs/trust-boundary/security.md`: capability = accepted risk), so an agent being
|
||||
able to *send* is an accepted extension of that boundary — but it is not
|
||||
able to _send_ is an accepted extension of that boundary — but it is not
|
||||
closed by this design, and nothing here should be read as closing it.
|
||||
|
||||
**The `agent` label is self-reported, and no planned authentication changes
|
||||
|
|
@ -73,13 +73,13 @@ is getting a credential:
|
|||
- **agent→collector** (this section's hop) stays open on the bridge. Nothing
|
||||
downstream can tell one agent's export from another's.
|
||||
- **hive→swarm** is where the planned ingest auth goes. The swarm tier stamps
|
||||
`hive=` from the connection it authenticated, so *that* label becomes
|
||||
`hive=` from the connection it authenticated, so _that_ label becomes
|
||||
unforgeable.
|
||||
|
||||
So a verified `hive` is reachable and a verified `agent` is not — and that falls
|
||||
out of the topology rather than being a gap someone forgot to close. The swarm
|
||||
runs one collector, and the mechanism gives it no finer grain: a bearer-token
|
||||
check never reveals *which* token matched, and a receiver reads request metadata
|
||||
check never reveals _which_ token matched, and a receiver reads request metadata
|
||||
rather than the claims it authenticated with.
|
||||
|
||||
If you need per-agent numbers you can act on, take them from the agent's own
|
||||
|
|
@ -100,10 +100,10 @@ two-tier architecture, the security model, and how the options interact.
|
|||
Telemetry crosses two collectors, and which one you configure depends on what
|
||||
the host is:
|
||||
|
||||
| | runs where | receives from | does |
|
||||
|---|---|---|---|
|
||||
| **hive tier** — `otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination |
|
||||
| **swarm tier** — `deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream |
|
||||
| | runs where | receives from | does |
|
||||
| ------------------------------------ | ---------------------- | --------------------------------- | --------------------------------------------------------------------- |
|
||||
| **hive tier** — `otel.enable` | every hive with agents | that hive's agents, on the bridge | forwards to the swarm tier. Holds no credential, picks no destination |
|
||||
| **swarm tier** — `deploy.swarm-otel` | once per swarm | every hive's collector | writes the swarm's store and exports upstream |
|
||||
|
||||
An all-local host runs both, and needs nothing said about the hop between them.
|
||||
|
||||
|
|
@ -119,16 +119,16 @@ services.hyperhive.otel = {
|
|||
every agent needs the credential to authenticate — and the harness delivers
|
||||
that token into the agent's own `~/.claude/settings.json`, a file the agent can
|
||||
read. `0600` protects it from other containers, not from the agent itself. As
|
||||
long as the direct path stays *selectable*, that hole stays selectable; an
|
||||
long as the direct path stays _selectable_, that hole stays selectable; an
|
||||
option that can reintroduce it is a hole with extra steps.
|
||||
|
||||
**Why the tiers stay separate on one box.** They are not collapsed when
|
||||
co-located: an all-local hive is a statement about *where* processes run, not
|
||||
co-located: an all-local hive is a statement about _where_ processes run, not
|
||||
about the shape of the deployment. A boundary that disappears locally is one
|
||||
the local deployment stops testing.
|
||||
|
||||
**`endpoint` keeps meaning "where telemetry goes upstream."** Neither tier
|
||||
redefines it — the agent-facing value is *derived*
|
||||
redefines it — the agent-facing value is _derived_
|
||||
(`http://<bridgeIp>:<collector.port>`), so an existing deployment's `endpoint`
|
||||
keeps working unchanged. The bridge port is contributed to `exposeHostPorts`
|
||||
automatically; there is nothing to open by hand.
|
||||
|
|
@ -160,9 +160,9 @@ swarm tier and no metrics appearing for that hive.
|
|||
|
||||
⚠️ **`endpoint` must be valid for `protocol`.** The upstream exporter follows
|
||||
`otel.protocol` (`grpc` → the gRPC exporter, otherwise OTLP/HTTP), and the gRPC
|
||||
exporter takes an *address*: `https://host/path` is a legal
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` for HTTP but fails as gRPC with *"missing port in
|
||||
address"*. The collector's config is validated at build time, so a mismatch is
|
||||
exporter takes an _address_: `https://host/path` is a legal
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` for HTTP but fails as gRPC with _"missing port in
|
||||
address"_. The collector's config is validated at build time, so a mismatch is
|
||||
a build error naming the reason rather than telemetry silently going nowhere.
|
||||
|
||||
## Network access
|
||||
|
|
@ -176,7 +176,7 @@ services.hyperhive.network.exposeHostPorts = [ 5432 ];
|
|||
```
|
||||
|
||||
and point whatever consumes it at `10.42.0.1:5432` rather than loopback: inside
|
||||
a container, loopback is the *container*. The bridge IP is the host's address on
|
||||
a container, loopback is the _container_. The bridge IP is the host's address on
|
||||
the `hive-br0` bridge. The service must also bind an address the bridge can
|
||||
reach — a `127.0.0.1`-only listener stays unreachable no matter what the
|
||||
firewall allows. See `docs/networking/network.md::Reaching host services` for details.
|
||||
|
|
@ -196,12 +196,12 @@ yourself from a tool call or `hivectl shell`.
|
|||
Every agent's export therefore includes these resource attributes
|
||||
automatically:
|
||||
|
||||
| Attribute | Value |
|
||||
|-----------|-------|
|
||||
| `service.name` | `hyperhive-agent` (constant) |
|
||||
| `agent` | agent logical name (e.g. `iris`) |
|
||||
| `hive` | hive display name (`services.hyperhive.hiveName`) |
|
||||
| `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) |
|
||||
| Attribute | Value |
|
||||
| -------------- | ------------------------------------------------------------ |
|
||||
| `service.name` | `hyperhive-agent` (constant) |
|
||||
| `agent` | agent logical name (e.g. `iris`) |
|
||||
| `hive` | hive display name (`services.hyperhive.hiveName`) |
|
||||
| `swarm` | swarm display name (`services.hyperhive.swarm.name`, if set) |
|
||||
|
||||
Additional labels can be appended via `extraResourceAttributes` (see option
|
||||
reference above); custom per-data-point labels can be passed with
|
||||
|
|
@ -222,14 +222,14 @@ exists, so off-the-shelf OTEL/Grafana container dashboards work. Resource
|
|||
`service.name = hyperhive-c0re`; each data point is tagged `container.name`
|
||||
(= the `h-<agent>` machine) and the hive `agent` label:
|
||||
|
||||
| Metric | Unit | Kind | Source |
|
||||
|--------|------|------|--------|
|
||||
| `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds |
|
||||
| `container.memory.usage` | `By` | gauge | `memory.current` |
|
||||
| `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) |
|
||||
| `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) |
|
||||
| `hyperhive.container.storage.usage` | `By` | gauge | state dir + writable rootfs (custom — semconv only has `disk.io`; omitted until the slow disk sampler runs) |
|
||||
| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — the value the dashboard LOAD tab shows, no `rate()` needed) |
|
||||
| Metric | Unit | Kind | Source |
|
||||
| ----------------------------------- | ---- | ------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `container.cpu.time` | `s` | counter | cumulative `cpu.stat` `usage_usec` → seconds |
|
||||
| `container.memory.usage` | `By` | gauge | `memory.current` |
|
||||
| `hyperhive.container.memory.limit` | `By` | gauge | `memory.max` (custom — semconv has no `.limit` metric; omitted when unlimited) |
|
||||
| `hyperhive.container.memory.peak` | `By` | gauge | `memory.peak` (custom — no semconv metric; omitted if unavailable) |
|
||||
| `hyperhive.container.storage.usage` | `By` | gauge | state dir + writable rootfs (custom — semconv only has `disk.io`; omitted until the slow disk sampler runs) |
|
||||
| `hyperhive.container.cpu.percent` | `%` | gauge | host-normalised percent (custom — the value the dashboard LOAD tab shows, no `rate()` needed) |
|
||||
|
||||
The `hyperhive.`-prefixed metrics have no semconv equivalent (memory
|
||||
limit + peak, on-disk footprint, and an instantaneous cpu percent kept
|
||||
|
|
@ -252,14 +252,14 @@ turn, its own outcome classification, the loose-ends backlog, and session
|
|||
boundaries. Token usage, cost, and tool-call counts are **not** duplicated
|
||||
here; that's already covered by Claude's own export.
|
||||
|
||||
| Metric | Unit | Kind | Attributes |
|
||||
|--------|------|------|------------|
|
||||
| `hyperhive.agent.turn.duration` | `ms` | histogram | `wake_from`, `result_kind`, `model` |
|
||||
| `hyperhive.agent.turn.count` | — | counter | `wake_from`, `result_kind`, `model` |
|
||||
| `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) |
|
||||
| `hyperhive.agent.loose_ends.threads` | — | gauge | none |
|
||||
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
|
||||
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
|
||||
| Metric | Unit | Kind | Attributes |
|
||||
| -------------------------------------- | ---- | --------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `hyperhive.agent.turn.duration` | `ms` | histogram | `wake_from`, `result_kind`, `model` |
|
||||
| `hyperhive.agent.turn.count` | — | counter | `wake_from`, `result_kind`, `model` |
|
||||
| `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) |
|
||||
| `hyperhive.agent.loose_ends.threads` | — | gauge | none |
|
||||
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
|
||||
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
|
||||
|
||||
Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
|
||||
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
|
||||
|
|
@ -277,11 +277,11 @@ that hosts no agents still reports, and "this hive is quiet" is
|
|||
distinguishable from "this hive is gone". Select them with
|
||||
`{hive!="",agent=""}`.
|
||||
|
||||
| Metric | Unit | Kind | Meaning |
|
||||
|--------|------|------|---------|
|
||||
| `process.uptime` | `s` | gauge | seconds since this hive's `hive-c0re` started exporting; a restart reads as a drop to ~0 |
|
||||
| `hyperhive.hive.degraded` | `1` | gauge | `1` while the hive reports itself unhealthy — the same verdict `/health/ready` gives and the swarm status view shows |
|
||||
| `hyperhive.hive.warnings` | `1` | gauge | how many warnings are currently raised, split by a `level` attribute (`warn`, `crit`) |
|
||||
| Metric | Unit | Kind | Meaning |
|
||||
| ------------------------- | ---- | ----- | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| `process.uptime` | `s` | gauge | seconds since this hive's `hive-c0re` started exporting; a restart reads as a drop to ~0 |
|
||||
| `hyperhive.hive.degraded` | `1` | gauge | `1` while the hive reports itself unhealthy — the same verdict `/health/ready` gives and the swarm status view shows |
|
||||
| `hyperhive.hive.warnings` | `1` | gauge | how many warnings are currently raised, split by a `level` attribute (`warn`, `crit`) |
|
||||
|
||||
Both levels are reported every cycle, `0` included, so a healthy hive is
|
||||
visible as zeros rather than as missing series.
|
||||
|
|
@ -303,10 +303,10 @@ Forgejo's own native `/metrics` endpoint has no equivalent: it exposes
|
|||
counts of durable rows (issues, comments, repos), and neither a commit nor a
|
||||
push is stored anywhere as a row to count.
|
||||
|
||||
| Metric | Unit | Kind | Attributes |
|
||||
|--------|------|------|------------|
|
||||
| `hyperhive.vcs.commit.count` | — | counter | `repo` (`org/repo`) |
|
||||
| `hyperhive.vcs.push.count` | — | counter | `repo` (`org/repo`) |
|
||||
| Metric | Unit | Kind | Attributes |
|
||||
| ---------------------------- | ---- | ------- | ------------------- |
|
||||
| `hyperhive.vcs.commit.count` | — | counter | `repo` (`org/repo`) |
|
||||
| `hyperhive.vcs.push.count` | — | counter | `repo` (`org/repo`) |
|
||||
|
||||
A push with zero commits (a branch delete, or a force-push that doesn't add
|
||||
new commits) still increments `push.count`; `commit.count` only advances
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ the additional config needed when the swarm spans multiple hosts.
|
|||
|
||||
For the full option reference rather than prose: `services.hyperhive.swarm.*`
|
||||
(swarm-wide facts, identical on every host) and `services.hyperhive.deploy.*`
|
||||
(this host's own deployment decisions — does *this* machine run grafana,
|
||||
(this host's own deployment decisions — does _this_ machine run grafana,
|
||||
the swarm controller, authelia, …) are separate generated pages, `nix
|
||||
build .#docs-swarm` / `.#docs-deploy` or the website's `/options/swarm.html`
|
||||
/ `/options/deploy.html`.
|
||||
|
|
@ -20,7 +20,7 @@ build .#docs-swarm` / `.#docs-deploy` or the website's `/options/swarm.html`
|
|||
- **swarm** — one or more hives whose operators have declared them
|
||||
as peers. Agents can be qualified as `agent@hive-domain`.
|
||||
- **peer hive** — any hive in `services.hyperhive.swarm.hives` other
|
||||
than this one. Peers are *derived*, not declared: the directory lists
|
||||
than this one. Peers are _derived_, not declared: the directory lists
|
||||
every hive including yourself, and `hiveName` says which one you are.
|
||||
|
||||
## Hive identity config
|
||||
|
|
@ -47,7 +47,7 @@ and deploys — an eval failure asking the operator to write the address
|
|||
down is the cheaper outcome. **Upgrading past this release means setting
|
||||
both once.**
|
||||
|
||||
`domain` is required too, but you no longer *write* it: it is read from
|
||||
`domain` is required too, but you no longer _write_ it: it is read from
|
||||
this hive's own entry in the directory, whose `domain` defaults to
|
||||
`<name>.<swarm.domain>`. So a conventional swarm states no addresses at
|
||||
all, and a hive addressed by something else states it in the one place
|
||||
|
|
@ -70,7 +70,7 @@ form qualified labels (`iris@pr1ma.example.com`).
|
|||
`swarm.name` is purely display — it surfaces in the dashboard chrome
|
||||
header and per-agent system prompts, and federated hives at different
|
||||
domains can share one. `hiveName` surfaces in the same places but is
|
||||
*not* only display: it is the leftmost label of the hive's domain. That
|
||||
_not_ only display: it is the leftmost label of the hive's domain. That
|
||||
`swarm.name` sits under `swarm` and `hiveName` does not is the whole
|
||||
distinction — one names this hive, the other names the group it belongs
|
||||
to.
|
||||
|
|
@ -120,14 +120,14 @@ services.hyperhive.swarm.hives = {
|
|||
```
|
||||
|
||||
One attrset describing **every** hive in the swarm, **including this
|
||||
one**, keyed by that hive's `hiveName`. It is meant to be *identical on
|
||||
every host* — write it once, share it, and each host reads it correctly
|
||||
one**, keyed by that hive's `hiveName`. It is meant to be _identical on
|
||||
every host_ — write it once, share it, and each host reads it correctly
|
||||
because `services.hyperhive.hiveName` says which entry is itself.
|
||||
|
||||
Empty (the default) means this host isn't in a swarm. Once non-empty it
|
||||
**must** contain an entry for `hiveName`; eval fails naming the missing
|
||||
hive. That assertion is load-bearing rather than pedantic — "my peers"
|
||||
is derived as *everything that isn't me*, so a directory that doesn't
|
||||
is derived as _everything that isn't me_, so a directory that doesn't
|
||||
contain you derives every hive as a peer and you peer with yourself.
|
||||
|
||||
`domain` is required per entry and deliberately undefaulted: it is
|
||||
|
|
@ -138,7 +138,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
|
|||
> inside a swarm comes from the swarm root ([`ca.md`](ca.md)): every
|
||||
> hive chains to it, so one anchor replaces per-hive pinning entirely.
|
||||
> What that genuinely drops is trusting a hive whose root this swarm
|
||||
> does *not* own — another swarm's, or one keeping its own CA. That is
|
||||
> does _not_ own — another swarm's, or one keeping its own CA. That is
|
||||
> a cross-swarm problem and wants a mechanism designed for it. (An
|
||||
> earlier `certFingerprint` field existed for exactly that gap, pinning
|
||||
> a peer's TLS leaf for hive-c0re's own peer HTTPS checks — removed
|
||||
|
|
@ -162,7 +162,7 @@ evaluates cleanly points at a real machine that isn't the one you meant.
|
|||
⚠️ **That container currently trusts no swarm-internal CA**, so a
|
||||
self-signed gateway certificate does not federate. The swarm root
|
||||
can't simply be listed there: `security.pki.certificateFiles` is
|
||||
read when the system is *built*, and the root is a runtime file (its
|
||||
read when the system is _built_, and the root is a runtime file (its
|
||||
key must never enter the store), so there is no build-time name for
|
||||
it. Bridging that needs a runtime mechanism and is tracked as its own
|
||||
issue. Until then, federation needs CA-issued certs (ACME). See
|
||||
|
|
@ -184,7 +184,7 @@ swarm.hives = { … }; swarm.hives = { … }; # byte-identical
|
|||
```
|
||||
|
||||
That is the point of the shape, and it removes a class of bug rather
|
||||
than saving typing: a per-host peer list let two hosts hold *different*
|
||||
than saving typing: a per-host peer list let two hosts hold _different_
|
||||
facts about the same third hive — a stale endpoint, a rotated
|
||||
fingerprint — with nothing to detect the disagreement. One entry per
|
||||
hive makes it unrepresentable.
|
||||
|
|
@ -277,7 +277,7 @@ port}` tells this hive where the swarm's `btrfs receive` endpoint is, so
|
|||
`hivectl agent <name> subvol snapshot push` has somewhere to stream to.
|
||||
|
||||
It is genuinely swarm-scoped rather than per-peer — a swarm has exactly
|
||||
one store, because the receiver keys destinations by *agent* so a
|
||||
one store, because the receiver keys destinations by _agent_ so a
|
||||
migrating agent keeps one unbroken incremental chain. See
|
||||
[snapshot-store.md](../networking/snapshot-store.md).
|
||||
|
||||
|
|
@ -305,12 +305,12 @@ A hive publishes only once it has been given the three
|
|||
`swarm.statusPublish` coordinates below. A hive that has not reads
|
||||
`never_reported` — it is not broken, it just has nothing to say upward.
|
||||
|
||||
| freshness | what to do about it |
|
||||
|---|---|
|
||||
| `fresh` | nothing — reported within `staleAfterSeconds` |
|
||||
| `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say |
|
||||
| `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage |
|
||||
| `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running |
|
||||
| freshness | what to do about it |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fresh` | nothing — reported within `staleAfterSeconds` |
|
||||
| `stale` | the hive stopped reporting. Its last payload is still shown, so check `age_seconds` and the payload for what it managed to say |
|
||||
| `never_reported` | this hive has never reported at all — normally a deployment that hasn't happened, not an outage |
|
||||
| `unknown` | something is publishing under a name that is not in `swarm.hives` — a typo in the roster, or a hive that was removed and is still running |
|
||||
|
||||
Every row also carries `last_seen_unix` and `age_seconds` if you want to
|
||||
apply your own threshold. The timestamp is the one the queue recorded on
|
||||
|
|
@ -328,11 +328,11 @@ Three options, on the **hive**, set together or not at all — a
|
|||
half-configured hive is an eval error rather than one that quietly never
|
||||
reports:
|
||||
|
||||
| option | what to set it to |
|
||||
|---|---|
|
||||
| `natsUrl` | where the swarm queue listens, as this hive reaches it |
|
||||
| `tokenEndpoint` | the swarm IdP's `/api/oidc/token` |
|
||||
| `clientSecretFile` | path to this hive's client secret, plaintext |
|
||||
| option | what to set it to |
|
||||
| ------------------ | ------------------------------------------------------ |
|
||||
| `natsUrl` | where the swarm queue listens, as this hive reaches it |
|
||||
| `tokenEndpoint` | the swarm IdP's `/api/oidc/token` |
|
||||
| `clientSecretFile` | path to this hive's client secret, plaintext |
|
||||
|
||||
On a host that runs the queue and the IdP itself, all three default to
|
||||
the local ones and there is nothing to set. Any other hive needs them
|
||||
|
|
@ -364,8 +364,8 @@ on the `agent-configs` org, both under
|
|||
`https://<swarm.domain>/webhook/forge/`.
|
||||
|
||||
The controller **interprets** a delivery and sends hives a specific
|
||||
message — *the knowledge repo changed*, *deploy agent `foo` at rev
|
||||
`abc123`* — rather than forwarding forge payloads for each hive to
|
||||
message — _the knowledge repo changed_, _deploy agent `foo` at rev
|
||||
`abc123`_ — rather than forwarding forge payloads for each hive to
|
||||
re-derive. Approval happens once, at the swarm level: a hive receives a
|
||||
decision, not an event to adjudicate.
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ O(n²) hand-pinning into one anchor per swarm.
|
|||
|
||||
What differs is who puts the artifacts on disk:
|
||||
|
||||
| | swarm root | this hive's CA |
|
||||
| --- | --- | --- |
|
||||
| default | operator-provided | operator-provided, else self-signed as before |
|
||||
| | swarm root | this hive's CA |
|
||||
| ---------------------------------------- | --------------------------------------------- | ---------------------------------------------- |
|
||||
| default | operator-provided | operator-provided, else self-signed as before |
|
||||
| `autoConfigure = true` (all on one host) | generated by `swarm-ca.service` on first boot | issued by `hive-tls-ca.service` under the root |
|
||||
|
||||
`services.hyperhive.swarm.ca.autoConfigure` selects between them, and is
|
||||
|
|
@ -49,13 +49,13 @@ swarm-services leaf can, and this host signs that one only under
|
|||
and every client sees a name mismatch.
|
||||
|
||||
So `hive-tls` emits an eval-time **warning** naming the uncovered names.
|
||||
It is deliberately not an assertion: this module can see what *it* is
|
||||
It is deliberately not an assertion: this module can see what _it_ is
|
||||
able to issue, but not an operator-installed sub-CA in
|
||||
`swarm.ca.stateDir`, an external ACME setup, or a certificate delivered
|
||||
by any other means. If you have arranged one, the warning is expected
|
||||
and can be ignored. Otherwise either install the sub-CA, or pin the
|
||||
names back under the hive domain — a supported migration, since the
|
||||
sub-CA is constrained to the *configured* names and the swarm root
|
||||
sub-CA is constrained to the _configured_ names and the swarm root
|
||||
carries no name constraints at all.
|
||||
|
||||
## Constraints on the material
|
||||
|
|
@ -96,7 +96,7 @@ CA has nothing to install — `peer-config` says so instead.
|
|||
|
||||
Handing a peer this hive's own `ca.pem` would not work even as a
|
||||
one-off: once a hive CA is an intermediate under the swarm root, it is
|
||||
no longer something a verifier can build a chain *to* — OpenSSL will not
|
||||
no longer something a verifier can build a chain _to_ — OpenSSL will not
|
||||
terminate a chain at a trusted non-self-signed certificate without
|
||||
`-partial_chain`. That is why the tool distributes the root, not a
|
||||
per-hive file.
|
||||
|
|
@ -127,7 +127,7 @@ that has not restarted yet still validates. Removing `ca-previous.pem`
|
|||
is a deliberate later step: how long is long enough is a property of the
|
||||
deployment, not something the unit can know.
|
||||
|
||||
A marker file (`.swarm-ca-adopted`) records that this ran. Its *absence*
|
||||
A marker file (`.swarm-ca-adopted`) records that this ran. Its _absence_
|
||||
is the trigger, so adoption fires once per hive rather than being
|
||||
re-decided on every activation.
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ this: it self-signs exactly as it always has.
|
|||
The root **key** is a runtime file for the reason in
|
||||
[Constraints on the material](#constraints-on-the-material).
|
||||
|
||||
The root **certificate** is a runtime file as a *consequence* — it lives
|
||||
The root **certificate** is a runtime file as a _consequence_ — it lives
|
||||
beside the key under `swarm.ca.stateDir` — and that has a cost worth
|
||||
naming, because it is not obvious and it bites at a distance:
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ Two consumers, and only one of them is fine:
|
|||
an OpenSSL-linked binary.
|
||||
|
||||
> ⚠️ **Concatenate; never point `SSL_CERT_FILE` at the anchor alone.**
|
||||
> `openssl-probe` uses it *instead of* the default store, so naming
|
||||
> `openssl-probe` uses it _instead of_ the default store, so naming
|
||||
> just the bundle would drop every public CA and break federation with
|
||||
> the wider matrix network — a much bigger outage than the one being
|
||||
> fixed. The same caveat applies to `hive-forge` (Go) for the same
|
||||
|
|
|
|||
|
|
@ -29,34 +29,34 @@ container that starts after it.
|
|||
|
||||
Every row below is read against one of these. This is a different cut
|
||||
from the [deployment shapes](services.md#deployment-shapes) --- those
|
||||
say *where services run*, these say *who is responsible for a secret
|
||||
file being there* --- so the two lists do not line up one-to-one, and
|
||||
say _where services run_, these say _who is responsible for a secret
|
||||
file being there_ --- so the two lists do not line up one-to-one, and
|
||||
neither is a renaming of the other.
|
||||
|
||||
| topology | what it means | who places secrets |
|
||||
|---|---|---|
|
||||
| **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it is read, or copied by a host unit |
|
||||
| **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place |
|
||||
| **hive elsewhere** | a hive that federates with a swarm it does not host | the operator provides the file and names it in config |
|
||||
| topology | what it means | who places secrets |
|
||||
| ------------------ | ---------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| **all-local** | one host runs the swarm's shared services and its own hive | nobody — each secret is generated where it is read, or copied by a host unit |
|
||||
| **swarm-managed** | the swarm's services run on a host with `swarmctl` | `swarmctl` writes what it owns; the rest is still generated in place |
|
||||
| **hive elsewhere** | a hive that federates with a swarm it does not host | the operator provides the file and names it in config |
|
||||
|
||||
## Swarm-level — one of each per swarm
|
||||
|
||||
| secret | generated by | lives at | hive elsewhere |
|
||||
|---|---|---|---|
|
||||
| swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it is public |
|
||||
| swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below |
|
||||
| swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives |
|
||||
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
|
||||
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
|
||||
| authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service, `otel.clientSecretFile` for the hive's telemetry collector |
|
||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||
| the swarm collector's copy of its OIDC secret | `swarm-otel-oidc-secret.service` copies it from authelia's tree, **when authelia runs on this host** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | operator provides the file and names it in `swarm.otel.clientSecretFile` — the collector need not share a host with authelia |
|
||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||
| wireguard private key | **the operator** — `wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` |
|
||||
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a *client* of it and holds none of this |
|
||||
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything |
|
||||
| secret | generated by | lives at | hive elsewhere |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| swarm root CA cert | `swarm-ca.nix` first-boot unit, when `autoConfigure` is set | `/var/lib/swarm-ca/root.pem` | operator copies the **cert** in; it is public |
|
||||
| swarm root CA key | same unit | `/var/lib/swarm-ca/root-key.pem`, `0600` | stays on whichever host holds it — see the constraint below |
|
||||
| swarm-services sub-CA (cert + key) | `swarm-ca.nix`, signed by the root | `/var/lib/swarm-ca/services-ca{,-key}.pem` | issued where the root lives |
|
||||
| authelia session, JWT and storage-encryption keys | authelia's first-boot unit, in-container | `/var/lib/authelia-swarm/{session,jwt,storage-encryption}.key` | generated in place; nothing outside that container reads them |
|
||||
| authelia OIDC HMAC key | same unit | `/var/lib/authelia-swarm/oidc-hmac.key` | same |
|
||||
| authelia OIDC issuer key (RSA) | same unit | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service, `otel.clientSecretFile` for the hive's telemetry collector |
|
||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||
| the swarm collector's copy of its OIDC secret | `swarm-otel-oidc-secret.service` copies it from authelia's tree, **when authelia runs on this host** | `/var/lib/swarm-otel-oidc/<id>.secret` inside the `swarm-otel` container | operator provides the file and names it in `swarm.otel.clientSecretFile` — the collector need not share a host with authelia |
|
||||
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
|
||||
| wireguard private key | **the operator** — `wg genkey` | whatever `swarm.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
|
||||
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when `nats.autoGenerateCallout` is set | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `nats.calloutUserSeedFile` / `nats.calloutIssuerSeedFile` |
|
||||
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
|
||||
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it is stated rather than inferred | n/a — only the host running the store seals anything |
|
||||
|
||||
The three keys authelia mints for itself are generated in-container precisely
|
||||
because nothing outside that container ever reads them. **That is the test worth
|
||||
|
|
@ -81,7 +81,7 @@ The copy is only made when authelia is enabled **on this host** and something
|
|||
published is being scraped; otherwise no secret is needed and none is placed.
|
||||
|
||||
⚠️ **Do not read that delivery unit as the only way this collector is fed.**
|
||||
Whether it authenticates follows the *credential*, never another service's
|
||||
Whether it authenticates follows the _credential_, never another service's
|
||||
placement: a swarm collector may run on a host that holds neither store and no
|
||||
authelia, and then the secret is an ordinary operator-provided file named in
|
||||
`services.hyperhive.swarm.otel.clientSecretFile` — the same shape as the hive
|
||||
|
|
@ -126,13 +126,13 @@ when the system builds. The server names the offending file and refuses to run.
|
|||
|
||||
## Hive-level — one of each per hive
|
||||
|
||||
| secret | generated by | lives at |
|
||||
|---|---|---|
|
||||
| hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) |
|
||||
| hive leaf certs | `hive-tls.nix`, signed by the hive CA | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
|
||||
| matrix registration token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-register-token` (`0600`) |
|
||||
| the forge's copy of its OIDC secret | `hive-forge-oidc-secret.service` copies it from authelia's tree | `/var/lib/forgejo-oidc/<id>.secret` inside the forge container |
|
||||
| the homeserver's copy of its OIDC secret | `hive-matrix-oidc-secret.service`, same shape | `/var/lib/tuwunel-oidc/<id>.secret`, handed to tuwunel through `LoadCredential` |
|
||||
| secret | generated by | lives at |
|
||||
| ---------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| hive CA cert + key | `hive-tls.nix` first-boot unit | `<deploy.hive-controller.tls.stateDir>/ca.pem`, `ca-key.pem` (`0600`) |
|
||||
| hive leaf certs | `hive-tls.nix`, signed by the hive CA | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
|
||||
| matrix registration token | a host activation script, on first boot | `/var/lib/hyperhive/matrix-register-token` (`0600`) |
|
||||
| the forge's copy of its OIDC secret | `hive-forge-oidc-secret.service` copies it from authelia's tree | `/var/lib/forgejo-oidc/<id>.secret` inside the forge container |
|
||||
| the homeserver's copy of its OIDC secret | `hive-matrix-oidc-secret.service`, same shape | `/var/lib/tuwunel-oidc/<id>.secret`, handed to tuwunel through `LoadCredential` |
|
||||
|
||||
Both delivery units wait for authelia's first boot to mint the secret — a
|
||||
bounded wait, 120s — and then **fail loudly** rather than skipping. A silent skip
|
||||
|
|
@ -141,7 +141,7 @@ layers from its cause.
|
|||
|
||||
The store's **first reader** is the matrix registration token, and it is worth
|
||||
saying why that one: it is an opaque 32-byte value with no second file and no
|
||||
format. Authelia's OIDC secret needs a `.secret` *and* a matching `.digest`, so
|
||||
format. Authelia's OIDC secret needs a `.secret` _and_ a matching `.digest`, so
|
||||
starting there would have meant debugging "can a reader authenticate and get
|
||||
bytes back" and "did we write authelia's file format right" at once, with an
|
||||
SSO outage as the failure mode.
|
||||
|
|
@ -182,7 +182,7 @@ possible at all. Two things follow:
|
|||
containers through a bind-mounted bundle assembled at boot instead.
|
||||
- **On any topology other than all-local, placing that key is an operations
|
||||
decision**, not something this module tree makes for you. A hive that hosts no
|
||||
swarm services needs only the root's *cert*, to trust what others issue.
|
||||
swarm services needs only the root's _cert_, to trust what others issue.
|
||||
|
||||
## Adding a secret
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ services.hyperhive.deploy.allSwarmServices = true;
|
|||
```
|
||||
|
||||
**`deploy.allSwarmServices` is what "the swarm's shared services run
|
||||
here" means: every once-per-swarm service that is *optional* takes its
|
||||
here" means: every once-per-swarm service that is _optional_ takes its
|
||||
`enable` from it.** That is the whole rule, stated once — the per-service
|
||||
sections below do not repeat it, so a service that stops deriving is a
|
||||
visible difference rather than one more paragraph saying the same thing.
|
||||
|
|
@ -23,7 +23,7 @@ which wins, so "all local except X" needs no further option.
|
|||
**Both default to off**, and that is deliberate: a host cannot tell
|
||||
whether it is meant to be the swarm's service host, so this is an
|
||||
operator saying so rather than something inferred. With them off, a hive
|
||||
is a *client* of those services — it configures how to reach them and
|
||||
is a _client_ of those services — it configures how to reach them and
|
||||
runs none of them.
|
||||
|
||||
The forge is the exception, and not because it is per-hive: it is
|
||||
|
|
@ -112,11 +112,11 @@ services.hyperhive.deploy.grafana.enable = false;
|
|||
⚠️ **This starts a database that grows for as long as the swarm runs.**
|
||||
See `retentionPeriod` below before leaving it at its default.
|
||||
|
||||
| Option | When you'd touch it |
|
||||
|---|---|
|
||||
| Option | When you'd touch it |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `deploy.victoriametrics.retentionPeriod` | Default `5y`. Lower it once you have measured how fast this swarm actually fills a disk — the default is deliberately generous because too-short silently discards history you cannot get back. |
|
||||
| `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. |
|
||||
| `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. |
|
||||
| `swarm.grafana.oidc.role` | Default `Admin` for everyone who logs in. Lower to `Viewer`/`Editor` if the swarm grows operators who should not be able to reconfigure Grafana. |
|
||||
| `swarm.grafana.datasourceUrl` | Only if you front VictoriaMetrics with something else. It defaults to the store on this host, which is the only thing it can reach. |
|
||||
|
||||
**Logging in.** Grafana is behind swarm SSO, so the accounts are the
|
||||
authelia ones — there is no separate Grafana password, and the local
|
||||
|
|
@ -138,16 +138,16 @@ the hive's daemons and infra containers, and the harness units inside every
|
|||
agent container. The collector below is what writes to it.
|
||||
|
||||
**Reading them.** Open Grafana, pick **Explore**, and choose the
|
||||
`VictoriaLogs` datasource — it is provisioned for you. Grafana's *Logs
|
||||
Drilldown* app is deliberately not installed: it only supports Loki, and
|
||||
`VictoriaLogs` datasource — it is provisioned for you. Grafana's _Logs
|
||||
Drilldown_ app is deliberately not installed: it only supports Loki, and
|
||||
no setting here changes that, so Explore is the log browser for this
|
||||
swarm.
|
||||
|
||||
| Option | When you'd touch it |
|
||||
|---|---|
|
||||
| Option | When you'd touch it |
|
||||
| ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `deploy.victorialogs.retentionPeriod` | Default `30d`, far shorter than the metrics store's — logs are bulkier per unit of value and are usually read within days of being written. Raise it if you need to answer questions about last quarter. |
|
||||
| `swarm.victorialogs.domain` | Only to rename it. |
|
||||
| `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. |
|
||||
| `swarm.victorialogs.domain` | Only to rename it. |
|
||||
| `swarm.victorialogs.port` | Only if something else on the services host already claims `9428`. |
|
||||
|
||||
Like the metrics store, it binds loopback and takes no credential of its
|
||||
own: the gateway vhost is the only way in, and the collector is the only
|
||||
|
|
@ -173,10 +173,10 @@ loopback URL an operator has to redirect. There is nothing to set on a hive
|
|||
that does not run the swarm's services; the name resolves through the
|
||||
gateway either way.
|
||||
|
||||
| Option | When you'd touch it |
|
||||
|---|---|
|
||||
| Option | When you'd touch it |
|
||||
| ------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `swarm.otel.domain` | Only to rename it — the default already resolves correctly for every hive in the swarm. |
|
||||
| `swarm.otel.port` | Only if something else on the services host already claims `4319`. |
|
||||
| `swarm.otel.port` | Only if something else on the services host already claims `4319`. |
|
||||
|
||||
With neither `otel.endpoint` nor the store enabled, this collector is
|
||||
refused at eval — a tier that receives samples and drops them looks
|
||||
|
|
@ -184,4 +184,3 @@ healthy while losing data.
|
|||
|
||||
Agent-side configuration, and what a hive's own collector does, are in
|
||||
[`../scheduler/observability.md`](../scheduler/observability.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -157,12 +157,12 @@ evaluation error.
|
|||
The registration half is identical; what each service does with the
|
||||
result is not.
|
||||
|
||||
| | forge | matrix |
|
||||
|---|---|---|
|
||||
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
|
||||
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
|
||||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||
| | forge | matrix |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file |
|
||||
| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` |
|
||||
| callback URL | `<root>/user/oauth2/<source>/callback` | `<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`, a shape tuwunel fixes rather than accepts |
|
||||
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
|
||||
|
||||
Two consequences worth stating plainly:
|
||||
|
||||
|
|
@ -213,13 +213,13 @@ friendly "SSO is unavailable" page; that page is a file, so it returns
|
|||
HTML instead of the failure that actually happened:
|
||||
|
||||
- `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as
|
||||
success**, so a down provider means *access granted*
|
||||
success**, so a down provider means _access granted_
|
||||
- `/api/oidc/introspection` — a token check that answers `200`
|
||||
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client
|
||||
parsing an error page as its JSON document
|
||||
|
||||
So authelia's `/api/` and `/.well-known/` prefixes are routed **without**
|
||||
error interception. The split is by *audience*, not by an enumerated path
|
||||
error interception. The split is by _audience_, not by an enumerated path
|
||||
list: a human gets the page, every machine caller gets the status.
|
||||
Enumerating endpoints individually would leave the next one added
|
||||
silently intercepted.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ The swarm's own web surface, served by the gateway on the **swarm apex**
|
|||
(`services.hyperhive.swarm.domain`) and readable only by operators.
|
||||
|
||||
Distinct from the per-hive dashboard, which lives on the hive domain and
|
||||
answers for one host. This one is the view *across* hives.
|
||||
answers for one host. This one is the view _across_ hives.
|
||||
|
||||
## Enabling
|
||||
|
||||
|
|
@ -15,7 +15,7 @@ services.hyperhive.deploy.swarm-ui.enable = true; # defaults to deploy.swarm-c
|
|||
Derived from the controller rather than from `allSwarmServices`:
|
||||
the UI is a view onto the controller's state and reaches it over that
|
||||
daemon's socket, so the host that runs the controller is the host that
|
||||
can serve the UI. A hive that merely *uses* a swarm has nothing to serve.
|
||||
can serve the UI. A hive that merely _uses_ a swarm has nothing to serve.
|
||||
|
||||
The UI answers on `services.hyperhive.swarm.domain` and nothing else. It
|
||||
shares that name with the swarm-controller it fronts — one service to a
|
||||
|
|
@ -29,8 +29,8 @@ reader and to a certificate — so there is no separate option to pin.
|
|||
## 🔑 You must be in the `admins` group
|
||||
|
||||
**This is the step that separates "protected" from "locked out".** The
|
||||
vhost's `auth_request` asks authelia *"is there a session"*; the rule
|
||||
that makes it mean *"is this an operator"* is an `access_control` entry
|
||||
vhost's `auth_request` asks authelia _"is there a session"_; the rule
|
||||
that makes it mean _"is this an operator"_ is an `access_control` entry
|
||||
requiring `group:admins`. An account without that group authenticates
|
||||
fine and still gets bounced.
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ swarmctl user add <you> --group admins
|
|||
`admins` deliberately, not a new word: [`../getting-started/setup.md`](../getting-started/setup.md) has
|
||||
told every operator to create exactly that group since the bootstrap step
|
||||
existed, so an account made by following the guide already passes. This
|
||||
is the first rule that *consumes* a group name — inventing a second one
|
||||
is the first rule that _consumes_ a group name — inventing a second one
|
||||
would have meant those accounts silently failing a check they were
|
||||
supposed to pass.
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ An account created without any group needs re-adding with the flag —
|
|||
what changes.
|
||||
|
||||
Why a group and not a list of usernames: agents are getting authelia
|
||||
accounts of their own (matrix SSO), and *authenticated* would then
|
||||
accounts of their own (matrix SSO), and _authenticated_ would then
|
||||
include every agent in the hive. The group is the only thing standing
|
||||
between "an operator's page" and "anyone with a session".
|
||||
|
||||
|
|
@ -68,10 +68,10 @@ Adding a swarm service name means touching two things. Missing the
|
|||
second ships as a different flavour of "works from the host, broken from
|
||||
a container":
|
||||
|
||||
| site | file |
|
||||
| --- | --- |
|
||||
| site | file |
|
||||
| ---------------------------- | --------------------------------------------------------------- |
|
||||
| vhost + `gateway.localNames` | the service's own module (e.g. `nix/host-modules/swarm-ui.nix`) |
|
||||
| **certificate name** | `nix/host-modules/swarm.nix` (`serviceDomains`) |
|
||||
| **certificate name** | `nix/host-modules/swarm.nix` (`serviceDomains`) |
|
||||
|
||||
The DNS record and the local-dev `/etc/hosts` entry need no separate
|
||||
edit: both are derived from `services.hyperhive.gateway.localNames`,
|
||||
|
|
@ -83,7 +83,7 @@ matrix discovery); a swarm service declares its own vhost next to its
|
|||
own options, the way `swarm-ui.nix` and `swarm-authelia.nix` do.
|
||||
|
||||
⚠️ The certificate one is the least obvious and the most visible when
|
||||
missed. `serviceDomains` is *both* the services sub-CA's
|
||||
missed. `serviceDomains` is _both_ the services sub-CA's
|
||||
`nameConstraints` set and the leaf's SAN list, and the apex is a
|
||||
**sibling** of `forge.<swarm>` / `chat.<swarm>` / `auth.<swarm>`, not a
|
||||
parent — no CA in the hierarchy issues for it implicitly. Left out, the
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Tools
|
||||
|
||||
`hivectl` is *your* tool — the operator's own host CLI. Everything
|
||||
`hivectl` is _your_ tool — the operator's own host CLI. Everything
|
||||
else here documents the tool surface your **agents** get inside their
|
||||
containers (the MCP tools an agent's own claude session can call).
|
||||
You never call these directly, but they're the reference for what an
|
||||
|
|
|
|||
|
|
@ -20,15 +20,15 @@ output. Handle it on a future turn — unless `wait_seconds` already
|
|||
delivered the terminal result inline, in which case no todo is created
|
||||
(see `status` below).
|
||||
|
||||
* `timeout_secs` — kill the task after N seconds and mark it
|
||||
- `timeout_secs` — kill the task after N seconds and mark it
|
||||
`timed_out`. Omit for no timeout (runs until natural exit).
|
||||
* `wait_seconds` — inline poll before returning (capped at 30).
|
||||
- `wait_seconds` — inline poll before returning (capped at 30).
|
||||
When the task finishes within the window the full status is
|
||||
returned immediately and no todo is created; when the window expires
|
||||
the task keeps running and the normal `task started: id=<id>`
|
||||
response is returned. **Defaults to 3** — pass `wait_seconds: 0`
|
||||
to disable inline waiting and always get the immediate response.
|
||||
* `name` — optional caller-chosen task id. When set it replaces the
|
||||
- `name` — optional caller-chosen task id. When set it replaces the
|
||||
auto-generated hex id, so it surfaces in `status(<name>)` lookups and
|
||||
the loose-ends list — a memorable label instead of an opaque id. A name
|
||||
is **reusable once its previous task has finished**; submitting a
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ resume drains the backlog rather than dropping it. Points worth knowing:
|
|||
|
||||
- **Sticky.** The marker lives on the persistent harness mount, so a
|
||||
paused agent stays paused across a container restart — and pausing a
|
||||
*stopped* agent makes it come up parked.
|
||||
_stopped_ agent makes it come up parked.
|
||||
- **Not a DAG.** Unlike `restart`/`stop`, there's no container operation
|
||||
to sequence, so it applies immediately with nothing to wait on.
|
||||
- **Stopping a paused agent is still fast.** The graceful-stop
|
||||
|
|
@ -201,7 +201,7 @@ they go into a systemd drop-in verbatim, and a typo there makes the
|
|||
unit fail to start.
|
||||
|
||||
**Declarative, not incremental**: each invocation replaces the agent's
|
||||
whole entry. `set-limits sock --memory-max 8G` leaves `sock` with *only*
|
||||
whole entry. `set-limits sock --memory-max 8G` leaves `sock` with _only_
|
||||
a memory override, reverting any previously-set CPU quota to the hive
|
||||
default. To avoid a forgotten flag silently wiping an override, a bare
|
||||
`set-limits <name>` with no flags is rejected — clearing requires the
|
||||
|
|
@ -236,7 +236,7 @@ Bare `choom` starts a fresh blank session. `--resume <value>` passes
|
|||
through as `claude --resume <value>` to rejoin a prior session by its
|
||||
session id — the flag name deliberately matches the claude flag it maps
|
||||
to. (choom never uses claude's `--continue`: that's a bare flag that
|
||||
takes no argument and resumes the cwd's *latest* session, i.e. the
|
||||
takes no argument and resumes the cwd's _latest_ session, i.e. the
|
||||
harness's; a value after it would be consumed as the first prompt,
|
||||
silently poking the live harness session.) A value is required when the
|
||||
flag is given. Either way choom never collides with the harness's live
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ agents after the approval resolves.
|
|||
| `kill` / `start` / `restart` / `update` | No | Direct children |
|
||||
| `list_containers` | No | All descendants |
|
||||
| `request_init_config` | Yes (InitConfig) | New direct child only |
|
||||
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
|
||||
| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) |
|
||||
|
||||
## See also
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ This document contains the help content for the `swarmctl` command-line program.
|
|||
|
||||
**Command Overview:**
|
||||
|
||||
* [`swarmctl`↴](#swarmctl)
|
||||
* [`swarmctl user`↴](#swarmctl-user)
|
||||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||
* [`swarmctl user list`↴](#swarmctl-user-list)
|
||||
* [`swarmctl completions`↴](#swarmctl-completions)
|
||||
- [`swarmctl`↴](#swarmctl)
|
||||
- [`swarmctl user`↴](#swarmctl-user)
|
||||
- [`swarmctl user add`↴](#swarmctl-user-add)
|
||||
- [`swarmctl user update`↴](#swarmctl-user-update)
|
||||
- [`swarmctl user list`↴](#swarmctl-user-list)
|
||||
- [`swarmctl completions`↴](#swarmctl-completions)
|
||||
|
||||
## `swarmctl`
|
||||
|
||||
|
|
@ -19,17 +19,15 @@ swarm-level operator CLI
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `user` — Manage subjects in the swarm's SSO provider
|
||||
* `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
||||
- `user` — Manage subjects in the swarm's SSO provider
|
||||
- `completions` — Generate a shell completion script for `swarmctl` and print it to stdout
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the *configured* package rather than whatever is on `PATH`
|
||||
* `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root.
|
||||
|
||||
This is the only user store: it is read before every change and written in place, and `swarm-authelia-bridge` writes the same file.
|
||||
|
||||
- `--authelia-bin <PATH>` — authelia binary used to hash passwords. The argon2 parameters must match the verifier's, so this has to be the _configured_ package rather than whatever is on `PATH`
|
||||
- `--users-file <PATH>` — Host-side path of authelia's users database — i.e. the path inside the container, prefixed with the container's root.
|
||||
|
||||
This is the only user store: it is read before every change and written in place, and `swarm-authelia-bridge` writes the same file.
|
||||
|
||||
## `swarmctl user`
|
||||
|
||||
|
|
@ -39,11 +37,9 @@ Manage subjects in the swarm's SSO provider
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `add` — Add a user, generating a password for them
|
||||
* `update` — Change an existing user's attributes
|
||||
* `list` — List every user in authelia's users database
|
||||
|
||||
|
||||
- `add` — Add a user, generating a password for them
|
||||
- `update` — Change an existing user's attributes
|
||||
- `list` — List every user in authelia's users database
|
||||
|
||||
## `swarmctl user add`
|
||||
|
||||
|
|
@ -53,15 +49,13 @@ Add a user, generating a password for them
|
|||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<USERNAME>` — Login name. Conservative ASCII only — it is a YAML map key and reaches access-control rules and logs
|
||||
- `<USERNAME>` — Login name. Conservative ASCII only — it is a YAML map key and reaches access-control rules and logs
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--display-name <TEXT>` — Name shown in the SSO UI. Defaults to the username
|
||||
* `--email <ADDRESS>`
|
||||
* `--group <GROUP>` — Repeatable
|
||||
|
||||
|
||||
- `--display-name <TEXT>` — Name shown in the SSO UI. Defaults to the username
|
||||
- `--email <ADDRESS>`
|
||||
- `--group <GROUP>` — Repeatable
|
||||
|
||||
## `swarmctl user update`
|
||||
|
||||
|
|
@ -73,16 +67,14 @@ Every flag is optional and they compose, so one call can set several things at o
|
|||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<USERNAME>` — Login name of an existing user
|
||||
- `<USERNAME>` — Login name of an existing user
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--display-name <TEXT>` — Name shown in the SSO UI
|
||||
* `--email <ADDRESS>`
|
||||
* `--add-group <GROUP>` — Repeatable. Adding a group the user is already in is not an error
|
||||
* `--remove-group <GROUP>` — Repeatable. Fails if the user is not in the group — a revocation that reports success without revoking is the failure nobody re-checks
|
||||
|
||||
|
||||
- `--display-name <TEXT>` — Name shown in the SSO UI
|
||||
- `--email <ADDRESS>`
|
||||
- `--add-group <GROUP>` — Repeatable. Adding a group the user is already in is not an error
|
||||
- `--remove-group <GROUP>` — Repeatable. Fails if the user is not in the group — a revocation that reports success without revoking is the failure nobody re-checks
|
||||
|
||||
## `swarmctl user list`
|
||||
|
||||
|
|
@ -92,8 +84,6 @@ Read-only: it never writes the file. Shows every subject in it, including agent
|
|||
|
||||
**Usage:** `swarmctl user list`
|
||||
|
||||
|
||||
|
||||
## `swarmctl completions`
|
||||
|
||||
Generate a shell completion script for `swarmctl` and print it to stdout.
|
||||
|
|
@ -106,16 +96,13 @@ Dispatched before `PathArgs::resolve()` for the same reason as `markdown-docs`:
|
|||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<SHELL>` — Shell to emit completions for
|
||||
- `<SHELL>` — Shell to emit completions for
|
||||
|
||||
Possible values: `bash`, `elvish`, `fish`, `powershell`, `zsh`
|
||||
|
||||
|
||||
|
||||
|
||||
<hr/>
|
||||
|
||||
<small><i>
|
||||
This document was generated automatically by
|
||||
<a href="https://crates.io/crates/clap-markdown"><code>clap-markdown</code></a>.
|
||||
This document was generated automatically by
|
||||
<a href="https://crates.io/crates/clap-markdown"><code>clap-markdown</code></a>.
|
||||
</i></small>
|
||||
|
|
|
|||
|
|
@ -99,11 +99,11 @@ exact same path, so the group grant always holds.
|
|||
`/run/hive-agent/<name>/` is shared by **three principals that share no
|
||||
group**, which is why its mode is what it is:
|
||||
|
||||
| principal | reaches | needs |
|
||||
|---|---|---|
|
||||
| principal | reaches | needs |
|
||||
| ------------------- | ---------------------------------------- | ------------ |
|
||||
| the agent's harness | binds + unlinks `agent.sock`, `web.sock` | owner, `rwx` |
|
||||
| `hive-c0re` | dials `agent.sock` (todo wakes) | traverse |
|
||||
| the gateway's nginx | dials `web.sock` | traverse |
|
||||
| `hive-c0re` | dials `agent.sock` (todo wakes) | traverse |
|
||||
| the gateway's nginx | dials `web.sock` | traverse |
|
||||
|
||||
The last two land in "other", so the dir is **`0751`, owned by the
|
||||
agent's container uid/gid** — `o=--x` is traverse without listing, and
|
||||
|
|
@ -111,13 +111,13 @@ both sockets are `0666`, which is all a dialer needs.
|
|||
|
||||
**Ownership is declared, not repaired.** The tmpfiles.d entry written by
|
||||
`SyncAgentTmpfiles` names the uid/gid directly. Do not add a chown
|
||||
alongside it: `d` re-applies on every boot *and* every agent
|
||||
alongside it: `d` re-applies on every boot _and_ every agent
|
||||
spawn/destroy, so ownership set afterwards is reverted the next time any
|
||||
agent changes — which is exactly how this dir spent a long time at
|
||||
`0777 root root` while a privileged chown appeared to be fixing it.
|
||||
|
||||
The mode is load-bearing, not cosmetic. Write permission on a
|
||||
*directory* is what confers the right to unlink its entries, whoever owns
|
||||
_directory_ is what confers the right to unlink its entries, whoever owns
|
||||
them, and the sticky bit is the only thing that would restrain that (it
|
||||
is not set here). A world-writable socket dir therefore lets anything
|
||||
able to reach the path delete an agent's socket and bind its own — and
|
||||
|
|
@ -129,11 +129,11 @@ nginx reaches all of `/run/hive-agent` as a plain host path. Dropping
|
|||
them and the rest of the host. That costs no network isolation: nginx
|
||||
binds the host's `:80`/`:443` and reaches `localhost` upstreams, which a
|
||||
netns would have to be opened up for anyway.
|
||||
🔑 It does mean nothing *implicitly* scopes the privileged reload verb —
|
||||
🔑 It does mean nothing _implicitly_ scopes the privileged reload verb —
|
||||
see [`docs/trust-boundary/security.md`](security.md#hive-c0re-privilege-separation) for
|
||||
how `PrivRequest::ReloadGatewayNginx`'s containment works.
|
||||
|
||||
⚠️ Contrast `/shared`, which *is* sticky world-writable (`1777`): it has
|
||||
⚠️ Contrast `/shared`, which _is_ sticky world-writable (`1777`): it has
|
||||
many legitimate writers, so sticky is the best available answer there.
|
||||
This dir has exactly one writer, so it needs no world write at all.
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ This dir has exactly one writer, so it needs no world write at all.
|
|||
|
||||
`hivectl` drives the whole hive — spawn / kill / destroy / rebuild /
|
||||
deploy — over the **host admin socket** `/run/hyperhive/host.sock`,
|
||||
socket-activated by the `hive-c0re.socket` unit. That socket *is* the
|
||||
socket-activated by the `hive-c0re.socket` unit. That socket _is_ the
|
||||
full-control surface, so who can connect to it is a real trust
|
||||
boundary.
|
||||
|
||||
|
|
|
|||
|
|
@ -207,22 +207,22 @@ for every operation that genuinely requires root.
|
|||
**Narrow interface** — `PrivRequest` variants map 1:1 to specific
|
||||
known operations; there is no arbitrary command pass-through:
|
||||
|
||||
| Operation | What it runs |
|
||||
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
| `ReadContainerJournal` | `journalctl -M <container> -n <n> [filters...]` |
|
||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) |
|
||||
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container |
|
||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
|
||||
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
|
||||
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` |
|
||||
| Operation | What it runs |
|
||||
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create/update <name> --flake <ref>` |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
| `ReadContainerJournal` | `journalctl -M <container> -n <n> [filters...]` |
|
||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list + network isolation vars) |
|
||||
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container |
|
||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
|
||||
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
|
||||
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` |
|
||||
|
||||
**Container allowlist** — every request is validated against an
|
||||
allowlist before any operation: only names matching the agent-name
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ percentage-of-window ctx badge.
|
|||
harness-owned session title (`turn::session_title()`, default
|
||||
`hive-session`, override `HIVE_SESSION_TITLE`). The durable
|
||||
`hive_claude::InfiniteSession` (built once by the serve loop via
|
||||
`turn::make_session`, then reused) `--resume <title>`s it; the *first* use
|
||||
`turn::make_session`, then reused) `--resume <title>`s it; the _first_ use
|
||||
(bootstrap, post-archive, post-purge) misses and the session re-runs the
|
||||
same prompt once with `--name <title>` to mint it. That single self-heal
|
||||
rule is the whole identity system — there is **no** scraped session-id
|
||||
|
|
@ -85,7 +85,7 @@ context (it won't carry our title). claude stores sessions in
|
|||
`~/.claude/projects/<cwd-slug>/<uuid>.jsonl` (bind-mounted persistently);
|
||||
`--name` writes the title into the file as a `custom-title` event, which
|
||||
is what `--resume <title>` resolves against. We never pass bare
|
||||
`--continue` (it resumes the *latest* session in the cwd — the hijack
|
||||
`--continue` (it resumes the _latest_ session in the cwd — the hijack
|
||||
vector). Auto-compact, auto-memory, and dynamic workflows (the `/workflows`
|
||||
feature) are disabled via the managed settings at
|
||||
`/etc/claude-code/managed-settings.json`: hyperhive owns compaction
|
||||
|
|
@ -94,12 +94,12 @@ feature) are disabled via the managed settings at
|
|||
harness's autonomous turns.
|
||||
|
||||
**Session reset** is available via `POST /api/new-session` (or
|
||||
`/new-session` slash command). It does *not* touch the session inline —
|
||||
`/new-session` slash command). It does _not_ touch the session inline —
|
||||
that would race a mid-write claude process. Instead `Bus::request_session_reset()`
|
||||
sets a one-shot flag consumed at the next turn boundary by `drive_turn`,
|
||||
which **archives** the current session: the backing `<uuid>.jsonl` is
|
||||
renamed to `<uuid>.jsonl.archived` (dropped out of claude's `*.jsonl`
|
||||
resolution glob, history preserved on disk, only the file carrying *our*
|
||||
resolution glob, history preserved on disk, only the file carrying _our_
|
||||
title — any `choom` session sharing the cwd is left alone). The next
|
||||
turn's `--resume <title>` then misses and self-heals into a fresh session.
|
||||
|
||||
|
|
@ -111,19 +111,19 @@ at `/etc/claude-code/managed-settings.json`); hyperhive owns it. The
|
|||
window with two triggers baked into its `run`:
|
||||
|
||||
- **Reactive** — claude-code prints `Prompt is too long`. The session is
|
||||
*already* past the window, so no turn can run on it — the session
|
||||
_already_ past the window, so no turn can run on it — the session
|
||||
`/compact`s straight away and retries the same wake-up prompt once. No
|
||||
notes-checkpoint turn is possible here: the detail is gone. If the retry
|
||||
*still* overflows, `run` surfaces `Error::PromptTooLong`; `drive_turn`
|
||||
_still_ overflows, `run` surfaces `Error::PromptTooLong`; `drive_turn`
|
||||
then archives the session (session lifecycle stays hive-side) and the
|
||||
serve loop requeues the message so it redelivers into a fresh session
|
||||
(see [Turn outcomes](README.md#turn-outcomes) — the wake prompt itself is tiny, so
|
||||
the overflow was the accumulated context the archive clears).
|
||||
- **Proactive** — a turn finishes cleanly but the last inference's context
|
||||
size crossed the policy watermark. While the session is still healthy it
|
||||
runs one synthetic *notes-checkpoint* turn (`CHECKPOINT_PROMPT` —
|
||||
runs one synthetic _notes-checkpoint_ turn (`CHECKPOINT_PROMPT` —
|
||||
"context is filling up, flush durable state into `/state` now") and
|
||||
*then* `/compact`s, so the agent can persist in-flight state before the
|
||||
_then_ `/compact`s, so the agent can persist in-flight state before the
|
||||
detail collapses into a summary.
|
||||
|
||||
The **when** is a `hive_claude::CompactionPolicy` injected by the harness:
|
||||
|
|
@ -166,7 +166,7 @@ still applies.
|
|||
session (same mechanism as the operator reset — rename `<uuid>.jsonl` →
|
||||
`.archived`) so the next turn's `--resume <title>` misses and starts
|
||||
fresh. Unlike proactive compaction the session is dropped entirely, not
|
||||
compacted — and *no* preceding checkpoint turn runs, because any turn
|
||||
compacted — and _no_ preceding checkpoint turn runs, because any turn
|
||||
before the reset would just re-warm the cache and defeat the purpose.
|
||||
Set `HIVE_AUTO_RESET_WATERMARK_TOKENS=0` to disable. Auto-reset and the
|
||||
operator reset are mutually exclusive per turn (both archive → fresh
|
||||
|
|
@ -236,11 +236,11 @@ needs to `chown` a bind mount), once at startup:
|
|||
|
||||
**`hive_identity` / `swarm_identity` shape.** Each carries a
|
||||
leading space + backticked name (` on hive \`pr1ma\``,
|
||||
` in swarm \`constellat1on\``) when the corresponding env var
|
||||
is set, otherwise empty string. The independence lets the
|
||||
template drop one or both into the opener prose without
|
||||
breaking single-hive deployments that never set the option;
|
||||
the renderer also treats `Some("")` from a caller as `None` so
|
||||
` in swarm \`constellat1on\``) when the corresponding env var
|
||||
is set, otherwise empty string. The independence lets the
|
||||
template drop one or both into the opener prose without
|
||||
breaking single-hive deployments that never set the option;
|
||||
the renderer also treats `Some("")`from a caller as`None` so
|
||||
empty-string env vars and missing env vars round-trip the
|
||||
same way.
|
||||
|
||||
|
|
@ -251,4 +251,3 @@ telemetry-to-bus bridge — lives in `hive-agent`'s `turn` module; see its
|
|||
The actual claude spawn, stream classification, and the
|
||||
reactive/proactive compaction loop are in the `hive-claude` crate.
|
||||
Login-wait lives in `hive-agent`'s `login` module.
|
||||
|
||||
|
|
|
|||
|
|
@ -75,11 +75,11 @@ Declares extra navigation links that appear in the per-agent page
|
|||
header alongside the built-in forge / config / container links. Each
|
||||
entry has:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `label` | yes | Display text shown in the icon strip tooltip and meta-nav. |
|
||||
| `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). |
|
||||
| `icon` | no | Emoji or short glyph prefix. Defaults to empty string. |
|
||||
| Field | Required | Description |
|
||||
| ------- | -------- | ----------------------------------------------------------------------------------------- |
|
||||
| `label` | yes | Display text shown in the icon strip tooltip and meta-nav. |
|
||||
| `url` | yes | Absolute URL — may include a different port (the dashboard renders it as a plain anchor). |
|
||||
| `icon` | no | Emoji or short glyph prefix. Defaults to empty string. |
|
||||
|
||||
The list is written to `<state>/hyperhive-dashboard-links.json` by a
|
||||
one-shot systemd unit at container boot. The harness's own web UI
|
||||
|
|
@ -213,6 +213,7 @@ hyperhive.claudePluginsAutoUpdate = false; # default
|
|||
> to list `skill-creator@claude-plugins-official` and `base@hyperhive`
|
||||
> explicitly alongside its own entries — likewise for the two default
|
||||
> entries in `claudeMarketplaces`.
|
||||
|
||||
- **`claudePluginsAutoUpdate`** — when `true`, runs
|
||||
`claude plugin marketplace update` before installing plugins to pull
|
||||
the latest index. Disabled by default to keep boot times short and
|
||||
|
|
@ -265,7 +266,7 @@ is a no-op without the other:
|
|||
(outside the nix store, one `KEY=value` per line, systemd
|
||||
`EnvironmentFile` syntax) supplying the credentials `claude` itself
|
||||
reads from the environment — typically `ANTHROPIC_API_KEY` and
|
||||
`ANTHROPIC_BASE_URL`. Loaded as an *optional* `EnvironmentFile`
|
||||
`ANTHROPIC_BASE_URL`. Loaded as an _optional_ `EnvironmentFile`
|
||||
(leading `-`), so setting the option before the file exists doesn't
|
||||
strand the harness at boot.
|
||||
|
||||
|
|
@ -299,4 +300,3 @@ Switching an already-provisioned OAuth agent to `useApiKey` leaves
|
|||
cleaned up automatically. Cost shape also changes: subscription pricing
|
||||
→ per-request billing with no built-in monthly cap, worth knowing before
|
||||
pointing a busy agent at a metered backend.
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ already in flight.
|
|||
`{state_dir}/hyperhive-status`. Pass `""` to clear.
|
||||
- `get_agent_meta` — fetch identity + status metadata for an agent:
|
||||
`{ name, hyperhive_rev, running, status_text, status_set_at,
|
||||
hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
|
||||
hive_name?, swarm_name?, matrix_accounts? }`. `matrix_accounts` is a
|
||||
list of matrix identities the agent can act as (`name`, `user_id?`,
|
||||
`homeserver`); omitted for agents with no matrix provisioning. Omit
|
||||
`name` to query self.
|
||||
|
|
@ -229,4 +229,3 @@ built-in shell there stays within the existing trust boundary. The bash MCP serv
|
|||
|
||||
`WebFetch` / `WebSearch` are off by default; enable the `web_tools`
|
||||
tool group in the P3RM1SS10NS tab and rebuild the agent to enable them.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ Both are SPAs — `GET /` returns a static shell, `/api/state`
|
|||
returns JSON, JS renders. No full-page reloads.
|
||||
|
||||
This doc has been split for readability. Pick the section you need —
|
||||
or, if you're the operator looking for how to actually *use* the
|
||||
or, if you're the operator looking for how to actually _use_ the
|
||||
dashboard rather than how it's built, start at
|
||||
**[web-ui/README.md](web-ui/README.md)** instead.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ of your time. It's a single page with exactly four tabs:
|
|||
|
||||
- **SW4RM** — every agent, live. This is the default tab and the one
|
||||
you'll check most.
|
||||
- **Y3R C4LL** — anything waiting on *you*: pending approvals. If an
|
||||
- **Y3R C4LL** — anything waiting on _you_: pending approvals. If an
|
||||
agent needs a decision from you, it's here.
|
||||
- **P3RM1SS10NS** — what tools and system-level access each agent has.
|
||||
- **SCH3DUL3S** — scheduled prompts and agent self-reminders.
|
||||
|
|
@ -47,7 +47,7 @@ and quick links (stats, screen, forge profile). Click the name to open
|
|||
its terminal and watch it work in real time.
|
||||
|
||||
**Approve something an agent is waiting on.** Y3R C4LL is the one tab
|
||||
worth checking regularly — it's everything that needs *you*: approvals
|
||||
worth checking regularly — it's everything that needs _you_: approvals
|
||||
for config changes. The tab's count pill tells you at a glance if
|
||||
anything's pending.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Scope: **swarm-ui only** (the swarm-level Preact app — not the per-hive
|
||||
dashboard, which has its own older visual language). This doc is the
|
||||
*why*: the principles behind how swarm-ui looks and behaves, and the
|
||||
_why_: the principles behind how swarm-ui looks and behaves, and the
|
||||
concrete rules that follow from them. It deliberately doesn't show what
|
||||
things look like — that's `ComponentsPage` (`/components`), the living,
|
||||
always-current demo of every primitive in `src/ui/`. Code can't go stale
|
||||
|
|
@ -21,14 +21,14 @@ repeated here.
|
|||
distinction, not decoration for its own sake.
|
||||
- **Whimsy** — small, delightful touches are welcome (the per-hive
|
||||
dashboard's home-page matrix-rain background, `packages/dashboard/src/
|
||||
home.js`, is the reference example — currently in the dashboard
|
||||
home.js`, is the reference example — currently in the dashboard
|
||||
package, not swarm-ui itself, but the pattern it sets applies here
|
||||
too). Whimsy still has to clear the accessibility bar below (motion,
|
||||
in particular). swarm-ui's own reference example: `Panel`'s optional
|
||||
`icon` prop (`src/ui/panel/Panel.tsx`), a small emoji glyph in a
|
||||
panel's header, chosen per panel with no default — grew out of a
|
||||
one-off emoji dropped into a single page's copy, which wasn't whimsy
|
||||
in this sense (a *consistent*, reusable touch) until it became a real
|
||||
in this sense (a _consistent_, reusable touch) until it became a real
|
||||
prop every panel can opt into. `aria-hidden`, since it's decorative —
|
||||
the title text is still the actual label.
|
||||
- **Efficient navigation** — minimize clicks/hops for a common task.
|
||||
|
|
@ -62,7 +62,7 @@ smooth them instead.
|
|||
|
||||
The mechanical contract (base16 slots, semantic vars, what a page's CSS
|
||||
is and isn't allowed to reference) lives in `docs/web-ui/css-vars.md` —
|
||||
read that for the how. This section is the *policy* layered on top:
|
||||
read that for the how. This section is the _policy_ layered on top:
|
||||
|
||||
- **User-theming compatible by construction.** The whole point of the
|
||||
base16/`colors.css` swap contract is that a user's own theme (stylix
|
||||
|
|
@ -132,7 +132,7 @@ just bare column headers floating over nothing.
|
|||
touch target (`2.75em` ≈ 44px, WCAG 2.5.5) by default, so this isn't
|
||||
something each page has to remember.
|
||||
- **Phone is a second-class citizen, not an unsupported one.** Things
|
||||
must not *break* at phone width, but don't over-invest in phone
|
||||
must not _break_ at phone width, but don't over-invest in phone
|
||||
optimization beyond that. swarm-ui is installable as a PWA, so a
|
||||
phone can check status or do basic interactions.
|
||||
- **A narrow viewport isn't only a phone** — a tiling-window-manager
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue