Skip to content

feat(dash): slot indicator dot states + warming pulse#314

Merged
thinmintdev merged 1 commit into
mainfrom
feat/slot-state-indicator-dots
May 25, 2026
Merged

feat(dash): slot indicator dot states + warming pulse#314
thinmintdev merged 1 commit into
mainfrom
feat/slot-state-indicator-dots

Conversation

@thinmintdev
Copy link
Copy Markdown
Contributor

Investigation summary

/api/slots on hal0 LXC exposes state + metadata.updated_at (last state-change wall clock) but no last_used_at (last-serve wall clock). SlotManager already maintains _last_used: dict[str, float] in memory, bumped on every dispatched request via the serving() async context manager (manager.py:1421/1440/1443) — wired through Dispatcher.forward → _forward_with_serving for every slot-routed call. So the "recently live within 1h" signal already exists server-side; it just isn't surfaced. Minimum diff: thread _last_used.get(slot_name) onto the Slot snapshot returned from SlotManager.status(), surface it on as_dict(), and consume it in the frontend. No persistence — on hal0-api restart the field resets to null, which the dashboard treats as "stale" (yellow). That matches operator intuition: we don't actually know if the slot was hit during downtime.

State → dot.cls mapping

Slot state Last-used age Dot class Color Behaviour
ready within 1h recent green (--ok) static
ready >1h or null stale yellow (--warn) static
idle stale yellow (--warn) static
warming / starting / pulling / unloading warming amber (--warn) pulsing (1.2s ease-in-out)
serving serving cyan (--accent) pulsing (pre-existing)
error error red (--err) static
offline / unknown offline grey (--fg-4) static

The 1h threshold lives as a single named const (RECENTLY_LIVE_MS = 60 * 60 * 1000) in slots.jsx. The mapping itself is the single function slotIndicator(slot), exported on window for unit testing.

Backend changes

  • src/hal0/slots/manager.py: Slot.__init__ + Slot.as_dict() gain last_used_at: float | None. SlotManager.status() populates it from self._last_used.get(slot_name).

That's it. No new endpoint, no schema migration, no new bump-site (the existing serving() context already covers every dispatched request). _last_used is process-local; hal0-api restart resets to null and the UI degrades to yellow, which is honest.

Known limitation observed live (NOT a regression — this PR's scope is the indicator, not the bump path): on a hal0 install with no upstreams.toml configured, chat completions fall through to lemonade_proxy._proxy (api/routes/v1.py:241), which bypasses SlotManager.serving() and therefore doesn't bump last_used_at. On a normal install with primary registered as a slot upstream, the dispatcher's _forward_with_serving path bumps as expected. A follow-up could add the bump to the proxy fallback; flagged in the PR conversation for triage rather than expanded here.

Frontend changes

  • ui/src/dash/slots.jsx: new slotIndicator(slot, now?) helper returning {cls, label, tooltip}. New IndicatorDot component. RECENTLY_LIVE_MS const. Replaces the two <span className={"dot " + state}/> sites in SlotCard + SlotListRow. All three exposed on window for tests.
  • ui/src/dashboard.css: adds .dot.recent, .dot.stale, .dot.warming, .dot.offline. Reuses existing palette + pulse keyframe (now 0.4 ↔ 1.0 opacity as the brief suggested, was 0.35).
  • ui/src/api/hooks/useSlots.ts: Slot interface gains last_used_at?: number | null so TS keeps its grip.

chrome.jsx (PR #306's territory) is not touched — its dots are lemond-status / coresident / follow-tail indicators, not per-slot state dots.

Tests

  • tests/slots/test_manager.py: new test_status_surfaces_last_used_at asserts Slot.last_used_at round-trips through status() + as_dict(), both before and after a bump_last_used() call. All 117 existing slot tests still pass.
  • ui/tests/e2e/specs/slot-indicator.spec.ts: 10 new Playwright cases pinning every cell of the mapping table — including the ≤1h is still recent / 1h + 1s is stale boundary cases and the null last_used_at "no requests since hal0-api started" tooltip.
  • ui/tests/e2e/specs/slot-indicator-live-screenshot.spec.ts: opt-in capture against the LXC, gated by HAL0_LIVE_LXC=1 (skipped in CI, useful for follow-up visual reviews).

Full local suite: 444 pytest tests pass, 44 Playwright tests pass (9 environmentally skipped).

Live verification on hal0 LXC (10.0.1.142)

After clean rebuild (cd /opt/hal0/ui && rm -rf dist node_modules/.vite && npm run build && systemctl restart hal0-api):

# Scenario Verified? Observation
1 /api/slots exposes last_used_at curl /api/slots returns "last_used_at": null for all 5 slots after restart
2 Ready slots with null last_used_at render yellow (stale) Captured screenshot: all 5 cards show --warn dot
3 Bump last_used_at → primary turns green (recent) ⚠️ simulated Unit test proves the mapping; on this LXC the chat path falls through lemonade_proxy (no upstreams registered) and bypasses serving(). Documented as known limitation; not introduced by this PR.
4 Forced stale (last_used_at > 1h ago) → yellow ✅ implicit The current LXC state IS this scenario (null reads as stale via the same code branch)
5 Restart → warming class (amber pulsing) POST /api/slots/primary/restart → screenshot captures primary's starting chip with amber dot, plus 4 other slots simultaneously in offline grey during the lemond cascade
6 Error state → red ⏭️ unit test only Not safe to force on the shared LXC; Playwright case error → error (red), tooltip surfaces metadata.message covers it
7 Offline slot → grey Multiple slots showed .dot.offline grey during scenario 5's restart cascade

Commands used:

# Verify field exposure
ssh hal0 'curl -s http://127.0.0.1:8080/api/slots' | python3 -m json.tool | head

# Capture restart-cascade screenshot
ssh hal0 'curl -s -X POST http://127.0.0.1:8080/api/slots/primary/restart -d "{}" -H "content-type: application/json" >/dev/null &' &
sleep 0.4
HAL0_LIVE_LXC=1 npx playwright test slot-indicator-live-screenshot --grep @live-lxc

Files touched

M  src/hal0/slots/manager.py              (+13 -1 — Slot.last_used_at + status() wiring)
M  tests/slots/test_manager.py            (+28 — test_status_surfaces_last_used_at)
M  ui/src/api/hooks/useSlots.ts           (+8 — Slot.last_used_at typing)
M  ui/src/dash/slots.jsx                  (+121 — slotIndicator + IndicatorDot + 2 callsites)
M  ui/src/dashboard.css                   (+11 -2 — 4 new dot classes + tweaked pulse opacity)
A  ui/tests/e2e/specs/slot-indicator.spec.ts                (118 lines — 10 mapping cases)
A  ui/tests/e2e/specs/slot-indicator-live-screenshot.spec.ts (29 lines — opt-in capture)

Owned-by-sibling-PR files NOT touched: chrome.jsx (#306), dashboard.jsx (#308/#309), chat.jsx (#309), main.tsx (#309), idle.py (#307), models.jsx/settings.jsx, dispatcher/router.py.

Out of scope

  • Animating colour transitions between states (warming pulse is the only animation)
  • Per-slot health detail panel
  • "Within 1h" history graph
  • Tunable threshold (1h hard-coded; separate PR if needed)
  • Bumping last_used_at from the lemonade proxy fallback path (separate PR — see the limitation note above)

🤖 Generated with Claude Code

@thinmintdev thinmintdev force-pushed the feat/slot-state-indicator-dots branch 2 times, most recently from 726c67e to e9ae99e Compare May 25, 2026 18:21
Surface slot lifecycle on the dashboard via colour-coded status dots —
the user's ask was "green for recently live, yellow for idle, red for
errors, grey for offline, plus a distinct warming/loading indicator".

Backend: `Slot.as_dict()` gains `last_used_at` (epoch seconds), sourced
from `SlotManager._last_used` (already bumped by `serving()` enter/exit
on every dispatched request). Process-local — on hal0-api restart the
field reads null, which the dashboard renders as "stale" (yellow). No
persistence, no new endpoint, no schema changes.

Frontend: new `slotIndicator(slot)` helper in `ui/src/dash/slots.jsx`
is the single source of truth for the dot mapping; `RECENTLY_LIVE_MS =
60 * 60 * 1000` is the one constant gating "recent" vs "stale". CSS
adds four new dot classes (`recent` / `stale` / `warming` / `offline`)
reusing the existing palette vars (`--ok` / `--warn` / `--fg-4`) and
the existing `pulse` keyframe for warming. Tooltip on each dot
surfaces "Loaded, last used 12 min ago" / "Warming up Qwen3.5-0.8B…"
/ "Error: <message>" via the `title` attribute.

State → dot.cls mapping:

  ready + last_used_at within 1h          → recent  (green)
  ready + >1h ago / null                  → stale   (yellow)
  idle                                    → stale   (yellow)
  warming / starting / pulling / unloading → warming (amber, pulses)
  serving                                 → serving (cyan, pulses; pre-existing)
  error                                   → error   (red)
  offline / unknown                       → offline (grey)

Tests: extends `tests/slots/test_manager.py` to assert
`status().last_used_at` round-trips through `as_dict()`. Adds 10
Playwright cases in `slot-indicator.spec.ts` pinning the mapping table
(state, label, tooltip, 1h boundary). Adds an opt-in
`slot-indicator-live-screenshot.spec.ts` (gated by `HAL0_LIVE_LXC=1`)
for visual capture against a live hal0 LXC.

Live-verified on hal0 LXC (10.0.1.142): all five ready slots with
null `last_used_at` rendered yellow stale dots; `POST /api/slots/
primary/restart` cascaded primary through `starting` (amber warming
pulse) while embed/rerank/stt/tts simultaneously turned grey
(`.dot.offline`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thinmintdev thinmintdev force-pushed the feat/slot-state-indicator-dots branch from e9ae99e to 0cb88ac Compare May 25, 2026 18:21
@thinmintdev thinmintdev merged commit 5098d99 into main May 25, 2026
4 checks passed
@thinmintdev thinmintdev deleted the feat/slot-state-indicator-dots branch May 25, 2026 19:04
thinmintdev added a commit that referenced this pull request May 28, 2026
…rough + gut installer auth section (#390)

- docs/operate/lemonade.md (new, .md canonical): operator reference for
  the v0.2 Lemonade runtime — what it is, where state lives, the /v1/*
  proxy + dispatcher fallthrough (PRs #248/#277), slot ↔ Lemonade
  model mapping (PRs #281/#282), max_loaded_models = 8 LRU cap (PR
  #283), per-type LRU eviction per ADR-0008 (supersedes nuclear-evict
  ADR-0007), OFFLINE-on-eviction (PR #276), and the three known v0.3
  caveats (Vulkan KV gauge missing, whisper RUNPATH workaround, GPU
  cleanup unload hang).

- docs/dashboard/v3.md (new, .md canonical, new docs/dashboard/ dir):
  page-by-page tour of the v3 React dashboard shipped in
  v0.3.0-alpha.1 (PR #235). Covers the shell + Mock-badge convention,
  /dashboard (system overview after #356), /chat (real surface per
  #309/#314/#315/#351), /slots (sidebar mirror per #357 + #344 UX
  sweep), /models (#313/#319/#353), /mcp (#304/#300), /agents (Peers
  per #299), /memory (graph #297, throughput #308), Settings (no Auth
  tab post-ADR-0012), and the footer journal (Epic #322 — PRs
  #321/#328/#329/#330/#332). Mock-fallback issues linked via the
  dashboard-v3 label, not enumerated.

- installer/README.md: gut ~95 lines of stale auth prose (Caddy,
  Bearer-token mint/use/revoke, first-run OTP claim wizard,
  HAL0_AUTH_ENABLED/HAL0_AUTH_DISABLED, password recovery, basic_auth
  upgrade path, the TLS recipe). Replace with one paragraph pointing
  at docs/operate/auth.mdx for the reverse-proxy recipe and
  docs/agents/identity.md for the X-hal0-Agent identity model. Auth
  was removed in v0.3.0-alpha.1 per ADR-0012; the README hadn't
  caught up.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant