Web console UI polish: dialog primitives, table truncation + mobile cards - #33
Merged
Conversation
Settings/policy edit dialogs had drifted because Field/Toggle were copy-pasted per file: 5 local Field copies (one using <span>+gap-1 instead of <Label>+gap-1.5), two divergent checkbox-row styles, and bare floating Save buttons. The upload dialog's empty-destinations hint also lived inside a grid cell, inflating one column and breaking the two-column vertical rhythm. Make apps/web/src/components/settings-fields.tsx the single source of truth (Field with optional hint/className at gap-2; polished Toggle with a disabled state) and migrate every consumer: - upload/retention/watchdog policy editors and the recording-profile editor now share Field/Toggle and anchor Save in DialogFooter. - Upload: destinations note moved to a full-width strip, Subfolder spans full width, fixing column alignment. - Recording profile: items-end so toggles bottom-align with inputs in the mixed Field/Toggle grid. - node-inventory-editors: dropped its duplicate Field. Net removal of duplication (-161/+76). Web tsc, oxlint (--deny-warnings), oxfmt, and the 144 web unit tests all pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On narrow desktop widths, list-table cells wrapped their contents — most visibly the action buttons (e.g. Delete dropping to a second line on Schedules), which looked broken. Make DataTable header and body cells whitespace-nowrap so the table grows to its natural width and the existing overflow-auto wrapper scrolls horizontally instead of wrapping. Drop flex-wrap from the five DataTable action columns (schedules, jobs, health, access users, access groups) so their button rows stay on a single line. Card action rows that use responsive justify (schedule-detail, node-health-events, channel-map-template-card) are left as-is — they are not tables. Web tsc, oxlint (--deny-warnings), oxfmt, and the 144 web unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Long single-value cells (names, ids, hostnames, emails, recurrence, audit action/reason, etc.) previously stretched their column and forced excessive horizontal scroll. Cap each such column at a width chosen for its likely data and truncate with an ellipsis; when a value is actually clipped, hovering (or focusing) reveals the full text in a tooltip. - New TruncateCell (components/ui): a single truncating line that measures its own overflow and only mounts a shadcn tooltip when the text is clipped. Used for multi-line name+id cells so each line clamps independently, and by DataTable for single-value columns. - DataTable gains an opt-in meta.truncateClassName (a max-w-* utility). Columns set it per their data; short columns (badges, dates, counts), action, checkbox and expander columns stay unset. - Applied across schedules, jobs, health, nodes, access, audit, rooms, access groups, and the settings sections (retention, watchdog, recording profiles, upload policies/destinations, channel maps, switchers). Web tsc, oxlint (--deny-warnings), oxfmt, and the 144 web unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dense multi-column tables only scrolled horizontally on phones. The shared DataTable now renders a stacked card per row below the md breakpoint (full table at md+), so every list in the console — nodes, schedules, jobs, health, audit, access, rooms, and all settings sections — gets a readable mobile layout from one change. Each card reuses the column `cell` renderers (badges, links, truncation preserved): the first data column is the title, the rest become labelled fields, select/expander chrome sits top-right, and the actions column becomes a footer. Long single-value fields wrap in the card rather than truncate, since there is no hover to reveal a tooltip on touch. Web tsc, oxlint (--deny-warnings), oxfmt, and 144 web tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Conformance + mobile polish surfaced by the UI audit: - Add toneTextClass() to status-colors and use it for the dashboard status icons and the settings access-state chip, replacing hardcoded text-emerald-*/text-amber-* ladders duplicated from the tone system. - Stat-tile grids (dashboard, jobs, health) go 2-up on mobile (grid-cols-2) instead of a single stacked column. - Fixed-width upload-policy / channel-mode selects become full-width on mobile (w-full sm:w-44) so they no longer overflow narrow rows. Web tsc, oxlint (--deny-warnings), oxfmt, and 144 web tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
yashau
added a commit
that referenced
this pull request
Jul 8, 2026
…cleanup (#34) * Fix agent-release route shadowed by /nodes/:nodeId (audit G-CRIT) GET /api/v1/nodes/agent-release was registered after GET /api/v1/nodes/:nodeId. The static+param collision forces Hono onto the registration-order-sensitive TrieRouter, so the release route was swallowed by the detail handler (404), silently killing the whole PR #32 update-available feature in production while isolated unit tests stayed green. Register the static route first (mirroring /export); make the release service injectable through registerNodeRoutes; add a full-surface route test (red: 404, green: 200). Extract large node-route test fakes to node-routes-helpers.ts to stay under the LOC guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Exclude provisioning nodes from reachable counts (audit N1/N2) A never-contacted provisioning node was reported as reachable by both the rakkr_node_online metric gauge and the dashboard Active Nodes count, because each used a naive status !== "offline" that predates the new provisioning state — inflating the reachable count and masking the RakkrNodeOffline alert. Add a shared isNodeReachable predicate (online/ recording/degraded/alerting) and use it in both sites so they cannot diverge again. Red->green via a metrics test (provisioning -> 0) and a dashboardReportingNodes helper test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Prefill scheduling defaults on calendar create (audit S1) The calendar day-cell create path called defaultDraft(firstNode) without the controller settings, so it always fell back to the built-in profile/ policies and ignored the operator's configured scheduling defaults (the schedules list page passes them). Thread the already-fetched controller settings into the create draft, mirroring the list page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Cover controller-settings default merge/clear on the in-memory store (audit S4) The keep-vs-?? merge (omitted field keeps, explicit null clears a default) was only exercised by a DB-gated test, so a regression of keep() to ?? would clear-silently-broken with the whole in-memory suite still green. Add an in-memory route test that sets two defaults independently, confirms an unrelated PATCH preserves them, and confirms explicit null clears exactly one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Lock provisioning/offline-liveness invariant in node-lifecycle baseline (audit N3) The node-lifecycle baseline verifier (shipped to promote node lifecycle to done) never asserted the provisioning-gating invariant PR #31 introduced: its source list omitted node-liveness.ts and watchdog-node-liveness.ts and no phrase/snippet mentioned provisioning or the offline gate. Document the provisioning/offline-liveness contract and extend the verifier to assert the gate source, the isNodeReachable predicate, and the liveness/watchdog test titles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Add 2026-07-08 gap-hunt audit ledger (Run 1) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Re-clamp paginated lists when the total shrinks (audit H4-1) useServerPagination only reset offset on filter/page-size change, never when the row total shrank below the current offset. Deleting the last page's rows (bulk delete / retention sweep) stranded the operator on a blank page past the end with only Previous to escape. Add a pure clampedOffset helper (red->green) + a clampToTotal hook method the pages call during render with the server meta.total, mirroring the filter-reset pattern. Wired into all paginated pages. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Finish stub-upload-policy removal from the console (audit H3-1, H3-2) PR #31 filtered the test-only stub upload policy from settings/scheduling but missed two paths: (H3-1) the recordings page fed the unfiltered list to the per-recording and bulk Queue Upload dropdowns, so the stub was a selectable — and default-first — real upload target; (H3-2) editing a legacy schedule persisted with the stub silently re-saved it (the form's filtered toggles left no way to clear it). Add a selectableUploadPolicies helper for the recordings action dropdowns (labeling keeps the full list), and strip the stub in scheduleToDraft. Red->green helper + draft tests. (recordings.tsx also carries its pagination clampToTotal wiring, H4-1.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Stop agent :jobId route from shadowing /recording-jobs/export (audit H1-1) The residual instance of the G1 route-shadow class: the node-auth GET /recording-jobs/:jobId (registered before the operator export route) swallowed GET /recording-jobs/export under TrieRouter, answering it with a node-credential 401. The agent handler must stay registered first (so agent job-reads work), so defer the reserved 'export' segment to the downstream operator handler — a job id is never literally 'export'. Red->green in the agent-job-read harness (production registration order). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Promote provisioning nodes on heartbeat; block self-un-promotion (audit N4) The heartbeat write persisted the agent-reported status verbatim and the schema accepted 'provisioning'/'offline', so a first heartbeat only promoted a provisioning node because the shipped agent hardcodes 'online' — and any node-credential holder could POST status:'provisioning' to un-promote a live node, which deriveNodeStatus short-circuits, suppressing its offline alert forever. Coerce a heartbeat's provisioning/offline to online in both stores (heartbeatStatus); the controller owns lifecycle state. Red->green transform tests (also closes the H2-2 coverage gap). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Require a real destination when creating an upload policy (audit H3-3) PR #31 intended every upload policy to target a real destination, but the create input left destinationId optional and the console eager-created a destination-less policy on New — assignable, and reconciling its recordings to partial (provider_not_configured). Enforce destinationId at the operator create route (the store stays lenient for seeds/tests); client seeds the first destination and disables New until one exists. Red->green route test; fixture updates for the stricter route. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Offer provisioning in the node status filter (audit H1-2) The node inventory status filter omitted 'provisioning', so operators could not filter to the enrolled-but-never-contacted cohort the API fully supports. Add it and lock the dropdown against the full NodeStatus enum. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 2 (7 fixes, streak 0) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Cap and dedup schedule uploadPolicyIds (audit R3-4) Each schedule upload policy id fans a recording out to its own upload queue item, but the list was uncapped and un-deduped — a schedule:manage holder could multiply queue work per recording with a long/duplicate list (the sibling switcher-mappings list already caps at 256). Cap the schedule create/update schemas at 32 and dedup the client draft before submit. Red->green dedup test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Lock upload-policy update against clearing the destination (audit H3-3 coverage) The create route rejects a destination-less policy; add the matching coverage on the update path — an empty destinationId is schema-rejected (400) and an omitted one is preserved — so a future nullable/empty-allowed change to the update schema can't silently reopen the H3-3 hole. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 3 (2 changes, streak 0) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 4 clean (streak 1/5) Run 4 convergence attempt: adversary-on-Run-3 + completeness critic (all 85 changed files), data-integrity/concurrency, permission-boundary sweeps — zero code changes; db:verify migration replay green. R4-1/R4-2 logged as suspected/mitigated. First clean run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 5 clean (streak 2/5) Run 5 convergence (fresh angles): end-to-end feature-trace, deploy/ops + docs/baseline consistency, observability/error-surface — all clean, zero code changes. node:test-db 22/22 green. Second consecutive clean run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Stop cleared watchdog numeric fields from persisting 0 (audit H4-2 re-triage) Run-6 re-triage found H4-2 was mis-classified as cosmetic for the watchdog editor: clearing a numeric field yields Number("")===0, and thresholdDbfs (dbfsSchema [-160,24]) and the score thresholds ([0,1]) ACCEPT 0 — so a cleared threshold silently persists 0 and arms an always-fire alert (watchdog-signal fires low-signal for all healthy audio), flooding the operator with spurious critical health events. Add a shared numericInputCommit helper (empty/invalid -> no commit, never 0) + a local text buffer in the shared NumberField (keeps the field editable while typing) so all 16 watchdog numeric fields are fixed at one point. Red->green helper test. Other editors' clear->0 is server-rejected (no persist) and stays the catalogued cosmetic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 6 dirty, H4-2 re-triaged + fixed (streak reset 2->0) Catalogue re-triage found H4-2 mis-classified (watchdog thresholdDbfs/score thresholds accept 0 -> persisted always-fire alert); fixed as H4-2W. Other 19 catalogued items confirmed correctly triaged. New suspected: R6-ENROLL-DUP, R6-INSTALL-URL-QUOTE. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Truncate over-cap heartbeat ipAddresses instead of failing closed (audit R7-IPCAP) The agent's collect_ip_addresses() reports every address from 'hostname -I' uncapped, but the heartbeat schema caps ipAddresses at .max(16). A multi-homed node (>16 IPs: IPv6 SLAAC/privacy + Docker/libvirt/VLAN bridges) had every heartbeat rejected 400 — and since the agent freezes the IP list at startup, that desynced the node forever, freezing lastSeenAt and flipping the live, recording node offline. A liveness heartbeat must not fail closed over a cosmetic field: preprocess ipAddresses to truncate to the documented cap and accept the heartbeat (keeping the first 16, the primary addresses). Red->green route test (20 IPs -> 202 + truncated, was 400). Agent-side .take(16) noted as defensive follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 7 dirty, R7-IPCAP heartbeat desync fixed (streak 0) Rust agent-contract angle found the agent's uncapped ipAddresses vs the heartbeat .max(16) reject -> permanent desync for multi-homed nodes; fixed controller-side (truncate). H4-2W verified sound. New catalogue: R7-IP-AGENT-CAP, R7-NUMCOMMIT-HEX, R7-SEED-LIVENESS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Keep a stale retention selection visible in the schedule form (audit R8-RETENTION-SELECT) The schedule form wrapped its recording-profile and watchdog Selects in withSelectedOption (which prepends a synthetic option so a current-but-absent id stays visible), but the retention Select rendered the raw list. A stale/ deleted retention id (a prefilled controller-settings default, or an edited schedule whose policy was removed) made the controlled Select fall back to its placeholder — reading as 'unselected' while the draft silently kept and re-saved the id. Route retention through the same helper (moved to schedule-draft as a shared, now-tested export). Red->green helper test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 8 dirty, R8-RETENTION-SELECT fixed (streak 0) Broad web sweep found the schedule-form retention Select not wrapped in withSelectedOption (stale id renders blank but re-saves); fixed. R7-IPCAP verified sound+complete. Runs 6-8 each closed one marginal PR-surface defect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 9 clean (streak 1/5) Adversary verified R8 sound; completeness critic over all 85 changed files found no new real defect. Zero code changes. R9-NODEIFACE-SELECT catalogued (pre-existing/bounded). First clean run after the 6-8 dirty streak. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 10 clean (streak 2/5) Security-lens + production-scenario edge passes both clean; zero code changes. Cross-replica schedule double-fire investigated -> documented single-writer constraint, rejected. Two consecutive clean runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 11 clean (streak 3/5) Fresh-eyes dense-logic re-read + test-durability critic (all 18 fixes' tests re-derived to catch a revert) both clean; zero code changes. Three consecutive clean runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Update gap-hunt ledger: Run 12 clean (streak 4/5) Diff-as-reviewer (raw PR hunks) + React effect/query-timing passes, both clean (run inline after session limits killed the subagents; diff-reviewer rerun independently confirmed). Zero code changes. Four consecutive clean runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Gap-hunt audit converged: 5 consecutive clean runs (Runs 9-13) Run 13 final convergence: last-chance broad sweep + agent-contract re-confirm both clean; full 'mise run check' (incl. rust+miri+fake-controller-smoke+ db:verify+all baselines) and 'mise run build' green. 18 findings fixed across 13 runs against static base 619b6f1; remaining opens all catalogued cosmetic/ suspected/pre-existing/by-design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Handle catalogued audit entries: API hardening Post-convergence cleanup of the 2026-07-08 gap-hunt catalogue (API items): - R4-1: dedup uploadPolicyIds server-side in buildSchedule + sanitizeScheduleUpdate (mirrors uniqueTags) so the server is authoritative. - R4-2: isolate each node's reconcile in reconcileNodeLivenessEvents with a per-node try/catch -> {skipped, reconcile_failed} so one failing node no longer aborts the whole tick. - H1-3: treat a provisioning node as unavailable in node-action-routes with an accurate node_provisioning reason (not node_offline). - N-3A/3B: agent-release fetch now follows Link rel=next (bounded to 5 pages, same-origin) and reads the body through a bounded reader (Content-Length pre-check + streaming byte cap); an over-cap body is rejected, last-good kept. - N-COV-FETCH + R11: lock the GitHub request contract and the uploadPolicyIds >32 rejection. Each with a red->green/coverage test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Handle catalogued audit entries: web console fixes Post-convergence cleanup of the 2026-07-08 gap-hunt catalogue (web items): - R3-1 (operator decision: Critical): offline node status now reads critical (red); nodeStatusBadgeClass rewritten as an exhaustive switch. - R3-2: toneFillClass gets an explicit neutral (muted) branch. - H4-2/H4-3: extract the buffered NumberField into shared settings-fields and wire it into the recording-profile, upload-policy, and watchdog cards; retention's optionalNumber routes through numericInputCommit. - R7-NUMCOMMIT-HEX: numericInputCommit gates on a decimal-shape regex. - R9: node/interface selects show a synthetic "(unavailable)" option for a stale id (mirrors withSelectedOption). - R6-INSTALL-URL-QUOTE: shell-quote every interpolated install-command value. - H2: nodeStatusLabel (Title Case) replaces raw status on dashboard, room-detail, and the nodes table. - S2: remove the dead schedulingDefaultsFrom export. - S3: defaultDraft validates operator defaults against available lists (threaded from schedules list + calendar via cached query data). - W2: TruncateCell effect depends on [children] (no per-render observer churn). - W3: nodes.tsx omits the Actions column entirely for read-only users. - W4A: withWatchdogDisplayDefaults folds display fallbacks into the draft so the watchdog form is what-you-see-is-what-you-save. - R6-ENROLL-DUP: invalidate ["nodes"] on enroll error too. Unit-tested where a seam exists (node-status, settings-updates, schedule-draft, node-page-helpers); render-only items verified via tsc + oxlint + build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Cap recorder-agent ipAddresses at the documented heartbeat limit R7-IP-AGENT-CAP: refactor collect_ip_addresses to a pure parse_ip_addresses helper bounded to MAX_IP_ADDRESSES (16, matching nodeHeartbeatSchema), so a well-behaved agent never emits a payload the controller must truncate. Pure Rust unit test (cap at 16, normal parse, empty). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Record post-convergence catalogued-entry cleanup in the gap-hunt ledger Document the disposition of the 2026-07-08 gap-hunt catalogue after convergence: 21 items fixed (each with a test where a unit seam exists), 6 deferred with rationale (render-harness-gated or product decisions, plus R7-SEED-LIVENESS as won't-fix). Records the two operator product decisions (offline tone -> Critical; switcher password-clear -> defer) and the green full-check gate at close. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A focused polishing pass over the operator console: consistent dialogs, tables that behave on every screen size, and better shared-component/token conformance. Web-only, no API/schema changes.
What changed
Dialogs — consistent form primitives
Field/Toggle/NumberFieldwere copy-pasted per file (one variant even used<span>+gap-1instead of<Label>). Consolidated onto the sharedsettings-fields.tsxField/Toggle, anchored the Save action inDialogFooter, and fixed the upload dialog's column misalignment (the empty-destinations note now spans full width). Net removal of duplication across the upload/retention/watchdog/recording-profile editors + node inventory.Tables — truncate, tooltip, and mobile cards
flex-wrap).TruncateCell(components/ui): truncates one line, measures its own overflow, and mounts a shadcn tooltip with the full text only when clipped. Used per-line in multi-linename + idcells and, viameta.truncateClassName, on single-value columns — each column capped at a width sized to its data (canonicalmax-w-*).DataTablerenders a card per row belowmd(full table atmd+). One shared change gives every list — nodes, schedules, jobs, health, audit, access, rooms, and all settings sections — a readable mobile layout: first column as title, rest as labelled fields, select/expander top-right, actions in a footer.Conformance + mobile tidy
toneTextClass()and routed the dashboard status icons + settings warning chip through it, replacing hardcodedtext-emerald-*/amber-*ladders.Verification
tsc --noEmit,oxlint --deny-warnings,oxfmt --check, and the 144 web unit tests all pass.Deliberately left out (rationale)
quality-timeline,node-healthbars,meter-bankglow) — intentional color scales/effects, not badge tones.NumberFieldconsolidation — the nullable vs required variants differ enough that a merged type would churn ~18 call sites for little gain (both already use the sharedField+Input).bg-black/10overlays, one-offStatCard, minorp-2/p-4spacing — low-value or intentional.Not yet driven through the full authenticated app on mobile (needs the Postgres/API stack); the mobile-card mechanism is validated in isolation and inherited by every page through the shared component.
🤖 Generated with Claude Code