Skip to content

Scoped session browsing + installation-location surfacing — design

Date: 2026-07-16 Status: Approved (design); pending implementation plan. Scope: Both frontends — admin dashboard (frontend/, admin.api.xylolabs.com) and operator app (frontend-app/, app.xylolabs.com) — plus one small backend DTO/query change.

Problem

Three related gaps in how operators and admins browse audio/metadata sessions and locate devices:

  1. No scoped session browsing. There is no way to select one device and step through its sessions, nor to select one installation (facility) and step through the mixed sessions of its various devices. In the admin app you can filter a list, but "opening" a session is a full-page jump with no next/prev and no per-device stepping. In the operator app the session list rows are not even clickable — there is no session detail page at all.
  2. Awkward width/layout when a session is open. frontend/src/pages/MetadataSessionDetailPage.tsx has no max-w/centering at its root, so on wide monitors the charts bleed edge-to-edge; and its charts grid is declared grid-cols-1 md:grid-cols-2 xl:grid-cols-3 while almost every card overrides itself to col-span-full, leaving lone 1/3-width "compact" cards sitting in dead space.
  3. Inconsistency across similar UIs. Admin uses a table + rich filters + a full-page detail; the operator app uses a flat, filter-less, non-clickable list with no detail. The device lists diverge too. Nothing shares a master-detail pattern.
  4. Installation location is buried. In the platform repo's canonical firmware/board1-v1/docs/FLEET.md, the headline per-device column is Location (e.g. "office bench (사무실, mac3)", "harbor site (HJNC)", "UNKNOWN"). In the web UIs, the admin device table shows a free-text location column but no facility, and the operator device cards show neither location nor facility (only a page-level facility subtitle). "Where a device is installed" is the single most important field and it is not prominent.

Domain grounding

  • "설치" (installation) = facility/site. In the server model this is facilities (e.g. 자일로랩스 본사 / xylolabs-hq, HJNC). A device belongs to exactly one facility via device.facility_id.
  • "설치 위치" (installation location) = where the unit physically sits. In the server this is the free-text device.location: Option<String> ("Free-text installation location, e.g. 'Building A / Line 3 / Pump 2'; operator-set via the admin device PATCH"). Combined with the facility name, this reproduces the FLEET.md Location column.
  • The server also has a separate fleet concept (fleet_stats.rs, FleetBenchmarkPage) that groups devices by hardware_version for z-score benchmarking. That is unrelated to this work and is left untouched.
  • The facility-map subsystem stores per-device x/y placements, but admin writes elements keyed by dongle_id while the operator map reads device_id — a real mismatch. Out of scope by decision; noted here only so a future map task can pick it up.

Decisions (locked)

  1. Interaction model: master-detail split. A scoped session list on the left; clicking a row opens that session's charts in a width-bounded column on the right; keep clicking down the list. Scope is switched by a top bar.
  2. Coverage: both apps. The operator session-detail pane is net-new.
  3. Location display: facility name (primary/bold) + free-text device.location (secondary). No map placement (map subsystem stays out).

Approach

One unified "Sessions" page per app — master-detail, mirrored structure, no shared code package. The two SPAs already relate this way: they independently mirror DevicesPage, AlertsPage, SettingsPage against a shared backend, each with its own API client, Zustand stores, i18n, and auth model. Consistency comes from this shared spec + a mirrored layout, not from a shared TypeScript library (a monorepo lib was rejected as far more costly than a UX refactor warrants). A minimal admin-only variant was also rejected because the locked decisions are master-detail + both apps.

Architecture

Unified Sessions page (both apps)

  • Route: /sessions.
  • Admin: replaces the /metadata list and /metadata/:id detail. Old routes redirect to /sessions (and /sessions?session=<id> for a deep-linked detail) so existing links/bookmarks survive.
  • Operator: replaces the flat SessionsPage at /sessions.
  • URL state (deep-linkable, back-button-safe): ?facility=<id>&device=<id|all>&status=<all|active|closed>&mode=<all|continuous|sampling>&session=<id>&page=<n>.
  • Layout shell: max-w-[1600px] mx-auto, split into a fixed-width master column (w-80 lg:w-96) and a flex-1 min-w-0 detail column.

Scope bar (top)

  • Facility selector: shown to multi-facility / super-admin users only (admin). The operator app is single-facility, so the facility is the implicit page context and no selector is shown. Backed by the existing facility-filter store in admin; by useFacilityId() in operator.
  • Device selector: an "All devices" option plus one entry per device. Option label = alias / dongle_id, with the device's location as a subtitle. "All devices" ⇒ facility-mixed stream; a specific device ⇒ device-scoped stream.
  • Status (all/active/closed) and Mode (all/continuous/sampling) filters; sort started_at desc.
  • All of the above map onto the existing endpoint GET /v1/metadata/sessions?facility_id=&device_id=&status=&mode=&page=&per_page=. No new list endpoint. Operator gets the full filter set for parity (it has none today).

Master (left) — scoped session list

  • Paginated (or incrementally loaded) list scoped by the current facility/device/status/mode.
  • Row: start time (primary) + device · streams · sample-count (secondary). In facility-mixed mode ("All devices"), each row carries a per-device color chip so mixed devices read at a glance.
  • Selecting a row sets ?session=<id> and loads the detail pane; the selected row is highlighted.

Detail (right) — session charts

  • flex-1 min-w-0, width-bounded by the shell — this is the width fix.
  • Content: session header + device-info + session-info panels + the stream charts.
  • Charts layout replacement (fixes the awkward grid): because the detail pane is already a bounded column, charts render as a clean single-column vertical stack (flex flex-col gap-4). Compact single-sample cards may be grouped into a grid grid-cols-2 sm:grid-cols-3 sub-row. The old grid-cols-1 md:grid-cols-2 xl:grid-cols-3 + per-card col-span-full pattern (and its lonely 1/3-width dead space) is removed.
  • Admin reuses its existing session-detail content (stream panels, audio, search-by-sound, time-range presets/custom) as the pane body.
  • Operator detail pane is net-new — it renders a historical session's waveform + sensor charts via the existing GET /v1/metadata/sessions/{id}/streams/{streamId}/data (and .../audio) endpoints, porting/reusing the operator app's chart components. This is the largest single piece of work.

Responsive collapse

  • lg: list + detail side by side.
  • < lg: list-only; tapping a row routes to ?session=<id> rendered as a full-screen detail with a "Back to list" control. Preserves the operator app's existing min-w-0 horizontal-pan fix in AppLayout.

Entry points ("select a device / an installation and open its sessions")

  • Device list row/card → "Open sessions"/sessions?device=<id> (device-scoped). Both apps.
  • Facility (admin) / the app's facility → "Open sessions"/sessions?facility=<id> with device=all (facility-mixed). Admin facility surfaces + the operator app's facility context.
  • Sessions nav item/sessions unscoped (facility-mixed default for the active facility).

Installation location in the device list

  • Admin DevicesPage table: the Location cell becomes two lines — facility name (bold/primary) + device.location (muted secondary), placed early in the column order for prominence. Facility name is already available client-side: DevicesPage.tsx:621 builds a facilityNameById map (D-IA-1) from an unconditionally-loaded facilities list, presently used only in a "quiet per-row info cluster" — this change promotes that name into the prominent Location cell → no backend change on admin. Unknown location renders an explicit "— unknown location —" placeholder.
  • Operator DeviceTile cards + DeviceDetailPage: add the free-text location prominently. Facility name is the page context and is not repeated per card.
  • Backend (the only backend change): add location: Option<String> to the operator FacilityOverviewDevice DTO and the /v1/facility/overview query, and to the operator DeviceDetail shape. Session endpoints already carry every filter needed.

Consistency deliverable

Both apps ship the identical master-detail shell, scope-bar semantics, responsive-collapse behavior, and detail width treatment. The pattern is documented briefly (in the app READMEs or a short docs/ note) so future pages conform.

Data flow

  1. Scope bar reads/writes URL params → React Query keys derived from {facility, device, status, mode, page}.
  2. Master list: GET /v1/metadata/sessions?... (existing).
  3. Selecting a row sets ?session=<id>.
  4. Detail pane: GET /v1/metadata/sessions/{id} + GET /v1/devices/{id} + per-stream GET /v1/metadata/sessions/{id}/streams/{streamId}/data (and .../audio for audio streams) — all existing.
  5. Device list location: admin joins facility_id → name from its already-loaded facilities list; operator reads the new location field from /v1/facility/overview.

Error handling

  • No ?session → detail pane shows a "Select a session" empty state.
  • 404 session (deleted / not in scope) → inline error in the detail pane; the list persists.
  • Facility/device with no sessions → empty state in the master column.
  • RBAC facility scoping is unchanged (super-admin sees all; facility-admin is scoped by resolve_facility_filter).
  • Selecting a device then switching facility clears an out-of-facility ?device/?session.

Testing

  • Frontend (per CLAUDE.md): Playwright viewport tests at mobile 375 / tablet 768 / desktop 1280 for both apps — verify master-detail renders side-by-side ≥lg, collapses to list+back below lg, the body never scrolls horizontally, the detail column is width-bounded, and there are zero pageerror events on all three viewports. Run npx tsc -b --noEmit and npx vite build in both frontend/ and frontend-app/.
  • Backend: cargo check + cargo clippy. Extend the metadata-sessions API tests to cover device_id × facility_id filter combinations. Add an assertion that FacilityOverviewDevice (and the operator device detail) include location.
  • Post-deploy (per CLAUDE.md): verify new bundle hashes on admin + app, confirm the three subdomains respond and the container is Up (healthy).

Build sequence

  1. Backend: add location to the operator FacilityOverviewDevice DTO + /v1/facility/overview query + operator DeviceDetail.
  2. Admin: unified Sessions master-detail page + scope bar; width/chart-grid fix; device + facility "Open sessions" entry points; two-line Location cell in the device table; /metadata/sessions redirects.
  3. Operator: mirror the Sessions master-detail — largest piece (net-new detail pane rendering historical session charts) + scope bar + location on device cards/detail.
  4. Consistency doc + the tests above.

Optional split: steps 1–2 + all location changes can ship as plan A (fast value), with the operator detail pane (step 3) as plan B, if landing incrementally is preferred over one feature.

Explicitly out of scope

  • The facility-map dongle_id-vs-device_id element-keying mismatch (map subsystem untouched).
  • The hardware_version fleet-benchmark feature.
  • Any change to session ingest, retention, or the metadata query endpoints beyond adding location to the two operator DTOs.