Compare commits

..
66 Commits
Author SHA1 Message Date
bonaminandClaude Opus 5.5 18705aaefb fix(docker): keep server Postgres on ./data/postgres, move Windows volume to an override
751cac7 switched the base compose file to an external named volume
(bellsystems-postgres-data) to dodge a WSL2/Docker Desktop bind-mount bug.
That volume only exists on the Windows dev machine: on the VPS, where the
push auto-deploys via deploy-host.sh, `docker compose up` would fail on the
missing external volume (or, if created, start Postgres on an empty
database while the real data sat unused in ./data/postgres).

The base file goes back to the ./data/postgres bind mount the server has
always used (Linux has no such bug). The named volume now lives in
docker-compose.override.yml, which Compose loads automatically, is
gitignored, and is created from docker-compose.override.example.yml on
Windows machines only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:45:58 +03:00
bonaminandClaude Opus 5.5 f1a7c5a7ca feat(devices): crash detail view and fleet Crash Report (F-070)
- CrashDetail (components/shared): headline is abort_msg when present,
  else the Xtensa exception name. For aborts, pc/exception/fault address are
  shown muted with a note that they describe abort() and that the first
  2-4 frames are abort internals. Shows task, uptime at crash, heap stats with
  a "likely heap exhaustion / fragmentation" badge (largest_block < 12000 or
  free_heap < 25000, F-055), PC + backtrace as 0x%08X in ESP serial format,
  a copyable addr2line command (PC, backtrace, then the PC parsed from
  "abort() was called at PC ..."), elf_sha256, and a backtrace-corrupted
  flag. When the server has the build's ELF and addr2line, a Decode button
  shows function + file:line per frame.
- Health tab boot history uses it, previews the headline in the row, and
  gets a "Sync from device" button (telemetry.get_boot_history, merged
  server-side).
- New /devices/crashes page (linked from the Fleet header): crashes grouped
  across devices with per-device counts and the latest occurrence.
- Firmware release modal: optional firmware.elf attachment.
- API reference: boot_report payload documents the F-070 fields.
- lib/crashReport.js holds the parsing/formatting rules; lib/clipboard.js
  a copy helper with a non-secure-context fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:28:41 +03:00
bonaminandClaude Opus 5.5 82cf95a9af feat(firmware): ELF symbol store for server-side crash decoding
Crash reports carry elf_sha256 (first 16 hex chars of the crashing build's
ELF SHA-256). ELFs are now stored under that key in
{firmware_storage_path}/elf/, uploaded either with a firmware release
(optional elf_file on POST /upload and PUT /{id}) or standalone via
POST /api/firmware/elf. The ELF is validated before the release is created.

GET /api/firmware/elf/{sha} reports whether that build's ELF exists and
whether the decoder is available; POST /api/firmware/elf/{sha}/decode runs
xtensa-esp32-elf-addr2line -pfiaC over the addresses. The decoder is looked
up via settings.addr2line_path or PATH. It is NOT in the Docker image yet,
so decoding reports "not installed" until the toolchain is added; the
console falls back to a copy-paste addr2line command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:28:41 +03:00
bonaminandClaude Opus 5.5 963516ece0 feat(mqtt): store F-070 crash detail and group crashes fleet-wide
Firmware F-070 adds a fuller `crash` object (backtrace, backtrace_corrupted,
elf_sha256) and a new `pre_crash` snapshot (uptime, heap stats, optional
abort_msg) to boot_report and to telemetry.get_boot_history entries.

- Migration c9d0e1f2a3b4: device_boot_events gains crash / pre_crash JSONB,
  stored verbatim so later additive fields need no migration. The legacy
  crash_* columns are still filled; old rows and old firmware are unchanged.
  JSON is bound as CAST(CAST(:x AS TEXT) AS JSONB): a bare JSONB cast makes
  asyncpg JSON-encode the already-encoded string a second time.
- boot_report ingestion passes both objects through.
- telemetry.get_boot_history replies (control/ack) are merged into boot
  history whoever sent the command: an entry matches an existing row on
  boot_count + reset_reason + time within 30 min (boot_count alone is not
  unique - the counter gets reset), and only fills crash/pre_crash the row
  lacks; unmatched entries are boots we never saw live and are inserted at
  the device's timestamp; entries without ts are skipped.
- GET /api/mqtt/crash-groups: fault boots grouped by abort_msg with hex
  addresses stripped, else task + exception cause, else reset reason. When
  abort_msg is present, pc/exc_cause describe abort() itself and are
  ignored for grouping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:28:40 +03:00
bonaminandClaude Opus 5.5 46c5c0a846 fix(mqtt): treat broker-replayed retained messages as stale state
The firmware publishes status/heartbeat, system/alerts, system/info and
status/playback with retain=true. On every backend (re)connect - every
restart and every uvicorn --reload - the broker replays the last message on
each of those topics for every device that ever connected. We handled those
replays as if they had just happened:

- heartbeats: a row with received_at=now() for every device, so devices
  that have been dead for months showed ONLINE for 90s after each restart
  and got pinged. ~770k such rows exist locally.
- boot_report: the last boot logged again as a new reboot (the phantom
  PANIC entries on the Health tab).
- alerts / other info events: logged again as new occurrences.

MQTT delivers retain=1 only for replays caused by a new subscription; live
publishes always arrive with retain=0. The flag is now passed through to the
handlers and the WS broadcast:

- heartbeat: replays are not stored. A live heartbeat is.
- {"state":"offline"} heartbeat (LWT / graceful disconnect) is no longer
  stored as a sign of life. It marks the device offline immediately in
  a small in-memory set (mqtt/presence.py) used by /mqtt/status and the ping
  loop; a later live heartbeat clears it. Replayed offline markers also mark
  offline, since a retained message is the device's last word.
- boot_report: live -> always a new boot. Replay -> stored only if it
  differs from the device's latest boot row (i.e. we missed it while down).
- alerts: replay still syncs the current-alert row; history gets a row on a
  live alert (even an identical repeat - faults recur) or on a replay that
  changes state. Replaces the 98dd16b rule that dropped identical live alerts.
- other info events: replays are not logged.
- Frontend (DeviceList, DeviceDetail, LogsTab) ignores retained WS messages
  for live updates, and flips a device offline on the offline marker instead
  of marking it online.

Verified locally after a backend restart: only the 7 actually-live devices
got new heartbeat rows (none from the replays), and no boot/alert/info rows
were created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 16:01:41 +03:00
bonaminandClaude Opus 5.5 18e57c6e5f fix(mqtt): dedupe boot_report against the latest row, not any boot_count
98dd16b skipped a boot_report if ANY earlier row had the same boot_count.
That is wrong: the firmware's lifetime boot counter gets reset (reflash /
telemetry reset), and the data shows counts 1-4 recurring in July and again
in September. With that rule a real later boot reusing a number would be
dropped forever.

A retained redelivery is always a copy of the device's most recent boot, so
compare only against the latest row (boot_count + reset_reason). A genuine
new boot always differs from it - the counter moves forward or was reset.

Note: the one-off cleanup run on 2026-09-30 used the same wrong
(serial, boot_count) key and deleted some genuine boot rows along with the
redelivery duplicates; see the session notes / heartbeat-based reboot list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:55:26 +03:00
bonaminandClaude Opus 5.5 98dd16b597 fix(mqtt): stop logging retained boot_report/alerts as new events
The firmware publishes boot_report on system/info and alerts on
system/alerts with retain=true. Every time the backend (re)connects - which
under uvicorn --reload means every backend file save - the broker redelivers
the last retained message and we inserted it again with occurred_at=now().
Result: the Health tab showed fresh PANIC boots and "Device reset due to
fault" alerts for a device that had been up for 4 days.

- insert_boot_event skips the insert when a row with the same
  (device_serial, boot_count) already exists. boot_count is the firmware's
  lifetime counter, so it uniquely identifies a boot.
- upsert_alert only writes when state/message actually changed and returns
  whether it did; the alert-event history row is only added on a change.
  A redelivered identical alert no longer bumps updated_at either.

Existing duplicate rows are not touched by this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:48:48 +03:00
bonaminandClaude Opus 5.5 36f9540a89 feat(users): require password confirmation when creating an app user
Both create paths (Users > Add User page and the CreateUserModal used during
device onboarding) now have a Confirm Password field. A mismatch shows an
inline error and blocks the create call, so a typo can't silently become the
user's Firebase Auth password. The confirm value is client-side only and is
never sent to the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:40:51 +03:00
bonaminandClaude Opus 5.5 c1df3b5aa3 fix(users): console-created users use the Firebase uid as doc ID
create_user wrote the profile with .add() (random doc ID). On first login the
FlutterFlow app looks for users/{uid}, doesn't find it, and creates a second,
bare doc - so every console-created user ended up duplicated, and devices
assigned in the console pointed at the doc the app never reads.

Now the profile is written to users/{uid} with created_time set, and the email
is lowercased to match what Firebase Auth stores. If the Firestore write
fails, the just-created Auth account is deleted so no orphan is left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:31:34 +03:00
bonaminandClaude Opus 5.5 3f3d2cc63c fix(ui): offline SignalIndicator is plain grey, no amber slash
The amber diagonal slash on offline devices was too loud on the Fleet list.
Offline now renders all arcs unlit in the neutral dim colour. The amber "?"
for online-but-no-rssi (legacy v1 firmware) is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:31:34 +03:00
bonaminandClaude Opus 5.5 e663b0a609 docs(mqtt-auth): record TLS for app users via NPM + WebSockets
TLS for the phone app went live on the VPS on 2026-09-30:
- Mosquitto got a second, non-published listener on 8083 with
  `protocol websockets`; port 1883 stays plain TCP for the boards
  (ESP32s can't spare RAM for TLS).
- The mosquitto service joined the external Docker network npm_npmnet so
  NPM (NPMplus) can reach mosquitto:8083 by name; it stays on `default`
  to reach the Console backend at 172.20.0.1:8000.
- NPM proxy host mqtt.bellsystems.net -> http://mosquitto:8083 terminates
  TLS and renews the Let's Encrypt cert. proxy_read/send_timeout 3600s
  added so NPM doesn't drop idle MQTT connections after 60s. NPMplus has no
  "Websockets Support" toggle (always on).
- Verified end to end: a paho client over wss://mqtt.bellsystems.net:443
  (path /mqtt) authenticated via the Console backend and received a
  heartbeat.

Chosen over native 8883 because NPM already owns 80/443 and certificate
renewal, so there is no extra cert handling on the host, and 443 also gets
through networks that block 8883.

The doc now gives the app's final transport (wss, 443, /mqtt, never 1883,
keepalive < 3600s), the listener/network/NPM layout, the end-to-end test,
rollback steps, and a new known gap: the backend's port 8000 is published
on 0.0.0.0, so the /mqtt/auth/* endpoints are reachable from the internet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:15:34 +03:00
bonaminandClaude Opus 5.5 61e9bfab7c docs(mqtt-auth): note old-firmware boards subscribe to vesper/{sn}/control
Broker logs after the 2026-09-30 restart show some boards (PV26B02BP01R01,
BSVSPR-26C20B-STD10R-2KCDPH) subscribing to vesper/{serial}/control rather
than control/command. The app ACL only allows publishing to control/command,
so the app can't command those boards until their firmware is updated.
Recorded so nobody widens the ACL by accident.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:00:43 +03:00
bonaminandClaude Opus 5.5 43102c617d docs(mqtt-auth): note VPS broker has allow_anonymous false but no TLS listener yet
The full mosquitto.conf (2026-09-30) has a single plain listener on 1883,
used by the boards. The phone app sends a Firebase ID token as its MQTT
password, so a TLS listener must be added before app users go live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:58:59 +03:00
bonaminandClaude Opus 5.5 ecfe00307a docs(mqtt-auth): record verified VPS broker setup and ACL isolation test
Checked the live broker on 2026-09-30:
- mosquitto.conf sets no files-backend ACL path. A live test (device A
  subscribing to device B's topics) was denied while A's own topics were
  delivered, so the files backend does not grant-all and every ACL check
  reaches the Console. The missing ACL file is therefore harmless.
- Recorded the container/image, config and passwd locations, which lines
  already match the new code, and the one change still pending
  (auth/acl cache 300s -> 60s before app users go live).
- Added the copy-paste isolation test so it can be re-run after any broker
  config change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:57:01 +03:00
bonaminandClaude Opus 5.5 6ea7e9d2c5 docs: MQTT app-user auth reference + CLAUDE.md pointer
docs/mqtt-app-user-auth.md records what future sessions (and whoever builds
the phone app) need and can't get from the code alone:
- app connection contract: username app_<uid>, Firebase ID token as
  password, client id prefix app_<uid>_, TLS only, allowed topics per acc,
- serial field (serial_number, legacy device_id) and the device_serials
  mirror + every code path that must keep it in sync,
- the uid-field lookup rule and ACL cache invalidation,
- legacy "vesper" password flag and its log line,
- rollout checklist: backfill, go-auth VPS config (required/recommended),
  files-ACL check, TLS listener,
- decisions/gaps: FlutterFlow must sync device_serials itself; the
  device_users subcollection is intentionally ignored.

CLAUDE.md gets a short section pointing agents at it before they touch
MQTT auth or anything that edits user_list/status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:45:18 +03:00
bonaminandClaude Opus 5.5 f5db83f26c fix(devices): sync users.device_serials when PUT /api/devices/{id} changes user_list
DeviceUpdate accepts user_list, so a device PUT could add or remove users
without touching their device_serials - leaving the MQTT app ACL stale
(a removed user would keep access; an added user would be denied).

update_device now diffs the old vs new user_list and, in the same atomic
batch as the device write, ArrayUnion/ArrayRemoves the device's serial on
each added/removed user, then invalidates their MQTT ACL cache entries.
Dangling user references are skipped (updating a missing doc would fail
the whole batch). PUTs without user_list take the old single-update path.

Covered by tests/test_device_serials_sync.py (fake Firestore).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:44:34 +03:00
bonaminandClaude Opus 5.5 3acd89a2c6 test(mqtt-auth): cover /mqtt/auth/user and /mqtt/auth/acl
First pytest suite in the backend (backend/tests/, run from backend/ with
`python -m pytest tests`). firebase_admin.verify_id_token and Firestore
are mocked, so no network access is needed. 82 cases:

- /user devices: HMAC ok / wrong / other serial's HMAC, kiosk HMAC,
  legacy password with flag on/off, legacy rejected for non-device-shaped
  usernames and for app_ users, HMAC rejected for app_ users, legacy-login
  log rate limiting.
- /user app users: valid token (asserts check_revoked=True), token for a
  different uid, revoked, expired, blocked user, unknown uid, empty uid,
  and that a denied token never appears in logs.
- /acl app users: acc 1/2/4 allow/deny per topic, unsupported acc values,
  wildcards, foreign serial, malformed topics, wrong clientid prefixes
  (incl. uid-prefix collision), blocked/unknown users, cache hit +
  invalidate, cache expiry.
- /acl devices/kiosk/superuser: unchanged behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:04:37 +03:00
bonaminandClaude Opus 5.5 81365eed89 feat(mqtt-auth): put legacy "vesper" password behind MQTT_ALLOW_LEGACY_PASSWORD
The shared legacy password is still needed for boards on pre-HMAC
firmware, but it was accepted for any username. Now:
- controlled by MQTT_ALLOW_LEGACY_PASSWORD (config.py, default true;
  documented in .env.example) so it can be switched off without a deploy,
- only accepted for device-shaped usernames (uppercase alphanumeric
  segments joined by "-", optional "-kiosk"), never for app_ users or
  any other shape,
- every successful legacy login is logged at WARNING with the username,
  rate-limited to once per username per hour, so the boards still
  depending on it are visible before the flag is turned off.

HMAC auth is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:03:23 +03:00
bonaminandClaude Opus 5.5 1253ec155e feat(mqtt-auth): per-device topic ACL for phone-app users
POST /mqtt/auth/acl now handles "app_<uid>" users:
- topic must be exactly vesper/{serial}/<a>/<b> with serial in the user's
  device_serials (resolved by the users doc `uid` field),
- publish (acc 2): only control/command,
- subscribe (acc 4) and read/delivery (acc 1): only control/ack,
  status/heartbeat, status/playback,
- wildcard topics (+ / #) are denied,
- clientid must start with "app_<uid>_" so one user cannot reuse another
  user's client id to kick them off,
- blocked users are denied; anything else (incl. other acc values) is 403.

Lookups go through mqtt/app_users.py's 60s TTL cache so per-message
checks don't hit Firestore every time; assign/unassign/block invalidate it.

Also fixes the acc comment: mosquitto passes 1 = read (delivery),
2 = write (publish), 4 = subscribe - not "1 = subscribe, 3 = both".
Device/kiosk ACL is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:02:48 +03:00
bonaminandClaude Opus 5.5 8a668ca60f feat(mqtt-auth): authenticate phone-app users with Firebase ID tokens
The remote FlutterFlow app connects to Mosquitto as "app_<firebase_uid>"
with a Firebase ID token as the password, so no per-user MQTT accounts
need to exist anywhere.

For app_ usernames, POST /mqtt/auth/user now:
- verifies the token with firebase_admin.auth.verify_id_token
  (check_revoked=True),
- requires the decoded uid to equal the uid in the username,
- requires a users doc with that `uid` field (queried, not by doc id)
  whose status is not "blocked" (same meaning as users.service.block_user).
It returns 200/403 and logs the deny reason - never the token.

app_ usernames never fall through to the HMAC / legacy "vesper" check.
Device and kiosk auth are unchanged. App users are still denied every
topic by the existing ACL until the app ACL lands in the next commit.

Both handlers are now plain `def` so the blocking Firestore / Firebase
calls run in FastAPI's threadpool instead of stalling the event loop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:02:21 +03:00
bonaminandClaude Opus 5.5 85bd2f7e51 feat(scripts): idempotent backfill of users.device_serials from devices.user_list
One-off script to populate the new device_serials array for assignments
made before the assign/unassign sync existed. Treats each device's
user_list as the source of truth and sets every user's device_serials to
exactly the matching serials, so re-running it is a no-op.

Dry run by default; --apply writes (batched, <=400 per commit).

It also reports users whose doc ID != uid field (MQTT resolves users by the
uid field), users with no uid, devices with users but no serial, and
user_list entries pointing at non-existent users. user_list entries are
accepted as DocumentReferences, "users/{id}" paths or raw doc IDs, and an
entry that matches a uid field rather than a doc ID is mapped to its doc.

Dry run against current data: 13 devices, 13 users, 11 users to update,
0 doc-ID/uid mismatches, 1 dangling user_list entry
(Cx2Va72sUzDbr1T8Ebbh on BSVSPR-26I047-STD10R-88YFJP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:01:36 +03:00
bonaminandClaude Opus 5.5 79a87e48f1 feat(users): store device_serials on user docs for direct user->device lookup
Adds a `device_serials: [string]` array to Firestore `users` docs so the
MQTT ACL (and get_user_devices) can answer "which boards may this user
reach?" without streaming the entire devices collection.

- The serial is the value used in MQTT topics vesper/{serial}/...: the
  device doc's `serial_number` (flashed into NVS, used by the firmware as
  its MQTT id), falling back to the legacy `device_id` for old docs.
  Centralised in users.service.device_serial_of().
- assign_device / unassign_device now write the device's user_list and the
  user's device_serials (ArrayUnion/ArrayRemove) in one atomic batch.
- The device Manage tab endpoints (POST/DELETE /api/devices/{id}/user-list)
  also edit user_list, so they get the same batched sync - otherwise the
  most common assignment path would silently leave device_serials stale.
- get_user_devices resolves devices via device_serials with chunked
  Firestore "in" queries instead of a full collection scan. Requires the
  backfill script (next commit) to be run for existing assignments.
- New mqtt/app_users.py: resolves users by the `uid` FIELD (not doc id -
  create_user uses .add(), FlutterFlow uses uid as doc id) with a 60s
  in-process TTL cache. Assign/unassign, update, block/unblock and delete
  invalidate that uid's entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 00:00:49 +03:00
bonaminandClaude Opus 5.5 017911bece docs(api-reference): rf.* is agnus-only — disabled on Vesper Standard (firmware F-069)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:54:12 +03:00
bonaminandClaude Opus 5.5 c36e4c41f8 docs(api-reference): loading playback state and playback_failed report (firmware F-068)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:25:38 +03:00
bonaminandClaude Opus 5.5 8b229e7fb3 docs(api-reference): list every playback.play/stop reply and error string (firmware F-067)
Replaces the generic "Playback command failed" with the firmware's specific
messages, documents "Already stopped" as SUCCESS, url no longer sticky,
and the stricter field validation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:18:13 +03:00
bonaminandClaude Opus 5.5 9d9b46056a docs(api-reference): status/playback is republished on every MQTT connect (firmware F-066)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:11:20 +03:00
bonaminandClaude Opus 5.5 adec979182 docs(api-reference): document the "stopping" playback state (firmware F-065)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:10:03 +03:00
bonaminandClaude Opus 5.5 99baec9fcf docs(api-reference): document new status/playback fields (firmware F-064)
pid, speed, duration, started_at, source, ts on status/playback (and the
same fields on the WebSocket playback INFO event), plus how to handle a
stale retained message and when the epoch fields are 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:09:16 +03:00
bonaminandClaude Opus 5.5 c56344a6dc docs(api-reference): sync with firmware handler audit — playback, rf namespace, clock/ota/files fixes
Mirrors the firmware repo's api-reference.md audit (project-vesper
docs/reference/api-reference.md):

- playback.play: correct field semantics (url not download_url, speed boot
  default 500 / 0→300, note_assignments value = 1-based bell, sticky fields,
  legacy total_duration ignored without continuous_loop); playback.stop has
  no error path.
- UART whitelist now matches UARTTransport::WHITELIST (5 raw cmd strings).
- Add the missing rf namespace (set_slot/clear_slot/get_slots/enable) with
  every error string.
- Fix clock.set_alerts (camelCase), clock.set_silence (nested), clock.set_time
  offsets, relay.test_bell missing error, ota.update default channel,
  ota.set_channel description, files.download download_url, list_builtin
  "pid" key, logs.list extra error, telemetry boot-history limit=0,
  diagnostics topic, network.set_config AP-mode messages, ping data.type.
- Legacy map now matches LegacyAdapter's tables; message-format card and
  req_id notes reflect MQTT-only req_id and per-transport "v" defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:02:58 +03:00
bonaminandClaude Sonnet 5 0c0dd9d0e9 fix(devices): signal glyph polish, fix invisible copy button, even firmware row
Follow-up on 529e866 after review:

- SignalIndicator: offline now renders a diagonal slash (standard
  "no signal" convention) instead of an X. Added a distinct amber "?"
  state for devices that are online but whose firmware never reports rssi
  at all (legacy v1 heartbeats predate the rssi/state/ok fields — see API
  Reference's v2 migration notes) — previously this looked identical to
  "not loaded yet". DeviceListCardView's online block now opts into this
  by passing isOnline explicitly.

- Copy-to-clipboard for the serial number was invisible: the icon button
  had opacity:0 as an inline style, which beats a CSS hover rule at equal
  specificity, so the reveal-on-hover code paths never fired. Replaced
  with a shared CopyableSerial component (components/shared/) where the
  whole serial text is clickable, not just a trailing icon, and the icon
  sits at partial opacity at rest instead of fully hidden. Wired into both
  DeviceDetail's header and OverviewTab's hero Serial Number field.

- GeneralTab firmware hero: all stat columns are now equal-width via a
  grid instead of ad-hoc flex gaps, and backup_version's "Unknown" sentinel
  (the device's own placeholder when no second OTA slot has ever been
  flashed) is no longer rendered as the literal string "vUnknown".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:45:01 +03:00
bonaminandClaude Sonnet 5 529e866fff fix(devices): offline signal glyph, plus firmware UI across Device Detail tabs
SignalIndicator now takes an isOnline prop — when a device is offline it
renders a dimmed amber glyph with an X instead of showing the last-known
(possibly full-bars) RSSI reading, which was misleading on the Device List
table view. Card view already special-cased offline devices and is
unaffected.

Also, while working the device details surface:
- DeviceDetail: hover-to-reveal copy button next to the serial number
  subtitle in the page header.
- OverviewTab hero: swapped the redundant "Location" field (already shown
  in detail on the General tab's map) for live Firmware Version.
- GeneralTab: new full-width, compact Firmware hero row (version, channel,
  validation state, boot count, backup slot) sourced from firmware.status.
- ControlTab: max width brought in line with every other tab (2000, was
  1400), and added a 4th "Firmware" section with channel selection,
  Update Now, custom-URL flash, and commit/rollback controls, wired to the
  firmware.status / ota.* / firmware.commit / firmware.rollback commands
  documented in the API Reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:47:10 +03:00
bonaminandClaude Sonnet 5 c8ac0b78c5 docs(api-reference): document req_id, rework Transports/Legacy tabs, fix playback.play fields
req_id (added 2026-09-21 in the v2 topic rebuild, F-062):
- New Extras tab documents it fully: envelope-level (sibling of cmd,
  not inside contents), always optional, exact echo/reply behavior,
  parse-failure edge case, and an MQTT-only gap — WebSocket and HTTP
  transports don't actually read or echo it despite CommandBus
  supporting it generically at the bus level.
- Every command's Contents section now carries a persistent note
  pointing to Extras, instead of duplicating the explanation 40+ times
  or only mentioning it in one Transports card.
- Removed the old "req_id Correlation" card from the Transports tab —
  superseded by the Extras tab.

Transports tab: dropped the V2/Legacy sub-tab switcher. It only ever
showed a "not documented yet" placeholder for Legacy, and the legacy
topic set belongs with the rest of the migration reference, not
alongside the current transport list.

Legacy Migration tab (renamed from "v1 → v2 Migration"): added a
"Legacy Topic Migration" table ahead of the command migration table,
mapping every MQTT topic from the old pre-rewrite firmware
("Controller - Production FW") to its v2 equivalent — including three
v2 topics (system/alerts, system/info, system/metrics) that have no
legacy predecessor at all. Reconstructed from that firmware's source;
it has been fully replaced, so this is historical reference only.

playback.play: filled in the full contents field set per the current
Player.cpp implementation — segment_duration, pause_duration,
total_duration, and continuous_loop are the legacy (no "mode") path,
while duration is the v2 path read only when mode is present. Added a
warning callout since sending mode alongside the legacy duration
fields doesn't merge behavior — only one path is read, based solely on
whether mode is present. This is intentional: mode is how a v2 caller
opts in, and its absence is how a v1 caller's request still works.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:35:20 +03:00
bonaminandClaude Sonnet 5 80c0e955f0 fix(devices): zero out unassigned bell outputs when saving Bell Mechanisms
relay.set_config is a partial update on the firmware side — any bell
index not present in the durations/outputs maps is left completely
untouched. EditBellOutputsModal and OnboardDevice's bell config step
both only sent entries for the currently-active bell count, so
deselecting a bell (lowering the active count) never actually
deactivated its output on the device — it stayed wired to whatever it
was last set to. The only way to disable a bell was to reselect it and
explicitly set it to "Disabled", which isn't obvious.

Both now send all maxOutputs slots on every save, forcing output to 0
for anything beyond the active count, so lowering the active bell
count reaches the firmware the way it visually appears to in the UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 20:35:03 +03:00
bonaminandClaude Sonnet 5 ae5da6046f docs(CLAUDE): require every change to be documented and committed
Adds a Documentation & Commit Policy section and a Golden Rules entry
requiring every change, however small, to be committed with a
descriptive message explaining why — so project history stays a
reliable record of what happened over time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:36:56 +03:00
bonaminandClaude Sonnet 5 aad5f4b98c chore(strategies): add Health Monitor standalone design mockup
Throwaway HTML prototype for the device Health tab's layout/visual
direction, kept alongside the other planning docs in strategies/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:36:50 +03:00
bonaminandClaude Sonnet 5 751cac72ce fix(docker): move postgres data to a named volume, off the WSL2 bind mount
The ./data/postgres bind mount hit a WSL2/Docker Desktop bug where the
9p/virtiofs bridge reports normal postgres:postgres 0600 ownership but
the kernel still refuses the postgres user's own open() calls for
write — silent on read, fatal on any WAL write, which took the whole
database down after an unclean shutdown. Switches to a Docker-managed
named volume (bellsystems-postgres-data) that lives inside the WSL2
VM's own filesystem instead. The old bind-mounted data is left in
place at ./data/postgres/ as an untouched backup — that folder was
too wedged by the same bug to even rename, so it's abandoned rather
than deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:36:28 +03:00
bonaminandClaude Sonnet 5 cef5e1f0f5 chore(docker): restart backend, frontend, and nginx unless stopped
Matches postgres, which already had restart: unless-stopped — the app
containers were the only ones that didn't come back automatically
after a host reboot or Docker Desktop restart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:36:20 +03:00
bonaminandClaude Sonnet 5 f036000769 feat(manufacturing): extract shared useSerialConnection hook for provisioning wizard
WebSerial port ownership was previously held inside StepFlash, which
made it awkward for other steps (StepVerify) to read from the same
port without fighting over exclusive access. useSerialConnection
centralizes port open/close/read/write so the wizard's steps share one
connection lifecycle instead of each managing their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:35:31 +03:00
bonaminandClaude Sonnet 5 d87925516a feat(devices): add ResetStatsRequest/ResetStatsResult models
Missed from the previous Reset Stats commit — these are the
request/response schemas for POST /devices/{id}/reset-stats.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:35:20 +03:00
bonaminandClaude Sonnet 5 f54500a9d9 feat(devices): add Reset Stats action for clearing pre-ship QA/bench data
New POST /devices/{id}/reset-stats + ResetStatsModal lets staff clear
QA/bench test data accumulated before a device ships to a customer:
Firestore bell/playback counters and Postgres history tables (logs,
heartbeats, commands, boot events, alert events, ping samples,
diagnostics reports, control/reports, and opt-in live alerts). The two
firmware-side resets (telemetry.reset_boot_data, logs.clear) go over
the existing MQTT command/ack flow instead, since they need the device
online and duplicating that round-trip server-side would just be a
second, inferior implementation.

Also includes two incidental cleanups in devices/router.py: audit-log
entries for create/update now use console_name in their label (missed
by the earlier console_name commit), and add/remove-device-user rename
their local Firestore client from `db` to `fs` to stop shadowing the
`db: AsyncSession` dependency param in the same function scope.
AddDeviceUserModal's results list also gets a max-height + scroll so a
long match list doesn't grow the modal off-screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:35:01 +03:00
bonaminandClaude Sonnet 5 4737ab4306 feat(devices): add device onboarding wizard
New guided flow (Bell Cloud > Devices > Onboard) for claiming a
manufactured/flashed device into a customer's fleet in one pass:
looks up the device by serial, finds or creates the customer
(CreateCustomerModal for a quick inline create), finds or creates the
app user account (reuses CreateUserModal from the user-creation work),
and assigns the device — replacing what used to require jumping
between the inventory, CRM, and user-management pages separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:34:32 +03:00
bonaminandClaude Sonnet 5 6686faad6b feat(manufacturing): enrich inventory list with customer/user details and filters
The inventory list and detail page previously only showed raw
customer_id / user_list references. list_devices now batch-resolves
customer names and user display names/emails (via Firestore get_all(),
avoiding an N+1 round trip per device) and exposes them as
customer_name / users on DeviceInventoryItem. Search now matches
against device name, console name, customer name, and assigned users
in addition to serial/owner/batch.

Also adds hw_types (multi-select board type) and has_users filters to
GET /manufacturing/devices, and carries console_name through for
display. DeviceInventoryDetail is updated to show and use all of this
(customer name, assigned users, new UI components for signal/charts).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:34:08 +03:00
bonaminandClaude Sonnet 5 b07bdafd85 fix(header): resolve breadcrumb labels for devices and melodies correctly
- devices: breadcrumb now falls back through console_name -> device_name
  -> device_id, matching every other device display label in the console
- melodies/archetypes: breadcrumb was reading a bare d.name field that
  doesn't exist on the melody schema (name lives at
  d.information.name.<locale>), so these breadcrumbs always showed blank.
  Now uses getLocalizedValue like the rest of the melodies UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:33:55 +03:00
bonaminandClaude Sonnet 5 dff1a35560 feat(settings): add log retention settings page
Sysadmin/admin-only settings page for configuring how long device log
history is kept before pruning. GET/PUT /api/settings/log-retention,
new LogRetentionSettings page, nav entry, and route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:33:40 +03:00
bonaminandClaude Sonnet 5 acd1896788 feat(devices): add Health tab, log explorer, and fleet health indicators
Surfaces the new device-health telemetry (boot events, ping RTT,
diagnostics reports, alert events) across the console:

- HealthTab: boot/diagnostics timeline, CPU temp and RSSI charts
  (LineChart), current alert status, and a Settings sub-tab for
  DeviceHealthSettings thresholds
- LogsTab: dedicated log explorer embedded in the Health tab, with
  level/source filtering and fmtLogTimestamp for dense timestamp rows
- OverviewTab: a "Latest Device Issue" card showing the most recent
  alert event, colour-coded by severity and fading with age, plus a
  modal to inspect the surrounding log lines
- DeviceList/DeviceListCardView/DeviceListMapView: fleet list gains a
  SignalIndicator-based RSSI display in place of the plain online dot

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:32:06 +03:00
bonaminandClaude Sonnet 5 0d79a9f72c feat(users): support creating app users with a Firebase Auth account
New users can now be created with a password, which creates a real
Firebase Auth account (so they can log into the mobile app immediately)
alongside the Firestore profile document. UserCreate is now
UserProfile + password (request-only, never persisted or echoed back);
deleting a user also removes their Auth account.

- backend/users: split UserCreate into UserProfile (persisted shape)
  and UserCreate (adds password), wire firebase_auth create/delete
- CreateUserModal: new lightweight modal for creating a user from
  other flows (e.g. device onboarding) without leaving the page
- UserForm: adds the password field for new users; also fixes
  useToast() being used undestructured (toast.success(...) was being
  called on the hook's return value instead of its .toast method)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:31:37 +03:00
bonaminandClaude Sonnet 5 6e0a291228 feat(devices): add console-only display name (console_name)
Lets staff set a friendly name for a device independent of whatever
name the end user gave it in the app. console_name is never shown to
app users and never synced from/to device_name; every display label
across search, equipment/helpdesk name resolution, device search, and
the Manage tab's issue linker now falls back through console_name ->
device_name -> serial rather than device_name alone.

Also includes an incidental one-line fix in devices/service.py: the
nested-struct deep-merge in update_device() was missing the newly
added device_health_settings key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:31:10 +03:00
bonaminandClaude Sonnet 5 8ec13947cb fix(devices): restore warranty/maintenance dates missing from timestamp field list
subscrStart, warrantyStart, and maintainedOn are written to Firestore as
Timestamps like the other date fields here, but were missing from
_TIMESTAMP_FIELD_NAMES, so they weren't being converted back to ISO
strings on read.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:30:15 +03:00
bonaminandClaude Sonnet 5 0bec4c4648 feat(devices): add DeviceHealthSettings model for per-device alert thresholds
Two-tier (warning/critical) threshold config per device — restarts/week,
RSSI floor, free-heap floor, CPU temp ceiling, plus an offline timeout
used for both a client-computed health status icon and the Health tab
chart's gap detection. Purely advisory for now: no server-side
email/push alerting infra exists yet, so email_on_threshold and
push_on_crash_boot are placeholders for that future work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:27:35 +03:00
bonaminandClaude Sonnet 5 cc138e3d82 feat(devices): push General/Clock/Bells/Alerts config live over MQTT
Adopts useDeviceCommand across DeviceDetail and its tabs so config
changes (log levels, clock settings, bell outputs, alert thresholds,
backlight, attributes) are sent to the device immediately via
control commands and only persisted to Firestore once the device
acks success, instead of writing Firestore first and hoping the
device eventually picks it up.

- GeneralTab: log-level sliders now call log.set_serial/sd/mqtt
  directly and revert on failure; a background log.get_config +
  network.info pull reconciles Firestore against the device's actual
  state once per mount
- ClockTab, BellsTab, ControlTab, and the Edit* modals: same
  live-command-then-persist pattern
- EditLoggingModal is removed — its job (log level editing) moved
  inline into GeneralTab's sliders, so a modal round-trip is no
  longer needed
- DeviceDetail wires the shared useDeviceCommand connection through
  to each tab and adds a Health tab entry

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:25:30 +03:00
bonaminandClaude Sonnet 5 7c533b9245 feat(mqtt): add device health telemetry and migrate to v2 topic spec
Two efforts that landed together because the v2 topic work extends
tables the health-telemetry effort added days earlier in the same
files/functions, making them impractical to separate cleanly:

Health/diagnostics telemetry (schema, Jul 13-17):
- New Postgres tables: device_alert_events, device_boot_events,
  device_ping_samples, device_diagnostics_reports, plus a `source`
  column on device_logs to distinguish log origins
- Query/service layer in pg_mqtt.py and database/__init__.py for
  inserting and listing this history, plus a "latest metrics" endpoint
  combining most-recent diagnostics + ping RTT per device
- mqtt/router.py gains list endpoints for alert/boot/ping/diagnostics
  history, consumed by the upcoming Health tab

MQTT v2 topic migration (Sep 21):
- Heartbeat payload flattened per vesper_mqtt_topic_spec_v2.md, adding
  rssi/free_heap/state/ok fields
- Command replies move to control/ack, device-initiated events to
  control/reports; mqtt/client.py subscribes to the new topic set and
  runs a ping_loop (wired up in main.py) for RTT sampling
- mqtt/logger.py and pg_mqtt.py updated to parse and persist the new
  payload shape alongside the legacy fields for backwards compatibility

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:24:36 +03:00
bonaminandClaude Sonnet 5 e5d556fee1 feat(devices): extract useDeviceCommand hook for shared MQTT command/ack flow
Pulls the send-command-and-await-ack machinery out of DeviceDetail.jsx
into a reusable hook, so other pages (the onboarding wizard, etc.) can
send a device a command and await its control/ack reply the same way,
with a live-updating toast for non-silent commands.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:22:48 +03:00
bonaminandClaude Sonnet 5 38750ba951 fix(scrollbar): theme scrollbars inside portal-rendered elements
Scrollbar theming was scoped to .app, so anything rendered outside it via
createPortal(..., document.body) — Modal, Select/MultiSelect's floating
menu, DataTable's column-visibility picker — fell back to the browser's
default light-on-dark scrollbar. Extends the same scrollbar-color rules
to .modal, .select-menu, and .dt-col-picker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:22:30 +03:00
bonaminandClaude Sonnet 5 7f6145ca35 fix(header): match header glass tint to sidebar, add drop shadow
.header used a lighter 0.30 background tint than .sidebar's 0.40, so
scrolled content underneath showed through almost undimmed even though
backdrop-filter was applied — the two fixed chrome surfaces no longer
read as one consistent glass layer. Aligns the tint and adds a shadow
to give the header separation from content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:22:23 +03:00
bonaminandClaude Sonnet 5 2f7c5e859d feat(ui): add MultiSelect, LineChart, SignalIndicator, PressHoldButton, EditableText, TimeRangeSelect
Six new design-system components, all documented with live examples in
the StyleGuide as required by CLAUDE.md before any page can use them:

- MultiSelect: checkbox dropdown for filters/tags, built on Select's
  trigger/menu styling
- LineChart: telemetry/time-series charting for the upcoming Health tab
- SignalIndicator: signal-strength glyph for device RSSI display
- PressHoldButton: press-and-hold confirmation for destructive actions
- EditableText: inline click-to-edit text with a hover/focus-revealed
  pencil affordance
- TimeRangeSelect: preset + custom time-range picker, backed by the new
  lib/timeRange.js helper

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:22:02 +03:00
bonaminandClaude Sonnet 5 883c0556dd feat(ui): add pending/update toast state and shared floating-menu positioning
Toast gains a pending/update lifecycle so async actions can show a single
toast that transitions from in-progress to success/error, instead of
firing a new one. Select's floating-menu placement logic is extracted
into a shared helper so other dropdown-style components (MultiSelect)
can reuse it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:20:26 +03:00
bonaminandClaude Sonnet 5 1ce0137ba9 feat(mqtt): implement useMqttWebSocket hook
Was previously a dead stub (TODO: implement). This real implementation
is the foundation for live heartbeats, ping RTT, and the device command
ack flow that later commits build on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 18:20:15 +03:00
bonaminandClaude Sonnet 5 eaf92f1581 docs(api-reference): add MQTT v2 topic spec, split Transports into V2/Legacy
Firmware rebuilt its MQTT topics to v2 (control/command+ack+reports,
status/playback, system/alerts+info+logs+metrics, LWT, req_id, per-topic
switches — see project-vesper's docs/reference/vesper_mqtt_topic_spec_v2.md
and feature-catalog.md F-062). Mirrors that in the console's API Reference:

- Transports tab now splits into V2 (fully documented: topic table with
  per-topic switches, req_id correlation, heartbeat/playback/reports/
  alerts/boot-report/metrics payload cards) and Legacy (placeholder,
  to be filled in later)
- mqtt namespace gains mqtt.get_topics / mqtt.set_topics command docs
- mqtt.disable description updated for its new graceful-offline behavior
- Message envelope docs (top of page) note the new optional req_id field

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 10:49:02 +03:00
bonaminandClaude Sonnet 5 cc28ed880b docs(ApiReferencePage): document system.set_identity console command
Firmware side added an MQTT-only sysadmin command to override a
device's factory-set identity (serial/hw_family/hw_revision) — see
project-vesper commit 64a8bc2. Per docs/README.md's console-sync
rule, mirrors the same command into the console's live API Reference
page.

Note: this repo had substantial other pending uncommitted work
(diagnostics reports, boot history, log retention, etc.) in this
same file and elsewhere at the time of this commit — left untouched
and still uncommitted, only the system.set_identity hunk is included
here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-20 20:11:26 +03:00
bonamin 44609352b2 docs(ApiReference): document logs.list/logs.download and extended firmware.status
Mirrors firmware changes: new SD log retrieval commands on LoggingHandler
(logs.list, logs.download — chunked/paginated for offline-device
troubleshooting), and firmware.status now returns OTA rollback safety-net
diagnostics (retry/failure counts, backup partition info).
2026-07-15 00:06:26 +03:00
bonaminandClaude Sonnet 4.6 70e0f9f734 docs(api-reference): add heartbeat payload card with rssi field
Adds a dedicated Heartbeat Payload card to the Transports tab showing
the full JSON schema with per-field descriptions, including the new rssi field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 02:14:53 +03:00
bonaminandClaude Sonnet 4.6 a9b3b4e9b0 feat(ApiReference): redesign network.info and network.status documentation
network.info: updated description and response example to reflect new fields
(subnet, mac, hostname, ssid, connection_type). Notes it as the command to use
for console panel population.

network.status: updated to show slimmed response (connected, ap_mode, rssi,
uptime_ms, state). Notes it as the command for monitoring/polling, not display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 00:53:42 +03:00
bonaminandClaude Sonnet 4.6 65ce446e44 fix(ApiReference): clock.get_config — add timezone fields, fix backlight/bell docs
- clock.get_config response example now includes gmt_offset_sec, dst_offset_sec, ntp_server
- Description updated to note it supersedes clock.get_timezone
- clock.set_backlight field names corrected to match wire format
  (backlight, backlight_output, backlight_on, backlight_off)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:38:21 +03:00
bonaminandClaude Sonnet 4.6 bff90965bf feat(ApiReference): add clock.set_config, clock.set_alerts_config, relay.set_config entries
Document three new batch commands with full response/errors/example fields:
- relay.set_config: combined bell durations + outputs
- clock.set_config: clock hardware settings (enabled, c1, c2, timings)
- clock.set_alerts_config: alert type, bell assignments, silence windows

Also updated output field notes throughout clock and relay commands to reflect
the enforced 1-based output numbering convention (0 = disabled, 255 = unconfigured).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:45:12 +03:00
bonaminandClaude Sonnet 4.6 3394716f8f docs(ApiReferencePage): full v2 API reference update
Fixes and additions across all namespaces to accurately reflect firmware v2.1.0:

Fixes:
- system.get_settings → system.get_config (backward compat alias noted)
- network.status returns now includes ssid, mac, hostname
- clock.set_alerts: correct alert_type values (OFF|SINGLE|HOURS, not none|hour|quarter)
- ota: removed non-existent ota.status and ota.check commands
- telemetry.get_loads returns now includes max_loads and guard_enabled

Additions:
- relay.test_bell and relay.test_batch (F-038)
- clock.test_c1 and clock.test_c2 (F-039)
- system.get_config, system.get_all, system.get_telemetry (F-035)
- telemetry.set_max_loads, telemetry.set_guard_enabled, telemetry.get_metrics, telemetry.reset_metrics (F-030, F-031, F-034)
- log.get_config and mqtt.get_config (F-036)
- ota.set_channel
- bells namespace: bells.enable, bells.disable, bells.get_config (F-029, F-036)
- UART whitelist updated for new commands
- Legacy map updated: get_full_settings → system.get_config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-10 19:55:08 +03:00
bonaminandClaude Sonnet 4.6 abf3c3825b docs(ApiReference): align V2 command reference with actual firmware
playback namespace: replaced stale root-level 'playback' command with
proper playback.play / playback.stop split. playback.play now documents
all firmware attributes: pid, uid, name, url, speed, note_assignments,
mode (single/timed/interval/infinite), duration, pause_duration,
total_duration.

clock namespace: filled in previously empty contents for set_timings
(pulse_duration, pause_duration), set_alerts (alert_type, alert_interval,
hour/half/quarter_bell outputs), set_backlight (backlight, backlight_output,
backlight_on/off), set_silence (daytime and nighttime windows with HH:MM
strings). Fixed clock.get_timezone returns (ntp_server field, removed
non-existent timezone_name). Added exact returns shape for clock.get_face
and full field list for clock.get_config matching handler output.

system namespace: added exact returns shapes for system.status
(player_status, time_elapsed_ms, projected_run_time, strike_counters)
and system.health (critical_count, warning_count, firmware_stable,
subsystems array).

Legacy map: updated playback row to show play→playback.play and
stop→playback.stop. UART whitelist updated to playback.play/stop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 10:03:52 +03:00
102 changed files with 15342 additions and 1042 deletions
+4
View File
@@ -17,6 +17,10 @@ MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
MQTT_CLIENT_ID=bellsystems-admin-panel
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
MQTT_SECRET=change-me-in-production
# Accept the legacy shared "vesper" MQTT password for device usernames
# (old firmware). Each use is logged once/hour per board; set to false
# once no boards show up in those logs.
MQTT_ALLOW_LEGACY_PASSWORD=true
# App
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
+2
View File
@@ -41,3 +41,5 @@ Thumbs.db
# claude
.claude/
# Per-machine compose overrides (e.g. Windows Postgres volume)
docker-compose.override.yml
+25
View File
@@ -85,6 +85,7 @@ Never use relative `../` paths except inside `providers/` and `hooks/` when refe
- **Source every UI element from `@/components/ui/`** — no raw HTML elements for styled things
- **Use only CSS tokens** — never raw hex, rgb, or pixel values in component or page files
- **Use `.masonry-grid` for all content pages with multiple variable-height sections** — never `display: grid` with fixed columns for card layouts. See DESIGN.md §11.
- **Every change, major or minor, must be documented and committed** — see "Documentation & Commit Policy" below.
---
@@ -99,6 +100,7 @@ Check the live Style Guide at `/dev/styleguide` to see all variants and states.
| `StatusBadge` | `@/components/ui/StatusBadge` | Coloured status pills |
| `FormField` | `@/components/ui/FormField` | Every text/email/password/textarea input |
| `Select` | `@/components/ui/Select` | Custom dropdown (used inside FormField type="select") |
| `MultiSelect` | `@/components/ui/MultiSelect` | Checkbox dropdown for selecting multiple values (filters, tags) |
| `Modal` | `@/components/ui/Modal` | All overlay dialogs |
| `ConfirmDialog` | `@/components/ui/ConfirmDialog` | Destructive / confirmation prompts |
| `DataTable` | `@/components/ui/DataTable` | All tabular data with sorting/selection |
@@ -363,6 +365,16 @@ used in a page must have a visible example there first.
---
## MQTT Auth (devices + phone-app users)
Read `docs/mqtt-app-user-auth.md` before touching `backend/mqtt/auth.py`,
`backend/mqtt/app_users.py`, or any code that changes a device's `user_list` or a
user's `status`. Any code that edits `user_list` must also update the user's
`device_serials` in the same batch and invalidate the MQTT ACL cache, or app users
lose (or keep) access to the wrong devices.
---
## API Client
```js
@@ -381,3 +393,16 @@ All pages import from `@/lib/api`, never directly from `_archive`.
These are the ONLY two files permitted to import from `_archive/auth/`.
All other files use `@/hooks/useAuth`.
---
## Documentation & Commit Policy
**Every change to this project — major or minor — must be documented and committed.** This is how we keep a record of what happened over time. Uncommitted or undocumented work is effectively invisible to future sessions.
- **Commit every change**, no matter how small (a copy tweak, a token rename, a bug fix, a new page). Do not batch unrelated work silently into "cleanup later" — if it changed, commit it.
- **Write descriptive commit messages** that explain *why*, not just *what* — future sessions rely on `git log` to reconstruct project history since this isn't otherwise tracked elsewhere.
- **Never leave work uncommitted** at the end of a session/task unless the user explicitly says to hold off.
- Follow the repo's existing commit message conventions (see recent `git log` for style/format).
- If a change is large enough to warrant explanation beyond the commit message (a new module, a non-obvious fix, a schema/API change), note the reasoning in the commit body — don't rely on memory or chat history to preserve it.
- This applies to backend, frontend, and config changes alike — even though `backend/` and `_archive/` are off-limits to modify directly in most cases, any change made anywhere in the repo still follows this same commit discipline.
@@ -0,0 +1,73 @@
"""device diagnostics reports
Adds device_diagnostics_reports — structured storage for the firmware's
diagnostics_report MQTT event (vesper/{uid}/status/info, type="diagnostics_report"),
published every 5 minutes. Cleanly separate from device_boot_events (one row per
boot, event-driven) and heartbeats (one row every 30s, transport/liveness facts):
this table is periodic health telemetry — CPU temperature (min/max/avg over the
5-minute window), WiFi reconnect count + last disconnect reason, OTA/firmware
check state, and per-task stack high-water marks (bytes free).
stack_high_water is stored as a JSON-encoded TEXT column rather than flattened
columns — unlike the other three groups (fixed field sets), the set of
monitored tasks is open-ended on the firmware side (see project-vesper's
Telemetry::registerTaskForStackMonitoring), so a fixed column per task would
need a migration every time a task is added. TEXT (not JSONB) matches this
codebase's existing convention for JSON blobs stored via raw SQL — see
commands.command_payload / response_payload.
Revision ID: a7b8c9d0e1f2
Revises: f6a7b8c9d0e1
Create Date: 2026-07-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "a7b8c9d0e1f2"
down_revision: Union[str, None] = "f6a7b8c9d0e1"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"device_diagnostics_reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("device_serial", sa.String(128), nullable=False),
# cpu_temp — omitted by firmware (all null here) if no samples were
# taken yet this window (e.g. very first report shortly after boot).
sa.Column("cpu_temp_avg", sa.Float(), nullable=True),
sa.Column("cpu_temp_min", sa.Float(), nullable=True),
sa.Column("cpu_temp_max", sa.Float(), nullable=True),
sa.Column("cpu_temp_samples", sa.Integer(), nullable=True),
# wifi_reconnects — this-boot-only lifetime count, not device lifetime.
sa.Column("wifi_reconnect_count", sa.Integer(), nullable=True),
sa.Column("wifi_last_disconnect_reason", sa.String(64), nullable=True),
sa.Column("wifi_last_disconnect_uptime_ms", sa.BigInteger(), nullable=True),
# ota
sa.Column("ota_current_version", sa.String(32), nullable=True),
sa.Column("ota_update_available", sa.Boolean(), nullable=True),
sa.Column("ota_available_version", sa.String(32), nullable=True),
sa.Column("ota_last_check_uptime_ms", sa.BigInteger(), nullable=True),
sa.Column("ota_last_error", sa.String(32), nullable=True),
# stack_high_water — open-ended task set, see module docstring.
sa.Column("stack_high_water", sa.Text(), nullable=True),
sa.Column("received_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index(
"idx_device_diagnostics_reports_serial_received",
"device_diagnostics_reports",
["device_serial", sa.text("received_at DESC")],
)
def downgrade() -> None:
op.drop_index("idx_device_diagnostics_reports_serial_received", table_name="device_diagnostics_reports")
op.drop_table("device_diagnostics_reports")
@@ -0,0 +1,78 @@
"""mqtt v2 topic migration
Firmware moved to a new MQTT topic spec (project-vesper's
docs/reference/vesper_mqtt_topic_spec_v2.md, feature-catalog F-062). Three
schema changes needed to keep ingestion correct against the new payload shapes:
1. heartbeats.state / heartbeats.ok — the v2 heartbeat payload is flat (no more
{"status":"INFO","type":"heartbeat","payload":{...}} wrapper) and adds two
new fields: state ("idle"/"playing"/"paused"/"error"/"booting") and ok
(overall health), both independently derived on the firmware side — not
mirrored from status/playback or system/alerts. Nullable: older rows
(pre-migration) and any device still reporting have no value for these.
2. device_diagnostics_reports gains bell_strikes / bell_loads / cooling_active.
The 5-min diagnostics report moved from status/info to system/metrics and
picked up per-bell strike/heat data along the way (the topic spec calls out
"bell heat ratings" explicitly for this topic). Stored as JSON-encoded TEXT,
matching this table's existing stack_high_water column — same reasoning:
16 fixed bell slots, but keeping the encoding consistent with the sibling
column is simpler than mixing raw JSONB and TEXT in one row shape.
3. device_reports — new table for the new control/reports topic. Board-
initiated, unsolicited, critical events (currently only bell_overload).
Distinct from device_alert_events (subsystem health transitions) and
device_logs (routine log lines) — this is a narrow, insert-only table for
a different kind of signal: urgent, bell-mechanism-specific events the
physical tablets act on in real time. The console side is deliberately
light for now — store + list for historical/audit purposes; the tablets
are the real-time consumer of control/reports, not this console.
Revision ID: b8c9d0e1f2a3
Revises: a7b8c9d0e1f2
Create Date: 2026-09-21 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b8c9d0e1f2a3"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("heartbeats", sa.Column("state", sa.String(16), nullable=True))
op.add_column("heartbeats", sa.Column("ok", sa.Boolean(), nullable=True))
op.add_column("device_diagnostics_reports", sa.Column("bell_strikes", sa.Text(), nullable=True))
op.add_column("device_diagnostics_reports", sa.Column("bell_loads", sa.Text(), nullable=True))
op.add_column("device_diagnostics_reports", sa.Column("cooling_active", sa.Boolean(), nullable=True))
op.create_table(
"device_reports",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("device_serial", sa.String(128), nullable=False),
sa.Column("report_type", sa.String(64), nullable=False),
sa.Column("payload", sa.Text(), nullable=True),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index(
"idx_device_reports_serial_occurred",
"device_reports",
["device_serial", sa.text("occurred_at DESC")],
)
def downgrade() -> None:
op.drop_index("idx_device_reports_serial_occurred", table_name="device_reports")
op.drop_table("device_reports")
op.drop_column("device_diagnostics_reports", "cooling_active")
op.drop_column("device_diagnostics_reports", "bell_loads")
op.drop_column("device_diagnostics_reports", "bell_strikes")
op.drop_column("heartbeats", "ok")
op.drop_column("heartbeats", "state")
@@ -0,0 +1,32 @@
"""boot event crash detail (firmware F-070)
Firmware F-070 enriches boot_report (and telemetry.get_boot_history entries)
with a fuller `crash` object (backtrace, backtrace_corrupted, elf_sha256) and a
new, independent `pre_crash` object (uptime/heap snapshot + optional
abort_msg). Both are stored verbatim as JSONB so future additive fields don't
need another migration. The existing flat crash_* columns stay and are still
filled, so older rows and older firmware keep working unchanged.
Revision ID: c9d0e1f2a3b4
Revises: b8c9d0e1f2a3
Create Date: 2026-09-30 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "c9d0e1f2a3b4"
down_revision: Union[str, None] = "b8c9d0e1f2a3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column("device_boot_events", sa.Column("crash", postgresql.JSONB(), nullable=True))
op.add_column("device_boot_events", sa.Column("pre_crash", postgresql.JSONB(), nullable=True))
def downgrade() -> None:
op.drop_column("device_boot_events", "pre_crash")
op.drop_column("device_boot_events", "crash")
@@ -0,0 +1,48 @@
"""device_alert_events
Adds an insert-only history log of device alert transitions (WARNING/CRITICAL/
FAILED only — CLEARED is not logged here). device_alerts remains the current-
state table and is untouched; this is purely additive, used to answer "when
was the most recent issue on this device" even after it has been resolved.
Also adds an optional rssi column to heartbeats, for a signal-strength
indicator once firmware starts reporting it on the heartbeat payload.
Revision ID: d4e5f6a7b8c9
Revises: c3d4e5f6a7b8
Create Date: 2026-07-13 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "d4e5f6a7b8c9"
down_revision: Union[str, None] = "c3d4e5f6a7b8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"device_alert_events",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("device_serial", sa.String(128), nullable=False),
sa.Column("subsystem", sa.String(128), nullable=False),
sa.Column("state", sa.String(64), nullable=False),
sa.Column("message", sa.Text(), nullable=True),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index(
"idx_device_alert_events_serial_occurred",
"device_alert_events",
["device_serial", sa.text("occurred_at DESC")],
)
op.add_column("heartbeats", sa.Column("rssi", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("heartbeats", "rssi")
op.drop_index("idx_device_alert_events_serial_occurred", table_name="device_alert_events")
op.drop_table("device_alert_events")
@@ -0,0 +1,39 @@
"""device_logs source column
Adds a 'source' column to device_logs distinguishing the debug log stream
(vesper/{id}/logs, source='log') from the general info stream
(vesper/{id}/status/info, source='info'), which was previously discarded
after only being written to the server debug log. Existing rows default to
'log' since that's the only source ever persisted before this migration.
Adding a column to a partitioned parent table applies it to all existing
and future partitions automatically.
Revision ID: e5f6a7b8c9d0
Revises: d4e5f6a7b8c9
Create Date: 2026-07-14 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "e5f6a7b8c9d0"
down_revision: Union[str, None] = "d4e5f6a7b8c9"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
ALTER TABLE device_logs
ADD COLUMN source TEXT NOT NULL DEFAULT 'log'
""")
op.execute("""
CREATE INDEX idx_device_logs_source
ON device_logs(device_serial, source, received_at DESC)
""")
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_device_logs_source")
op.execute("ALTER TABLE device_logs DROP COLUMN source")
@@ -0,0 +1,82 @@
"""device health tab — boot events, heartbeat free_heap, ping samples
Adds three pieces of schema needed for the device Health tab:
1. device_boot_events — structured, insert-only history of the firmware's
boot_report MQTT event (vesper/{uid}/status/info, type="boot_report").
Previously this payload was flattened into a single device_logs text line
with all structured fields (boot_count, crash detail) discarded on arrival.
This table gives the console a real timeline to query and chart against,
instead of parsing log strings.
2. heartbeats.free_heap — firmware now includes free_heap on every 30s
heartbeat (previously only available once per boot via boot_report), so
the console can chart heap trend instead of seeing one point per boot.
3. device_ping_samples — backend-computed RTT samples. The firmware's ping
command now echoes back a caller-supplied timestamp; the backend pings
each online device on an interval and records (now - echoed_ts) here.
A dedicated table rather than reusing `commands` because `commands` isn't
shaped for time-series charting (mixed command types, no fast per-device
time-range query path) and pruning ping history independently of other
command history is desirable.
Revision ID: f6a7b8c9d0e1
Revises: e5f6a7b8c9d0
Create Date: 2026-07-16 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "f6a7b8c9d0e1"
down_revision: Union[str, None] = "e5f6a7b8c9d0"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"device_boot_events",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("device_serial", sa.String(128), nullable=False),
sa.Column("boot_count", sa.Integer(), nullable=True),
sa.Column("reset_reason", sa.String(64), nullable=True),
sa.Column("is_fault", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("free_heap", sa.Integer(), nullable=True),
sa.Column("crash_task", sa.String(64), nullable=True),
sa.Column("crash_pc", sa.BigInteger(), nullable=True),
sa.Column("crash_exc_cause", sa.Integer(), nullable=True),
sa.Column("crash_exc_vaddr", sa.BigInteger(), nullable=True),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index(
"idx_device_boot_events_serial_occurred",
"device_boot_events",
["device_serial", sa.text("occurred_at DESC")],
)
op.add_column("heartbeats", sa.Column("free_heap", sa.Integer(), nullable=True))
op.create_table(
"device_ping_samples",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column("device_serial", sa.String(128), nullable=False),
sa.Column("rtt_ms", sa.Integer(), nullable=False),
sa.Column("sampled_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.func.now()),
)
op.create_index(
"idx_device_ping_samples_serial_sampled",
"device_ping_samples",
["device_serial", sa.text("sampled_at DESC")],
)
def downgrade() -> None:
op.drop_index("idx_device_ping_samples_serial_sampled", table_name="device_ping_samples")
op.drop_table("device_ping_samples")
op.drop_column("heartbeats", "free_heap")
op.drop_index("idx_device_boot_events_serial_occurred", table_name="device_boot_events")
op.drop_table("device_boot_events")
+6
View File
@@ -19,6 +19,9 @@ class Settings(BaseSettings):
mqtt_admin_username: str = "admin"
mqtt_admin_password: str = ""
mqtt_secret: str = "change-me-in-production"
# Accept the old shared "vesper" password for device-shaped usernames
# (boards on pre-HMAC firmware). Turn off once the logs show no users.
mqtt_allow_legacy_password: bool = True
mosquitto_password_file: str = "/etc/mosquitto/passwd"
mqtt_client_id: str = "bellsystems-admin-panel"
@@ -32,6 +35,9 @@ class Settings(BaseSettings):
# Local file storage
built_melodies_storage_path: str = "./storage/built_melodies"
firmware_storage_path: str = "./storage/firmware"
# xtensa-esp32-elf-addr2line for server-side crash decoding (firmware/elf_store.py).
# Empty = look it up on PATH; if absent, the console shows a copy-paste command instead.
addr2line_path: str = ""
flash_assets_storage_path: str = "./storage/flash_assets"
melody_binaries_storage_path: str = "./storage/melody_binaries"
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
+54
View File
@@ -16,6 +16,33 @@ from database.pg_mqtt import (
upsert_alert,
delete_alert,
get_alerts,
insert_alert_event,
get_latest_alert_events,
get_latest_alert_event,
get_alert_events,
insert_boot_event,
get_boot_events,
get_latest_boot_event,
merge_device_boot_history,
get_crash_events,
insert_ping_sample,
get_ping_samples,
get_latest_ping_samples,
insert_diagnostics_report,
get_diagnostics_reports,
get_latest_diagnostics_report,
get_latest_diagnostics_reports,
insert_report,
get_reports,
delete_device_logs,
delete_device_heartbeats,
delete_device_commands,
delete_device_alert_events,
delete_device_current_alerts,
delete_device_boot_events,
delete_device_ping_samples,
delete_device_diagnostics_reports,
delete_device_reports,
partition_manager_loop,
ensure_current_partitions,
)
@@ -42,6 +69,33 @@ __all__ = [
"upsert_alert",
"delete_alert",
"get_alerts",
"insert_alert_event",
"get_latest_alert_events",
"get_latest_alert_event",
"get_alert_events",
"insert_boot_event",
"get_boot_events",
"get_latest_boot_event",
"merge_device_boot_history",
"get_crash_events",
"insert_ping_sample",
"get_ping_samples",
"get_latest_ping_samples",
"insert_diagnostics_report",
"get_diagnostics_reports",
"get_latest_diagnostics_report",
"get_latest_diagnostics_reports",
"insert_report",
"get_reports",
"delete_device_logs",
"delete_device_heartbeats",
"delete_device_commands",
"delete_device_alert_events",
"delete_device_current_alerts",
"delete_device_boot_events",
"delete_device_ping_samples",
"delete_device_diagnostics_reports",
"delete_device_reports",
"partition_manager_loop",
"ensure_current_partitions",
]
File diff suppressed because it is too large Load Diff
+72
View File
@@ -98,6 +98,35 @@ class DeviceStatistics(BaseModel):
maintainancePeriod: int = 0
class HealthThresholdTier(BaseModel):
"""One severity tier's worth of metric thresholds. Two tiers (warning,
critical) make up DeviceHealthSettings — see there for why."""
restarts_per_week_threshold: int
rssi_floor_dbm: int
free_heap_floor_kb: int
cpu_temp_ceiling_c: int
class DeviceHealthSettings(BaseModel):
"""Per-device alert thresholds shown/edited on the Health tab's Settings
sub-tab. Purely advisory today — thresholds drive a client-computed
ok/warning/critical status icon, not any server-side alerting yet (no
email/push infra exists). Two severity tiers per metric (warning fires
first, critical is the harder breach) so the header status icon can show
orange vs. red rather than a single degraded/healthy binary.
offline_timeout_sec also feeds the Health tab's chart gap detection
(LineChart's gapThresholdMs) so both share one source of truth."""
warning: HealthThresholdTier = HealthThresholdTier(
restarts_per_week_threshold=6, rssi_floor_dbm=-80, free_heap_floor_kb=30, cpu_temp_ceiling_c=65,
)
critical: HealthThresholdTier = HealthThresholdTier(
restarts_per_week_threshold=12, rssi_floor_dbm=-90, free_heap_floor_kb=15, cpu_temp_ceiling_c=80,
)
offline_timeout_sec: int = 90
email_on_threshold: bool = False
push_on_crash_boot: bool = False
class MelodyMainItem(BaseModel):
"""Mirrors the Melody schema used in the melodies collection."""
information: dict = {}
@@ -112,12 +141,17 @@ class MelodyMainItem(BaseModel):
class DeviceCreate(BaseModel):
device_name: str = ""
# Console-only friendly name — set by staff, independent of the name the
# end user gave the device in the app. Never shown to app users, never
# synced from/to device_name. Falls back to device_name when empty.
console_name: str = ""
device_photo: str = ""
device_location: str = ""
is_Online: bool = False
device_attributes: DeviceAttributes = DeviceAttributes()
device_subscription: DeviceSubInformation = DeviceSubInformation()
device_stats: DeviceStatistics = DeviceStatistics()
device_health_settings: DeviceHealthSettings = DeviceHealthSettings()
events_on: bool = False
device_location_coordinates: Any = None # GeoPoint dict {lat, lng} or legacy str
device_melodies_all: List[MelodyMainItem] = []
@@ -136,6 +170,7 @@ class DeviceCreate(BaseModel):
class DeviceUpdate(BaseModel):
device_name: Optional[str] = None
console_name: Optional[str] = None
device_photo: Optional[str] = None
device_location: Optional[str] = None
is_Online: Optional[bool] = None
@@ -143,6 +178,7 @@ class DeviceUpdate(BaseModel):
device_attributes: Optional[Dict[str, Any]] = None
device_subscription: Optional[Dict[str, Any]] = None
device_stats: Optional[Dict[str, Any]] = None
device_health_settings: Optional[Dict[str, Any]] = None
events_on: Optional[bool] = None
device_location_coordinates: Optional[Any] = None # dict {lat, lng} or legacy str
device_melodies_all: Optional[List[MelodyMainItem]] = None
@@ -190,3 +226,39 @@ class DeviceUserInfo(BaseModel):
class DeviceUsersResponse(BaseModel):
users: List[DeviceUserInfo]
total: int
class ResetStatsRequest(BaseModel):
"""Which categories of QA/bench test data to clear before shipping a
device to a customer. Each flag is independent — callers check only the
boxes they want. device_stats (Firestore) and current_alerts both default
to False since they need extra care (current_alerts especially — see
ResetStatsResult.current_alerts note)."""
bell_stats: bool = False # Firestore device_stats: totalHammerStrikes, perBellStrikes
playback_stats: bool = False # Firestore device_stats: totalPlaybacks, totalWarningsGiven
device_logs: bool = False # Postgres device_logs
heartbeats: bool = False # Postgres heartbeats
commands: bool = False # Postgres commands
boot_events: bool = False # Postgres device_boot_events (console-side history)
alert_history: bool = False # Postgres device_alert_events
ping_samples: bool = False # Postgres device_ping_samples
diagnostics_reports: bool = False # Postgres device_diagnostics_reports
reports: bool = False # Postgres device_reports (control/reports history, e.g. bell_overload)
current_alerts: bool = False # Postgres device_alerts (LIVE state — opt-in only)
class ResetStatsResult(BaseModel):
"""Per-category outcome. Postgres/Firestore categories always run
(device connectivity doesn't matter); counts are rows deleted, or null
for categories that weren't requested."""
bell_stats: Optional[bool] = None
playback_stats: Optional[bool] = None
device_logs: Optional[int] = None
heartbeats: Optional[int] = None
commands: Optional[int] = None
boot_events: Optional[int] = None
alert_history: Optional[int] = None
ping_samples: Optional[int] = None
diagnostics_reports: Optional[int] = None
reports: Optional[int] = None
current_alerts: Optional[int] = None
+97 -11
View File
@@ -10,8 +10,10 @@ from devices.models import (
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
DeviceUsersResponse, DeviceUserInfo,
DeviceNoteCreate, DeviceNoteUpdate,
ResetStatsRequest, ResetStatsResult,
)
from devices import service
from users import service as users_service
import database as mqtt_db
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
from shared.firebase import get_db as get_firestore
@@ -65,7 +67,7 @@ async def create_device(
):
device = service.create_device(body)
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device",
device.device_id, device.device_name or device.device_id)
device.device_id, device.console_name or device.device_name or device.device_id)
return device
@@ -87,7 +89,7 @@ async def update_device(
if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []):
changes["tags"] = {"old": sorted(old.tags or []), "new": sorted(device.tags or [])}
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device.device_name or device_id, changes=changes or None)
device_id, device.console_name or device.device_name or device_id, changes=changes or None)
return device
@@ -437,14 +439,14 @@ async def add_user_to_device(
db: AsyncSession = Depends(get_pg_session),
):
"""Add a user reference to the device's user_list field."""
db = get_firestore()
device_ref = db.collection("devices").document(device_id)
fs = get_firestore()
device_ref = fs.collection("devices").document(device_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise HTTPException(status_code=404, detail="Device not found")
# Verify user exists
user_doc = db.collection("users").document(body.user_id).get()
user_doc = fs.collection("users").document(body.user_id).get()
if not user_doc.exists:
raise HTTPException(status_code=404, detail="User not found")
@@ -460,10 +462,15 @@ async def add_user_to_device(
elif isinstance(entry, str):
existing_ids.add(entry.split("/")[-1])
# user_list and the user's device_serials (MQTT ACL lookup) commit together.
user_ref = fs.collection("users").document(body.user_id)
batch = fs.batch()
if body.user_id not in existing_ids:
user_ref = db.collection("users").document(body.user_id)
user_list.append(user_ref)
device_ref.update({"user_list": user_list})
batch.update(device_ref, {"user_list": user_list})
users_service.stage_device_serial_link(batch, user_ref, users_service.device_serial_of(data), linked=True)
batch.commit()
users_service.invalidate_mqtt_acl_cache(user_doc.to_dict())
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device_id, meta={"action_detail": "user_added",
@@ -479,8 +486,8 @@ async def remove_user_from_device(
db: AsyncSession = Depends(get_pg_session),
):
"""Remove a user reference from the device's user_list field."""
db = get_firestore()
device_ref = db.collection("devices").document(device_id)
fs = get_firestore()
device_ref = fs.collection("devices").document(device_id)
device_doc = device_ref.get()
if not device_doc.exists:
raise HTTPException(status_code=404, detail="Device not found")
@@ -499,9 +506,88 @@ async def remove_user_from_device(
# Remove any entry that resolves to this user_id (handles both DocRef and string paths)
new_list = [entry for entry in user_list if not resolves_to(entry, user_id)]
device_ref.update({"user_list": new_list})
batch = fs.batch()
batch.update(device_ref, {"user_list": new_list})
user_ref = fs.collection("users").document(user_id)
user_doc = user_ref.get()
if user_doc.exists:
users_service.stage_device_serial_link(batch, user_ref, users_service.device_serial_of(data), linked=False)
batch.commit()
if user_doc.exists:
users_service.invalidate_mqtt_acl_cache(user_doc.to_dict())
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device_id, meta={"action_detail": "user_removed",
"user_id": user_id})
return {"status": "removed", "user_id": user_id}
@router.post("/{device_id}/reset-stats", response_model=ResetStatsResult)
async def reset_device_stats(
device_id: str,
body: ResetStatsRequest,
_user: TokenPayload = Depends(require_permission("devices", "edit")),
db: AsyncSession = Depends(get_pg_session),
):
"""Clears QA/bench test data accumulated before a device ships to a
customer. Only handles categories that don't require the device itself
to be online — Postgres history tables and Firestore device_stats
counters. The two firmware-side resets (telemetry.reset_boot_data,
logs.clear) are sent directly by the frontend over MQTT via the existing
command-ack flow, since they need the device online and this console
already has that round-trip built (see DeviceDetail.jsx's
sendMqttCommand) — duplicating it backend-side would just be a second,
inferior implementation of the same wait-for-device-reply logic.
Nothing here touches device identity, customer assignment, notes, tags,
warranty/maintenance fields, or audit logs — see ResetStatsRequest's
field list for exactly what's in scope.
"""
device = service.get_device(device_id)
serial = device.serial_number or device.device_id
result = ResetStatsResult()
# Firestore device_stats — field-level only. DeviceStatistics mixes true
# counters with warranty/maintenance fields in one object; a blanket
# clear would destroy warranty data, so only the 4 named counter fields
# are ever sent here (deep-merge in service.update_device preserves
# every other key, including warrantyActive/warrantyStart/etc.).
stats_update = {}
if body.bell_stats:
stats_update["totalHammerStrikes"] = 0
stats_update["perBellStrikes"] = []
if body.playback_stats:
stats_update["totalPlaybacks"] = 0
stats_update["totalWarningsGiven"] = 0
if stats_update:
service.update_device(device_id, DeviceUpdate(device_stats=stats_update))
if body.bell_stats:
result.bell_stats = True
if body.playback_stats:
result.playback_stats = True
# Postgres — pure history/telemetry tables, per-device row deletes.
if body.device_logs:
result.device_logs = await mqtt_db.delete_device_logs(serial)
if body.heartbeats:
result.heartbeats = await mqtt_db.delete_device_heartbeats(serial)
if body.commands:
result.commands = await mqtt_db.delete_device_commands(serial)
if body.boot_events:
result.boot_events = await mqtt_db.delete_device_boot_events(serial)
if body.alert_history:
result.alert_history = await mqtt_db.delete_device_alert_events(serial)
if body.ping_samples:
result.ping_samples = await mqtt_db.delete_device_ping_samples(serial)
if body.diagnostics_reports:
result.diagnostics_reports = await mqtt_db.delete_device_diagnostics_reports(serial)
if body.reports:
result.reports = await mqtt_db.delete_device_reports(serial)
if body.current_alerts:
result.current_alerts = await mqtt_db.delete_device_current_alerts(serial)
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
device_id, device.console_name or device.device_name or serial,
meta={"action_detail": "reset_stats", "categories": body.model_dump()})
return result
+37 -2
View File
@@ -19,6 +19,7 @@ _TIMESTAMP_FIELD_NAMES = {
"daySilenceFrom", "daySilenceTo",
"nightSilenceFrom", "nightSilenceTo",
"backlightTurnOnTime", "backlightTurnOffTime",
"subscrStart", "warrantyStart", "maintainedOn",
}
@@ -158,9 +159,10 @@ def list_devices(
if search:
search_lower = search.lower()
name_match = search_lower in (device.device_name or "").lower()
console_name_match = search_lower in (device.console_name or "").lower()
location_match = search_lower in (device.device_location or "").lower()
sn_match = search_lower in (device.serial_number or "").lower()
if not (name_match or location_match or sn_match):
if not (name_match or console_name_match or location_match or sn_match):
continue
results.append(device)
@@ -221,19 +223,52 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
# Deep-merge nested structs so unmentioned sub-fields are preserved
existing = doc.to_dict()
nested_keys = (
"device_attributes", "device_subscription", "device_stats",
"device_attributes", "device_subscription", "device_stats", "device_health_settings",
)
for key in nested_keys:
if key in update_data and isinstance(existing.get(key), dict):
update_data[key] = _deep_merge(existing[key], update_data[key])
update_data = _restore_timestamps(update_data)
if "user_list" not in update_data:
doc_ref.update(update_data)
else:
# A user_list change must also update each affected user's
# device_serials (MQTT ACL), in the same atomic batch.
from users import service as users_service
old_ids = {_user_list_entry_id(e) for e in (existing.get("user_list") or [])} - {""}
new_ids = {_user_list_entry_id(e) for e in update_data["user_list"]} - {""}
serial = users_service.device_serial_of(existing)
batch = db.batch()
batch.update(doc_ref, update_data)
touched = []
for user_id in (old_ids ^ new_ids):
user_ref = db.collection("users").document(user_id)
user_doc = user_ref.get()
if not user_doc.exists:
continue # dangling reference — nothing to sync
users_service.stage_device_serial_link(batch, user_ref, serial, linked=user_id in new_ids)
touched.append(user_doc.to_dict())
batch.commit()
for user_data in touched:
users_service.invalidate_mqtt_acl_cache(user_data)
updated_doc = doc_ref.get()
return _doc_to_device(updated_doc)
def _user_list_entry_id(entry) -> str:
"""user_list entries are DocumentReferences, "users/{id}" paths or raw doc IDs."""
if isinstance(entry, DocumentReference):
return entry.id
if isinstance(entry, str):
return entry.strip().split("/")[-1]
return ""
def get_device_users(device_doc_id: str) -> list[dict]:
"""Get users assigned to a device from the device_users sub-collection.
+2 -1
View File
@@ -48,7 +48,8 @@ def _resolve_names(db, device_id: str | None, user_id: str | None) -> tuple[str,
if device_id and isinstance(device_id, str) and device_id.strip():
device_doc = db.collection("devices").document(device_id.strip()).get()
if device_doc.exists:
device_name = device_doc.to_dict().get("device_name", "")
device_data = device_doc.to_dict()
device_name = device_data.get("console_name") or device_data.get("device_name", "")
if user_id and isinstance(user_id, str) and user_id.strip():
user_doc = db.collection("users").document(user_id.strip()).get()
+129
View File
@@ -0,0 +1,129 @@
"""Firmware ELF symbol store + server-side crash decoding (firmware F-070).
Crash reports carry `elf_sha256`: the first 16 hex chars of the SHA-256 of the
crashing build's firmware.elf. ELFs uploaded here are stored under that same
key, so a crash can be matched to its exact build and its PC/backtrace
addresses decoded to function + file:line with xtensa addr2line.
Decoding is optional: it needs a matching ELF AND the xtensa-esp32-elf-addr2line
binary in the backend container (settings.addr2line_path, or on PATH). When
either is missing the console falls back to a copy-paste addr2line command.
"""
import hashlib
import json
import re
import shutil
import subprocess
from datetime import datetime, timezone
from pathlib import Path
from config import settings
from shared.exceptions import NotFoundError, ValidationError
ADDR2LINE_NAME = "xtensa-esp32-elf-addr2line"
_SHA_PREFIX = re.compile(r"^[0-9a-f]{16}$")
_MAX_ADDRESSES = 40
def _elf_dir() -> Path:
return Path(settings.firmware_storage_path) / "elf"
def elf_sha_prefix(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()[:16]
def _check_sha(sha: str) -> str:
sha = (sha or "").lower()
if not _SHA_PREFIX.match(sha):
raise ValidationError("elf_sha256 must be 16 hex characters.")
return sha
def addr2line_path() -> str | None:
configured = getattr(settings, "addr2line_path", "") or ""
if configured and Path(configured).is_file():
return configured
return shutil.which(ADDR2LINE_NAME)
def validate_elf(data: bytes) -> None:
if not data.startswith(b"\x7fELF"):
raise ValidationError("Not an ELF file — upload the firmware.elf from the build, not the .bin.")
def save_elf(data: bytes, filename: str | None, uploaded_by: str | None,
firmware: dict | None = None) -> dict:
"""Store an ELF keyed by its sha256 prefix. Re-uploading the same file is a
no-op overwrite. `firmware` optionally records which release it belongs to."""
validate_elf(data)
sha = elf_sha_prefix(data)
d = _elf_dir()
d.mkdir(parents=True, exist_ok=True)
(d / f"{sha}.elf").write_bytes(data)
meta = {
"elf_sha256": sha,
"filename": filename or "firmware.elf",
"size": len(data),
"uploaded_at": datetime.now(timezone.utc).isoformat(),
"uploaded_by": uploaded_by,
"firmware": firmware,
}
(d / f"{sha}.json").write_text(json.dumps(meta), encoding="utf-8")
return meta
def get_elf_info(sha: str) -> dict:
"""Whether a matching ELF exists and whether the decoder is installed —
the crash view uses this to pick decode-in-console vs. copy-command."""
sha = _check_sha(sha)
meta_path = _elf_dir() / f"{sha}.json"
meta = None
if (_elf_dir() / f"{sha}.elf").is_file():
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
meta = {"elf_sha256": sha}
return {
"elf_sha256": sha,
"exists": meta is not None,
"meta": meta,
"decoder_available": addr2line_path() is not None,
}
def decode(sha: str, addresses: list[int]) -> list[dict]:
"""Run addr2line -pfiaC over the addresses. Returns one entry per address:
{"address": "0x400D8A3F", "frames": ["func at file:line", "(inlined by) ..."]}."""
sha = _check_sha(sha)
elf = _elf_dir() / f"{sha}.elf"
if not elf.is_file():
raise NotFoundError("ELF")
tool = addr2line_path()
if tool is None:
raise ValidationError(f"{ADDR2LINE_NAME} is not installed on the server.")
addrs = [a for a in addresses if isinstance(a, int) and 0 <= a <= 0xFFFFFFFF][:_MAX_ADDRESSES]
if not addrs:
return []
hex_addrs = [f"0x{a:08X}" for a in addrs]
proc = subprocess.run(
[tool, "-pfiaC", "-e", str(elf), *hex_addrs],
capture_output=True, text=True, timeout=20,
)
if proc.returncode != 0:
raise ValidationError(f"addr2line failed: {proc.stderr.strip()[:300]}")
# With -a each address starts a new block "0x400d8a3f: func at file:line";
# -i adds " (inlined by) ..." continuation lines under it.
results: list[dict] = []
for line in proc.stdout.splitlines():
if not line.strip():
continue
m = re.match(r"^(0x[0-9a-fA-F]+):\s*(.*)$", line)
if m:
results.append({"address": f"0x{int(m.group(1), 16):08X}", "frames": [m.group(2).strip()]})
elif results:
results[-1]["frames"].append(line.strip())
return results
+51 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from auth.models import TokenPayload
from auth.dependencies import require_permission
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
from firmware import service
from firmware import service, elf_store
from database.postgres import get_pg_session
from shared.audit import log_action
@@ -29,10 +29,15 @@ async def upload_firmware(
release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None),
file: UploadFile = File(...),
elf_file: Optional[UploadFile] = File(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session),
):
file_bytes = await file.read()
elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None
if elf_bytes is not None:
# Validate before the release is created, so a wrong file doesn't leave a half-done upload.
elf_store.validate_elf(elf_bytes)
fw = service.upload_firmware(
hw_type=hw_type,
channel=channel,
@@ -44,11 +49,48 @@ async def upload_firmware(
release_note=release_note,
bespoke_uid=bespoke_uid,
)
if elf_bytes is not None:
elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email,
firmware={"id": fw.id, "hw_type": hw_type, "channel": channel, "version": version})
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware",
fw.id, f"{hw_type} v{version} ({channel})")
return fw
# ── Firmware ELF symbols (crash decoding, F-070) ─────────────────────────────
# Registered before the /{hw_type}/... routes. Viewing/decoding is gated on
# mqtt:view like the crash data itself; uploading is a firmware-release action.
class ElfDecodeRequest(BaseModel):
addresses: list[int]
@router.post("/elf", status_code=201)
async def upload_elf(
file: UploadFile = File(...),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
):
data = await file.read()
return elf_store.save_elf(data, file.filename, _user.name or _user.email)
@router.get("/elf/{elf_sha256}")
def get_elf_info(
elf_sha256: str,
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
return elf_store.get_elf_info(elf_sha256)
@router.post("/elf/{elf_sha256}/decode")
def decode_addresses(
elf_sha256: str,
body: ElfDecodeRequest,
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
return {"elf_sha256": elf_sha256.lower(), "results": elf_store.decode(elf_sha256, body.addresses)}
@router.get("", response_model=FirmwareListResponse)
def list_firmware(
hw_type: Optional[str] = Query(None),
@@ -114,10 +156,14 @@ async def edit_firmware(
release_note: Optional[str] = Form(None),
bespoke_uid: Optional[str] = Form(None),
file: Optional[UploadFile] = File(None),
elf_file: Optional[UploadFile] = File(None),
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
db: AsyncSession = Depends(get_pg_session),
):
file_bytes = await file.read() if file and file.filename else None
elf_bytes = await elf_file.read() if elf_file and elf_file.filename else None
if elf_bytes is not None:
elf_store.validate_elf(elf_bytes)
fw = service.edit_firmware(
doc_id=firmware_id,
channel=channel,
@@ -129,6 +175,10 @@ async def edit_firmware(
bespoke_uid=bespoke_uid,
file_bytes=file_bytes,
)
if elf_bytes is not None:
elf_store.save_elf(elf_bytes, elf_file.filename, _user.name or _user.email,
firmware={"id": firmware_id, "hw_type": fw.hw_type if fw else None,
"channel": fw.channel if fw else None, "version": fw.version if fw else None})
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "firmware",
firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id)
return fw
+2 -1
View File
@@ -50,7 +50,8 @@ def _resolve_device_name(db, device_id: str) -> str:
try:
doc = db.collection("devices").document(device_id.strip()).get()
if doc.exists:
return doc.to_dict().get("device_name", "")
data = doc.to_dict()
return data.get("console_name") or data.get("device_name", "")
except Exception as e:
print(f"[helpdesk] Error resolving device name: {e}")
return ""
+1
View File
@@ -116,6 +116,7 @@ async def startup():
mqtt_manager.start(asyncio.get_event_loop())
asyncio.create_task(db.partition_manager_loop())
asyncio.create_task(db.purge_loop())
asyncio.create_task(mqtt_manager.ping_loop())
asyncio.create_task(nextcloud_keepalive_loop())
asyncio.create_task(crm_poll_loop())
sync_accounts = [a for a in get_mail_accounts() if a.get("sync_inbound") and a.get("imap_host")]
+13
View File
@@ -80,6 +80,12 @@ class BatchResponse(BaseModel):
created_at: str
class DeviceUserSummary(BaseModel):
user_id: str
display_name: str = ""
email: str = ""
class DeviceInventoryItem(BaseModel):
id: str
serial_number: str
@@ -91,9 +97,16 @@ class DeviceInventoryItem(BaseModel):
owner: Optional[str] = None
assigned_to: Optional[str] = None
device_name: Optional[str] = None
# Console-only friendly name (mirrors devices.models.DeviceCreate.console_name).
# Only populated once a device has been claimed/onboarded in the app — null
# for freshly manufactured inventory, which is expected and should render
# as a graceful fallback (e.g. serial number) on the frontend.
console_name: Optional[str] = None
lifecycle_history: Optional[List["LifecycleEntry"]] = None
customer_id: Optional[str] = None
customer_name: Optional[str] = None
user_list: Optional[List[str]] = None
users: Optional[List[DeviceUserSummary]] = None
class DeviceInventoryListResponse(BaseModel):
+4
View File
@@ -72,6 +72,8 @@ async def create_batch(
def list_devices(
status: Optional[str] = Query(None),
hw_type: Optional[str] = Query(None),
hw_types: Optional[list[str]] = Query(None, description="Multi-select board type filter — repeat param, e.g. ?hw_types=vesper&hw_types=chronos"),
has_users: Optional[bool] = Query(None, description="Filter by whether the device has any app users assigned"),
search: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
@@ -80,6 +82,8 @@ def list_devices(
items = service.list_devices(
status=status,
hw_type=hw_type,
hw_types=hw_types,
has_users=has_users,
search=search,
limit=limit,
offset=offset,
+77 -6
View File
@@ -11,9 +11,11 @@ from shared.firebase import get_db
from shared.exceptions import NotFoundError
from utils.serial_number import generate_serial
from utils.nvs_generator import generate as generate_nvs_binary
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS
from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS, DeviceUserSummary
COLLECTION = "devices"
CRM_COLLECTION = "crm_customers"
USERS_COLLECTION = "users"
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
@@ -45,7 +47,7 @@ def _resolve_user_list(raw_list: list) -> list[str]:
return result
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
def _doc_to_inventory_item(doc, customer_names: dict | None = None, user_infos: dict | None = None) -> DeviceInventoryItem:
data = doc.to_dict() or {}
created_raw = data.get("created_at")
if isinstance(created_raw, datetime):
@@ -53,6 +55,14 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
else:
created_str = str(created_raw) if created_raw else None
customer_names = customer_names or {}
user_infos = user_infos or {}
user_ids = _resolve_user_list(data.get("user_list") or [])
users = [user_infos[uid] for uid in user_ids if uid in user_infos]
customer_id = data.get("customer_id")
return DeviceInventoryItem(
id=doc.id,
serial_number=data.get("serial_number", ""),
@@ -64,12 +74,48 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
owner=data.get("owner"),
assigned_to=data.get("assigned_to"),
device_name=data.get("device_name") or None,
console_name=data.get("console_name") or None,
lifecycle_history=data.get("lifecycle_history") or [],
customer_id=data.get("customer_id"),
user_list=_resolve_user_list(data.get("user_list") or []),
customer_id=customer_id,
customer_name=customer_names.get(customer_id) if customer_id else None,
user_list=user_ids,
users=users,
)
def _bulk_resolve_names(db, docs_data: list[dict]) -> tuple[dict, dict]:
"""Batch-resolve customer names and user summaries referenced across a
list of device docs, so listing N devices costs a handful of get_all()
calls instead of an N+1 Firestore round trip per device."""
customer_ids = {d.get("customer_id") for d in docs_data if d.get("customer_id")}
user_ids = {uid for d in docs_data for uid in _resolve_user_list(d.get("user_list") or [])}
customer_names: dict[str, str] = {}
if customer_ids:
refs = [db.collection(CRM_COLLECTION).document(cid) for cid in customer_ids]
for snap in db.get_all(refs):
if not snap.exists:
continue
cdata = snap.to_dict() or {}
name = " ".join(filter(None, [cdata.get("name"), cdata.get("surname")])) or cdata.get("email") or snap.id
customer_names[snap.id] = name
user_infos: dict[str, DeviceUserSummary] = {}
if user_ids:
refs = [db.collection(USERS_COLLECTION).document(uid) for uid in user_ids]
for snap in db.get_all(refs):
if not snap.exists:
continue
udata = snap.to_dict() or {}
user_infos[snap.id] = DeviceUserSummary(
user_id=snap.id,
display_name=udata.get("display_name") or "",
email=udata.get("email") or "",
)
return customer_names, user_infos
def create_batch(data: BatchCreate) -> BatchResponse:
db = get_db()
existing_sns = _get_existing_sns(db)
@@ -123,6 +169,8 @@ def create_batch(data: BatchCreate) -> BatchResponse:
def list_devices(
status: str | None = None,
hw_type: str | None = None,
hw_types: list[str] | None = None,
has_users: bool | None = None,
search: str | None = None,
limit: int = 100,
offset: int = 0,
@@ -136,7 +184,24 @@ def list_devices(
query = query.where("hw_type", "==", hw_type)
docs = list(query.stream())
items = [_doc_to_inventory_item(doc) for doc in docs]
docs_data = [doc.to_dict() or {} for doc in docs]
if hw_types:
hw_type_set = set(hw_types)
keep = [i for i, d in enumerate(docs_data) if d.get("hw_type") in hw_type_set]
docs = [docs[i] for i in keep]
docs_data = [docs_data[i] for i in keep]
if has_users is not None:
keep = [
i for i, d in enumerate(docs_data)
if bool(d.get("user_list")) == has_users
]
docs = [docs[i] for i in keep]
docs_data = [docs_data[i] for i in keep]
customer_names, user_infos = _bulk_resolve_names(db, docs_data)
items = [_doc_to_inventory_item(doc, customer_names, user_infos) for doc in docs]
if search:
search_lower = search.lower()
@@ -145,6 +210,10 @@ def list_devices(
if search_lower in (item.serial_number or "").lower()
or search_lower in (item.owner or "").lower()
or search_lower in (item.mfg_batch_id or "").lower()
or search_lower in (item.device_name or "").lower()
or search_lower in (item.console_name or "").lower()
or search_lower in (item.customer_name or "").lower()
or any(search_lower in (u.display_name or "").lower() or search_lower in (u.email or "").lower() for u in (item.users or []))
]
return items[offset: offset + limit]
@@ -155,7 +224,9 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem:
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
if not docs:
raise NotFoundError("Device")
return _doc_to_inventory_item(docs[0])
doc = docs[0]
customer_names, user_infos = _bulk_resolve_names(db, [doc.to_dict() or {}])
return _doc_to_inventory_item(doc, customer_names, user_infos)
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
+89
View File
@@ -0,0 +1,89 @@
"""
Firebase app-user lookup for the MQTT auth/ACL endpoints.
App users connect to Mosquitto as "app_<firebase_uid>". Their allowed
devices come from the `device_serials` array on their `users` doc.
User docs are resolved by the `uid` FIELD, never by document ID: docs created
by the Console (users.service.create_user) get random IDs via .add(), while
FlutterFlow uses the Firebase uid as the doc ID.
Per-message ACL checks would otherwise hit Firestore on every publish and
delivery, so lookups are cached in-process for CACHE_TTL_SECONDS. Anything
that changes a user's device_serials or status calls invalidate(uid).
"""
import logging
import threading
import time
from dataclasses import dataclass
from shared.firebase import get_db
logger = logging.getLogger("mqtt.app_users")
USERS_COLLECTION = "users"
CACHE_TTL_SECONDS = 60.0
@dataclass(frozen=True)
class AppUser:
uid: str
blocked: bool
device_serials: frozenset[str]
_cache: dict[str, tuple[float, AppUser | None]] = {}
_lock = threading.Lock()
def _load(uid: str) -> AppUser | None:
db = get_db()
if db is None:
raise RuntimeError("Firestore not initialized")
docs = list(db.collection(USERS_COLLECTION).where("uid", "==", uid).limit(5).stream())
if not docs:
return None
if len(docs) > 1:
# Duplicate profiles for one Firebase account: be conservative on
# blocked, permissive on devices (union), and make it visible.
logger.warning("Multiple user docs share uid=%s: %s", uid, [d.id for d in docs])
blocked = False
serials: set[str] = set()
for doc in docs:
data = doc.to_dict() or {}
if data.get("status") == "blocked":
blocked = True
serials.update(s for s in (data.get("device_serials") or []) if isinstance(s, str) and s)
return AppUser(uid=uid, blocked=blocked, device_serials=frozenset(serials))
def get_app_user(uid: str, *, use_cache: bool = True) -> AppUser | None:
"""Return the app user for a Firebase uid, or None if no user doc has that uid.
use_cache=False forces a Firestore read (and refreshes the cache).
"""
now = time.monotonic()
if use_cache:
with _lock:
hit = _cache.get(uid)
if hit and hit[0] > now:
return hit[1]
user = _load(uid)
with _lock:
_cache[uid] = (now + CACHE_TTL_SECONDS, user)
return user
def invalidate(uid: str) -> None:
with _lock:
_cache.pop(uid, None)
def clear_cache() -> None:
with _lock:
_cache.clear()
+178 -12
View File
@@ -9,8 +9,10 @@ Password strategy: HMAC-SHA256(MQTT_SECRET, username)[:32]
- Deterministic: no storage needed, re-derive on every auth check
- Rotating MQTT_SECRET invalidates all passwords at once if needed
Transition support: during rollout, the legacy password "vesper" is also
accepted so that devices still on old firmware stay connected.
Transition support: while MQTT_ALLOW_LEGACY_PASSWORD is on (default), the
legacy password "vesper" is also accepted for device-shaped usernames so
boards still on old firmware stay connected. Each such login is logged
(once per username per hour) to show which boards still depend on it.
User types handled:
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
@@ -18,21 +20,49 @@ User types handled:
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
Same HMAC auth derived from the full kiosk username.
ACL: allowed to access topics of their base device (suffix stripped).
- App users (e.g. "app_<firebase_uid>"):
Remote phone app. Password = a Firebase ID token, verified with
firebase_admin (revocation checked). No per-user MQTT accounts exist.
Never accepted via HMAC or the legacy password.
- admin, bonamin, NodeRED, and other non-device users:
These connect via the passwd file backend (go-auth file backend).
They never reach this HTTP backend — go-auth resolves them first.
The ACL endpoint below handles them defensively anyway (superuser list).
The handlers are plain `def` on purpose: they make blocking Firestore /
Firebase Auth calls, which FastAPI then runs in its threadpool instead of
stalling the event loop.
"""
import hmac
import hashlib
import logging
import re
import threading
import time
from fastapi import APIRouter, Form, Response
from firebase_admin import auth as firebase_auth
from config import settings
from mqtt import app_users
logger = logging.getLogger("mqtt.auth")
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
LEGACY_PASSWORD = "vesper"
APP_USER_PREFIX = "app_"
# Uppercase alphanumeric segments joined by "-", optional "-kiosk" suffix.
# Covers PV25L22BP01R01, PV-26A18-BC02R-X7KQA, BSVSPR-26C13X-STD01R-X7KQA.
DEVICE_USERNAME_RE = re.compile(r"[A-Z0-9]{2,}(?:-[A-Z0-9]+)*(?:-kiosk)?")
LEGACY_LOG_INTERVAL_SECONDS = 3600
_legacy_log_last: dict[str, float] = {}
_legacy_log_lock = threading.Lock()
# Users authenticated via passwd file (go-auth file backend).
# If they somehow reach the HTTP ACL endpoint, grant full access.
SUPERUSERS = {"admin", "bonamin", "NodeRED"}
@@ -47,18 +77,44 @@ def _derive_password(username: str) -> str:
).hexdigest()[:32]
def _is_device_username(username: str) -> bool:
"""Board serial (optionally with -kiosk), e.g. "PV25L22BP01R01",
"BSVSPR-26C13X-STD01R-X7KQA", "PV25L22BP01R01-kiosk"."""
return bool(DEVICE_USERNAME_RE.fullmatch(username))
def _log_legacy_auth(username: str) -> None:
"""Log a legacy-password login, at most once per username per interval."""
now = time.monotonic()
with _legacy_log_lock:
last = _legacy_log_last.get(username)
if last is not None and now - last < LEGACY_LOG_INTERVAL_SECONDS:
return
_legacy_log_last[username] = now
logger.warning("MQTT legacy password accepted for %s — board still on pre-HMAC firmware", username)
def _is_valid_password(username: str, password: str) -> bool:
"""
Accept the password if it matches either:
- The HMAC-derived password (new firmware)
- The legacy hardcoded "vesper" password (old firmware, transition period)
Remove the legacy check in Stage 7 once all devices are on new firmware.
- The legacy hardcoded "vesper" password (old firmware, transition period),
only when MQTT_ALLOW_LEGACY_PASSWORD is on and the username is
device-shaped (never app_ users or unknown shapes).
"""
expected = _derive_password(username)
hmac_ok = hmac.compare_digest(expected, password)
legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password)
return hmac_ok or legacy_ok
if hmac.compare_digest(expected, password):
return True
if (
settings.mqtt_allow_legacy_password
and _is_device_username(username)
and hmac.compare_digest(LEGACY_PASSWORD, password)
):
_log_legacy_auth(username)
return True
return False
def _base_sn(username: str) -> str:
@@ -72,8 +128,52 @@ def _base_sn(username: str) -> str:
return username
def _deny_app(username: str, reason: str) -> Response:
# Never log the password — for app users it is a bearer token.
logger.warning("MQTT app auth denied for %s: %s", username, reason)
return Response(status_code=403)
def _auth_app_user(username: str, token: str) -> Response:
"""Authenticate "app_<firebase_uid>" with a Firebase ID token as password."""
uid = username[len(APP_USER_PREFIX):]
if not uid:
return _deny_app(username, "empty uid")
if not token:
return _deny_app(username, "empty token")
try:
decoded = firebase_auth.verify_id_token(token, check_revoked=True)
except firebase_auth.RevokedIdTokenError:
return _deny_app(username, "token revoked")
except firebase_auth.ExpiredIdTokenError:
return _deny_app(username, "token expired")
except firebase_auth.UserDisabledError:
return _deny_app(username, "firebase account disabled")
except firebase_auth.InvalidIdTokenError as e:
return _deny_app(username, f"invalid token ({type(e).__name__})")
except Exception as e:
return _deny_app(username, f"token verification failed ({type(e).__name__})")
if decoded.get("uid") != uid:
return _deny_app(username, "token uid does not match username")
try:
# Fresh read on CONNECT (also refreshes the ACL cache).
user = app_users.get_app_user(uid, use_cache=False)
except Exception as e:
return _deny_app(username, f"user lookup failed ({type(e).__name__})")
if user is None:
return _deny_app(username, "no user doc with this uid")
if user.blocked:
return _deny_app(username, "user is blocked")
return Response(status_code=200)
@router.post("/user")
async def mqtt_auth_user(
def mqtt_auth_user(
username: str = Form(...),
password: str = Form(...),
clientid: str = Form(default=""),
@@ -84,37 +184,103 @@ async def mqtt_auth_user(
Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01")
or kiosk variant: "PV25L22BP01R01-kiosk"
or app user: "app_<firebase_uid>"
Password = HMAC-derived (new firmware) or "vesper" (legacy firmware)
or, for app users, a Firebase ID token
Note: admin, bonamin and NodeRED authenticate via the go-auth passwd file backend
and never reach this endpoint.
"""
if username.startswith(APP_USER_PREFIX):
return _auth_app_user(username, password)
if _is_valid_password(username, password):
return Response(status_code=200)
return Response(status_code=403)
# Mosquitto access values as passed through by go-auth (`acc`).
ACC_READ = 1 # message delivery to the client
ACC_WRITE = 2 # publish
ACC_SUBSCRIBE = 4 # subscribe
# App-user topic allowlist, relative to vesper/{serial}/
APP_WRITE_TOPICS = frozenset({"control/command"})
APP_READ_TOPICS = frozenset({"control/ack", "status/heartbeat", "status/playback"})
def _app_acl_allowed(username: str, clientid: str, topic: str, acc: int) -> tuple[bool, str]:
"""ACL decision for "app_<uid>". Returns (allowed, reason)."""
uid = username[len(APP_USER_PREFIX):]
if not uid:
return False, "empty uid"
# Client id must be owned by this user, so one user can't take over
# (and disconnect) another user's session by reusing its client id.
if not clientid.startswith(f"{APP_USER_PREFIX}{uid}_"):
return False, f"clientid {clientid!r} not prefixed with app_<uid>_"
if "+" in topic or "#" in topic:
return False, "wildcards not allowed"
parts = topic.split("/")
if len(parts) != 4 or parts[0] != "vesper" or not parts[1]:
return False, "topic not vesper/{serial}/<a>/<b>"
serial, leaf = parts[1], f"{parts[2]}/{parts[3]}"
if acc == ACC_WRITE:
if leaf not in APP_WRITE_TOPICS:
return False, "publish not allowed on this topic"
elif acc in (ACC_READ, ACC_SUBSCRIBE):
if leaf not in APP_READ_TOPICS:
return False, "read/subscribe not allowed on this topic"
else:
return False, f"unsupported acc={acc}"
try:
user = app_users.get_app_user(uid)
except Exception as e:
return False, f"user lookup failed ({type(e).__name__})"
if user is None:
return False, "no user doc with this uid"
if user.blocked:
return False, "user is blocked"
if serial not in user.device_serials:
return False, "serial not assigned to user"
return True, ""
@router.post("/acl")
async def mqtt_auth_acl(
def mqtt_auth_acl(
username: str = Form(...),
topic: str = Form(...),
clientid: str = Form(default=""),
acc: int = Form(...), # 1 = subscribe, 2 = publish, 3 = subscribe+publish
acc: int = Form(...), # 1 = read (delivery), 2 = write (publish), 4 = subscribe
):
"""
Called by Mosquitto on every SUBSCRIBE and PUBLISH.
Called by Mosquitto on every SUBSCRIBE, PUBLISH and message delivery.
Returns 200 to allow, 403 to deny.
Topic pattern: vesper/{sn}/...
- Device users: may only access their own SN segment
- Kiosk users: stripped of -kiosk suffix, then same rule applies
- App users (app_<uid>): only their assigned serials, only the
command/ack/heartbeat/playback topics — see _app_acl_allowed
- Superusers (bonamin, NodeRED): full access
"""
# Superusers get full access (shouldn't reach here but handled defensively)
if username in SUPERUSERS:
return Response(status_code=200)
if username.startswith(APP_USER_PREFIX):
allowed, reason = _app_acl_allowed(username, clientid, topic, acc)
if allowed:
return Response(status_code=200)
logger.info("MQTT app ACL denied for %s (acc=%s, topic=%s): %s", username, acc, topic, reason)
return Response(status_code=403)
# Derive the base SN (handles -kiosk suffix)
base = _base_sn(username)
+68 -9
View File
@@ -1,12 +1,19 @@
import json
import logging
import asyncio
import time
from typing import Set
import paho.mqtt.client as paho_mqtt
from config import settings
logger = logging.getLogger("mqtt.client")
PING_INTERVAL_SECONDS = 60
# Only devices heard from within this window get pinged — no point spending
# broker traffic/RTT samples on a device that's already known offline; its
# heartbeat-derived "online" state will already reflect that on the console.
PING_ONLINE_WINDOW_SECONDS = 90
class MqttManager:
"""Singleton MQTT client manager."""
@@ -61,12 +68,17 @@ class MqttManager:
if reason_code == 0:
self._connected = True
logger.info("MQTT connected, subscribing to topics")
# v2 topic set — see vesper_mqtt_topic_spec_v2.md in the firmware repo.
# control/command is inbound-to-device only; the console never subscribes to it.
client.subscribe([
("vesper/+/data", 1),
("vesper/+/control/ack", 1),
("vesper/+/control/reports", 1),
("vesper/+/status/heartbeat", 1),
("vesper/+/status/alerts", 1),
("vesper/+/status/info", 0),
("vesper/+/logs", 1),
("vesper/+/status/playback", 1),
("vesper/+/system/alerts", 1),
("vesper/+/system/info", 1),
("vesper/+/system/logs", 0),
("vesper/+/system/metrics", 0),
])
else:
logger.error(f"MQTT connection failed: {reason_code}")
@@ -87,10 +99,17 @@ class MqttManager:
serial = parts[1]
topic_type = "/".join(parts[2:])
# The broker sets retain=1 only on a message it replays from its
# retained store because we just (re)subscribed: a stale snapshot
# of the device's last state, not something that just happened.
# Live publishes always arrive with retain=0, even on topics the
# firmware publishes retained. Every backend restart (and every
# uvicorn --reload) replays these for every device.
retained = bool(msg.retain)
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self._process_message(serial, topic_type, payload, topic),
self._process_message(serial, topic_type, payload, topic, retained),
self._loop,
)
except json.JSONDecodeError:
@@ -99,15 +118,16 @@ class MqttManager:
logger.error(f"Error processing MQTT message: {e}")
async def _process_message(self, serial: str, topic_type: str,
payload: dict, raw_topic: str):
payload: dict, raw_topic: str, retained: bool = False):
from mqtt.logger import handle_message
await handle_message(serial, topic_type, payload)
await handle_message(serial, topic_type, payload, retained=retained)
ws_data = {
"type": topic_type,
"device_serial": serial,
"payload": payload,
"topic": raw_topic,
"retained": retained,
}
await self._broadcast_ws(ws_data)
@@ -132,10 +152,49 @@ class MqttManager:
if not self._client or not self._connected:
return False
topic = f"vesper/{device_serial}/control"
payload = json.dumps({"cmd": cmd, "contents": contents})
topic = f"vesper/{device_serial}/control/command"
payload = json.dumps({"v": 2, "cmd": cmd, "contents": contents})
result = self._client.publish(topic, payload, qos=1)
return result.rc == paho_mqtt.MQTT_ERR_SUCCESS
async def ping_loop(self):
"""Periodically pings every recently-online device with a client
timestamp so mqtt/logger.py::_handle_data_response can compute RTT
from the echoed pong. Deliberately bypasses db.insert_command — this
is a background health check, not a user-initiated command, and
shouldn't clutter the Control tab's command history.
Only available on RTC-equipped firmware builds (see API Reference —
ping is compiled out on agnus/agnus-mini). A pong simply never
arrives for those devices, so no ping-latency samples accumulate for
them; the Health tab handles an empty series as "unsupported".
"""
while True:
await asyncio.sleep(PING_INTERVAL_SECONDS)
try:
await self._ping_online_devices()
except Exception as e:
logger.error(f"Ping loop error: {e}")
async def _ping_online_devices(self):
import database as db
from mqtt import presence
heartbeats = await db.get_latest_heartbeats()
now = time.time()
for hb in heartbeats:
try:
from datetime import datetime
received = datetime.fromisoformat(hb["received_at"])
age = now - received.timestamp()
except (ValueError, TypeError, KeyError):
continue
if age > PING_ONLINE_WINDOW_SECONDS or presence.is_marked_offline(hb["device_serial"]):
continue
self.publish_command(
device_serial=hb["device_serial"],
cmd="ping",
contents={"ts": int(now * 1000)},
)
mqtt_manager = MqttManager()
+90
View File
@@ -0,0 +1,90 @@
"""Fleet-wide crash grouping (firmware F-070).
Groups fault boots so recurring crash types are visible across devices:
- with an abort_msg: by the message with hex addresses stripped, since the
same assert/abort reports different addresses per build;
- otherwise: by faulting task + Xtensa exception cause;
- fault resets with no coredump and no abort_msg: by reset reason alone.
When abort_msg is present, pc/exc_cause/exc_vaddr describe the abort()
mechanism (always StoreProhibited at 0x0), so they must NOT drive the grouping.
"""
import re
# Xtensa EXCCAUSE values the ESP32 actually produces.
EXC_CAUSE_NAMES = {
0: "IllegalInstruction",
2: "InstructionFetchError",
3: "LoadStoreError",
6: "IntegerDivideByZero",
9: "LoadStoreAlignment",
28: "LoadProhibited",
29: "StoreProhibited",
}
_HEX = re.compile(r"0x[0-9a-fA-F]+")
_SPACES = re.compile(r"\s+")
def normalize_abort_msg(msg: str) -> str:
return _SPACES.sub(" ", _HEX.sub("0x…", msg)).strip()
def exc_cause_name(cause) -> str:
if cause is None:
return "Unknown exception"
return EXC_CAUSE_NAMES.get(cause, f"Exception cause {cause}")
def group_key(event: dict) -> tuple[str, str, str]:
"""(key, kind, title) for one boot event."""
crash = event.get("crash") or {}
pre = event.get("pre_crash") or {}
abort_msg = pre.get("abort_msg")
if abort_msg:
norm = normalize_abort_msg(abort_msg)
return f"abort:{norm}", "abort", norm
task = crash.get("task", event.get("crash_task"))
cause = crash.get("exc_cause", event.get("crash_exc_cause"))
if task is not None or cause is not None:
title = f"{exc_cause_name(cause)} in {task or 'unknown task'}"
return f"exc:{task}:{cause}", "exception", title
reason = event.get("reset_reason") or "UNKNOWN"
return f"reason:{reason}", "reason", f"{reason.replace('_', ' ')} (no coredump)"
def group_crashes(events: list[dict]) -> list[dict]:
"""events must be newest-first (as get_crash_events returns them)."""
groups: dict[str, dict] = {}
for ev in events:
key, kind, title = group_key(ev)
g = groups.get(key)
if g is None:
g = groups[key] = {
"key": key,
"kind": kind,
"title": title,
"count": 0,
"first_at": ev["occurred_at"],
"last_at": ev["occurred_at"],
"latest": ev,
"devices": {},
}
g["count"] += 1
g["first_at"] = ev["occurred_at"] # newest-first input, so the last seen is the oldest
d = g["devices"].setdefault(ev["device_serial"], {
"device_serial": ev["device_serial"], "count": 0, "last_at": ev["occurred_at"],
})
d["count"] += 1
result = []
for g in groups.values():
devices = sorted(g["devices"].values(), key=lambda d: (-d["count"], d["device_serial"]))
result.append({**g, "devices": devices, "device_count": len(devices)})
# Most frequent first; ties broken by most recent (two stable sorts).
result.sort(key=lambda g: g["last_at"], reverse=True)
result.sort(key=lambda g: g["count"], reverse=True)
return result
+199 -25
View File
@@ -1,5 +1,7 @@
import logging
import time
import database as db
from mqtt import presence
logger = logging.getLogger("mqtt.logger")
@@ -14,36 +16,70 @@ LEVEL_MAP = {
}
async def handle_message(serial: str, topic_type: str, payload: dict):
async def handle_message(serial: str, topic_type: str, payload: dict,
retained: bool = False):
"""`retained` is True when the broker replayed this from its retained store
on (re)subscribe: a stale copy of the device's last message, NOT a new
event. The firmware publishes status/heartbeat, system/alerts, system/info
and status/playback retained, so every backend restart replays them for
every device that ever connected. Handlers for those topics must never
record a retained replay as something that just happened."""
try:
# v2 topic set — see project-vesper's vesper_mqtt_topic_spec_v2.md.
if topic_type == "status/heartbeat":
await _handle_heartbeat(serial, payload)
elif topic_type == "status/alerts":
await _handle_alerts(serial, payload)
elif topic_type == "status/info":
await _handle_info(serial, payload)
elif topic_type == "logs":
await _handle_heartbeat(serial, payload, retained)
elif topic_type == "system/alerts":
await _handle_alerts(serial, payload, retained)
elif topic_type == "system/info":
await _handle_info(serial, payload, retained)
elif topic_type == "system/logs":
await _handle_log(serial, payload)
elif topic_type == "data":
await _handle_data_response(serial, payload)
elif topic_type == "system/metrics":
await _handle_metrics(serial, payload)
elif topic_type == "control/ack":
await _handle_ack(serial, payload)
elif topic_type == "control/reports":
await _handle_report(serial, payload)
elif topic_type == "status/playback":
pass # no console-side storage needed — WS broadcast already carries it live
else:
logger.debug(f"Unhandled topic type: {topic_type} for {serial}")
except Exception as e:
logger.error(f"Error handling {topic_type} for {serial}: {e}")
async def _handle_heartbeat(serial: str, payload: dict):
async def _handle_heartbeat(serial: str, payload: dict, retained: bool = False):
# Online status is "a heartbeat row newer than 90s", so only a live sign of
# life may be stored:
# - retained replay: the device's last heartbeat from whenever. Storing it
# with received_at=now() marked every device ever seen (even long-dead
# ones) online for 90s after each backend restart.
# - state "offline": the LWT / graceful-disconnect marker published when
# the device goes AWAY. Not a heartbeat, but it IS current state even
# when replayed (a retained message is the device's last word).
if payload.get("state") == "offline":
presence.mark_offline(serial)
return
if retained:
return
presence.mark_alive(serial)
# Store silently — do not log as a visible event.
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
inner = payload.get("payload", {})
# v2 heartbeat payload is FLAT — no {"status","type","payload"} wrapper,
# and field names changed: firmware_version -> fw_version, timestamp -> uptime_human.
# See vesper_mqtt_topic_spec_v2.md and mqtt-events.md for the full shape.
await db.insert_heartbeat(
device_serial=serial,
device_id=inner.get("device_id", ""),
firmware_version=inner.get("firmware_version", ""),
ip_address=inner.get("ip_address", ""),
gateway=inner.get("gateway", ""),
uptime_ms=inner.get("uptime_ms", 0),
uptime_display=inner.get("timestamp", ""),
device_id=payload.get("device_id", ""),
firmware_version=payload.get("fw_version", ""),
ip_address=payload.get("ip_address", ""),
gateway=payload.get("gateway", ""),
uptime_ms=payload.get("uptime_ms", 0),
uptime_display=payload.get("uptime_human", ""),
rssi=payload.get("rssi"),
free_heap=payload.get("free_heap"),
state=payload.get("state"),
ok=payload.get("ok"),
)
@@ -58,10 +94,11 @@ async def _handle_log(serial: str, payload: dict):
level=level,
message=message,
device_timestamp=device_timestamp,
source="log",
)
async def _handle_alerts(serial: str, payload: dict):
async def _handle_alerts(serial: str, payload: dict, retained: bool = False):
subsystem = payload.get("subsystem", "")
state = payload.get("state", "")
if not subsystem or not state:
@@ -71,24 +108,161 @@ async def _handle_alerts(serial: str, payload: dict):
if state == "CLEARED":
await db.delete_alert(serial, subsystem)
else:
await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
# Retained replays still sync the current-alert row, so a fresh DB
# learns about alerts raised while the backend was down.
changed = await db.upsert_alert(serial, subsystem, state, payload.get("msg"))
# Append-only history — survives past the alert being resolved, used to
# answer "when was the most recent issue" even once it's cleared.
# A live alert is always a new occurrence (the same fault can recur);
# a retained replay only counts if it isn't already the current state.
if changed or not retained:
await db.insert_alert_event(serial, subsystem, state, payload.get("msg"))
async def _handle_info(serial: str, payload: dict):
async def _handle_info(serial: str, payload: dict, retained: bool = False):
event_type = payload.get("type", "")
# boot_report carries structured fields (boot_count, crash detail, etc.) —
# parsed into device_boot_events instead of flattened into a text log line,
# so the Health tab can query/chart it. Not routed through insert_log at
# all: a text summary here would just duplicate the structured row.
#
# Note: diagnostics_report used to also arrive here (F-056) but moved to
# its own system/metrics topic in the v2 spec — see _handle_metrics below.
if event_type == "boot_report":
await _handle_boot_report(serial, payload, retained)
return
# Any other info event replayed from the retained store (e.g. an old
# playback_started) already happened in the past — don't log it again.
if retained:
return
data = payload.get("payload", {})
if event_type == "playback_started":
logger.debug(f"{serial}: playback started — melody_uid={data.get('melody_uid')}")
message = f"Playback started — melody_uid={data.get('melody_uid')}"
elif event_type == "playback_stopped":
logger.debug(f"{serial}: playback stopped")
message = "Playback stopped"
else:
logger.debug(f"{serial}: info event '{event_type}'")
message = f"Info event '{event_type}'" + (f" — {data}" if data else "")
await db.insert_log(
device_serial=serial,
level="INFO",
message=message,
source="info",
)
async def _handle_data_response(serial: str, payload: dict):
async def _handle_boot_report(serial: str, payload: dict, retained: bool = False):
"""Parses the firmware's consolidated boot_report event (see
CommunicationRouter::reportBootOnce() / publishInfo()) into
device_boot_events. Like all status/info events, the report fields are
nested under "payload" — CommunicationRouter::publishInfo() wraps every
info event as { "type": ..., "payload": {...} }.
"""
data = payload.get("payload", {})
# F-070: `crash` (coredump summary, now incl. backtrace/elf_sha256) and
# `pre_crash` (heap/uptime snapshot + optional abort_msg) are stored
# verbatim. Older firmware omits them / sends the 4-field crash only.
crash = data.get("crash") if isinstance(data.get("crash"), dict) else None
pre_crash = data.get("pre_crash") if isinstance(data.get("pre_crash"), dict) else None
await db.insert_boot_event(
device_serial=serial,
boot_count=data.get("boot_count"),
reset_reason=data.get("reset_reason"),
is_fault=bool(data.get("is_fault", False)),
free_heap=data.get("free_heap"),
crash=crash,
pre_crash=pre_crash,
# A live boot_report is always a new boot. A retained replay is the
# device's last boot, recorded only if we missed it while down.
skip_if_latest=retained,
)
async def _handle_metrics(serial: str, payload: dict):
"""Parses the firmware's periodic metrics payload (see
CommunicationRouter::reportDiagnosticsIfDue() in project-vesper) into
device_diagnostics_reports. Unlike status/info events, system/metrics has
NO {"type","payload"} wrapper — the payload IS the metrics object,
flat at the top level. See vesper_mqtt_topic_spec_v2.md / mqtt-events.md.
cpu_temp is entirely absent from the firmware payload (not present as a
key at all, not present-with-nulls) when no samples were taken yet this
window — .get() on a missing "cpu_temp" key correctly falls back to {}
below, leaving every cpu_temp_* column null for that row.
"""
cpu_temp = payload.get("cpu_temp") or {}
wifi = payload.get("wifi_reconnects") or {}
ota = payload.get("ota") or {}
stack = payload.get("stack_high_water") or {}
bell_strikes = payload.get("bell_strikes") or {}
bell_loads = payload.get("bell_loads") or {}
await db.insert_diagnostics_report(
device_serial=serial,
cpu_temp_avg=cpu_temp.get("avg"),
cpu_temp_min=cpu_temp.get("min"),
cpu_temp_max=cpu_temp.get("max"),
cpu_temp_samples=cpu_temp.get("samples"),
wifi_reconnect_count=wifi.get("lifetime_count"),
wifi_last_disconnect_reason=wifi.get("last_reason"),
wifi_last_disconnect_uptime_ms=wifi.get("last_at_uptime_ms"),
ota_current_version=ota.get("current_version"),
ota_update_available=ota.get("update_available"),
ota_available_version=ota.get("available_version"),
ota_last_check_uptime_ms=ota.get("last_check_uptime_ms"),
ota_last_error=ota.get("last_error"),
stack_high_water=stack if stack else None,
bell_strikes=bell_strikes if bell_strikes else None,
bell_loads=bell_loads if bell_loads else None,
cooling_active=payload.get("cooling_active"),
)
async def _handle_report(serial: str, payload: dict):
"""Parses control/reports — critical, unsolicited board-initiated events
(currently only bell_overload). Console-side storage is intentionally
light: history/audit only. The tablets are the real-time consumer."""
report_type = payload.get("type", "")
if not report_type:
logger.warning(f"Malformed report payload from {serial}: {payload}")
return
await db.insert_report(
device_serial=serial,
report_type=report_type,
payload=payload.get("payload"),
)
logger.warning(f"Report '{report_type}' received from {serial}: {payload.get('payload')}")
async def _handle_ack(serial: str, payload: dict):
status = payload.get("status", "")
# Health-check pings (see MqttManager.ping_loop) are published directly,
# bypassing db.insert_command, so scheduled liveness checks don't clutter
# the user-facing command history. The device echoes the caller's own
# send-time ("ts", epoch-ms) back unchanged, so RTT is computed here
# without needing to correlate against any pending-command bookkeeping.
if payload.get("type") == "pong":
ts = (payload.get("data") or {}).get("ts")
if isinstance(ts, (int, float)):
rtt_ms = max(0, int(time.time() * 1000) - int(ts))
await db.insert_ping_sample(device_serial=serial, rtt_ms=rtt_ms)
return
# The device's own SD boot log — merged into device_boot_events whoever
# asked for it (Health tab "Sync from device", API reference, Control tab),
# so crash detail for boots we never saw live isn't lost.
if payload.get("type") == "telemetry.get_boot_history" and status == "SUCCESS":
boots = (payload.get("data") or {}).get("boots")
if isinstance(boots, list):
result = await db.merge_device_boot_history(serial, boots)
logger.info(f"Merged device boot history for {serial}: {result}")
pending = await db.get_pending_command(serial)
if pending:
cmd_status = "success" if status == "SUCCESS" else "error"
@@ -98,4 +272,4 @@ async def _handle_data_response(serial: str, payload: dict):
response_payload=payload,
)
else:
logger.debug(f"Received data response for {serial} with no pending command")
logger.debug(f"Received control/ack for {serial} with no pending command")
+137
View File
@@ -27,6 +27,7 @@ class DeviceLogEntry(BaseModel):
level: str
message: str
device_timestamp: Optional[int] = None
source: str = "log"
received_at: str
@@ -39,6 +40,10 @@ class HeartbeatEntry(BaseModel):
gateway: Optional[str] = None
uptime_ms: Optional[int] = None
uptime_display: Optional[str] = None
rssi: Optional[int] = None
free_heap: Optional[int] = None
state: Optional[str] = None # "idle" | "playing" | "paused" | "error" | "booting" (v2 firmware only)
ok: Optional[bool] = None # overall device health, independent of playback state (v2 firmware only)
received_at: str
@@ -53,11 +58,21 @@ class CommandEntry(BaseModel):
responded_at: Optional[str] = None
class AlertEventEntry(BaseModel):
id: int
device_serial: str
subsystem: str
state: str
message: Optional[str] = None
occurred_at: str
class DeviceMqttStatus(BaseModel):
device_serial: str
online: bool
last_heartbeat: Optional[HeartbeatEntry] = None
seconds_since_heartbeat: Optional[int] = None
last_alert_event: Optional[AlertEventEntry] = None
class MqttStatusResponse(BaseModel):
@@ -96,3 +111,125 @@ class DeviceAlertEntry(BaseModel):
class DeviceAlertsResponse(BaseModel):
alerts: List[DeviceAlertEntry]
class AlertEventListResponse(BaseModel):
events: List[AlertEventEntry]
total: int
class BootEventEntry(BaseModel):
id: int
device_serial: str
boot_count: Optional[int] = None
reset_reason: Optional[str] = None
is_fault: bool = False
free_heap: Optional[int] = None
crash_task: Optional[str] = None
crash_pc: Optional[int] = None
crash_exc_cause: Optional[int] = None
crash_exc_vaddr: Optional[int] = None
# Firmware F-070 objects, verbatim — None for older firmware/rows.
crash: Optional[Dict[str, Any]] = None
pre_crash: Optional[Dict[str, Any]] = None
occurred_at: str
class BootEventListResponse(BaseModel):
events: List[BootEventEntry]
total: int
class CrashGroupDevice(BaseModel):
device_serial: str
count: int
last_at: str
class CrashGroup(BaseModel):
key: str
kind: str # "abort" | "exception" | "reason"
title: str
count: int
device_count: int
first_at: str
last_at: str
latest: BootEventEntry
devices: List[CrashGroupDevice]
class CrashGroupListResponse(BaseModel):
groups: List[CrashGroup]
total_crashes: int
class PingSampleEntry(BaseModel):
id: int
device_serial: str
rtt_ms: int
sampled_at: str
class PingSampleListResponse(BaseModel):
samples: List[PingSampleEntry]
total: int
class DiagnosticsReportEntry(BaseModel):
id: int
device_serial: str
cpu_temp_avg: Optional[float] = None
cpu_temp_min: Optional[float] = None
cpu_temp_max: Optional[float] = None
cpu_temp_samples: Optional[int] = None
wifi_reconnect_count: Optional[int] = None
wifi_last_disconnect_reason: Optional[str] = None
wifi_last_disconnect_uptime_ms: Optional[int] = None
ota_current_version: Optional[str] = None
ota_update_available: Optional[bool] = None
ota_available_version: Optional[str] = None
ota_last_check_uptime_ms: Optional[int] = None
ota_last_error: Optional[str] = None
stack_high_water: Optional[str] = None # JSON-encoded string — see insert_diagnostics_report
bell_strikes: Optional[str] = None # JSON-encoded string, {"0": count, ...} — v2 firmware only
bell_loads: Optional[str] = None # JSON-encoded string, {"0": load, ...} — v2 firmware only
cooling_active: Optional[bool] = None # v2 firmware only
received_at: str
class DiagnosticsReportListResponse(BaseModel):
reports: List[DiagnosticsReportEntry]
total: int
class DeviceReportEntry(BaseModel):
"""A control/reports event (currently only bell_overload) — see
project-vesper's vesper_mqtt_topic_spec_v2.md. Console-side storage is
intentionally light: history/audit only, not a real-time UI surface."""
id: int
device_serial: str
report_type: str
payload: Optional[str] = None # JSON-encoded string
occurred_at: str
class DeviceReportListResponse(BaseModel):
reports: List[DeviceReportEntry]
total: int
class LatestDiagnosticsEntry(BaseModel):
device_serial: str
cpu_temp_avg: Optional[float] = None
received_at: str
class LatestPingEntry(BaseModel):
device_serial: str
rtt_ms: int
sampled_at: str
class LatestMetricsResponse(BaseModel):
diagnostics: List[LatestDiagnosticsEntry]
pings: List[LatestPingEntry]
+26
View File
@@ -0,0 +1,26 @@
"""Devices that announced they went offline.
The firmware's LWT (unclean disconnect) and its graceful-disconnect message both
publish {"state": "offline", "ok": false} to status/heartbeat, retained. Online
status is otherwise "a heartbeat newer than 90s", which would keep showing a
device that just dropped as online for up to 90s. This remembers the marker so
/mqtt/status and the ping loop treat the device as offline right away.
In-memory only, which is enough: on backend start the broker replays each
device's retained heartbeat, so a device whose last word was "offline" gets
re-marked, and any live heartbeat clears the mark.
"""
_offline: set[str] = set()
def mark_offline(serial: str) -> None:
_offline.add(serial)
def mark_alive(serial: str) -> None:
_offline.discard(serial)
def is_marked_offline(serial: str) -> bool:
return serial in _offline
+126 -7
View File
@@ -1,13 +1,19 @@
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
from typing import Optional
from typing import Optional, List
from auth.models import TokenPayload
from auth.dependencies import require_permission
from mqtt.models import (
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
CommandListResponse, HeartbeatEntry,
CommandListResponse, HeartbeatEntry, AlertEventEntry,
AlertEventListResponse, BootEventListResponse, PingSampleListResponse,
DiagnosticsReportListResponse, LatestMetricsResponse,
LatestDiagnosticsEntry, LatestPingEntry, DeviceReportListResponse,
CrashGroupListResponse,
)
from mqtt.crash_groups import group_crashes
from mqtt.client import mqtt_manager
from mqtt import presence
import database as db
from datetime import datetime, timezone
@@ -19,6 +25,8 @@ async def get_all_device_status(
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
heartbeats = await db.get_latest_heartbeats()
alert_events = await db.get_latest_alert_events()
alert_by_serial = {a["device_serial"]: a for a in alert_events}
now = datetime.now(timezone.utc)
devices = []
for hb in heartbeats:
@@ -31,11 +39,14 @@ async def get_all_device_status(
except (ValueError, TypeError):
seconds_ago = 9999
alert_event = alert_by_serial.get(hb["device_serial"])
devices.append(DeviceMqttStatus(
device_serial=hb["device_serial"],
online=seconds_ago < 90,
online=seconds_ago < 90 and not presence.is_marked_offline(hb["device_serial"]),
last_heartbeat=HeartbeatEntry(**hb),
seconds_since_heartbeat=seconds_ago,
last_alert_event=AlertEventEntry(**alert_event) if alert_event else None,
))
return MqttStatusResponse(
devices=devices,
@@ -43,6 +54,21 @@ async def get_all_device_status(
)
@router.get("/latest-metrics", response_model=LatestMetricsResponse)
async def get_latest_metrics(
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
# Fleet-wide "last known" CPU temp + ping RTT, one query each — used by
# DeviceList to render optional columns without polling any device.
# Uptime/firmware/RSSI don't need this: they're already in /mqtt/status.
diag_reports = await db.get_latest_diagnostics_reports()
ping_samples = await db.get_latest_ping_samples()
return LatestMetricsResponse(
diagnostics=[LatestDiagnosticsEntry(**d) for d in diag_reports],
pings=[LatestPingEntry(**p) for p in ping_samples],
)
@router.post("/command/{device_serial}", response_model=CommandSendResponse)
async def send_command(
device_serial: str,
@@ -81,14 +107,18 @@ async def send_command(
async def get_device_logs(
device_serial: str,
level: Optional[str] = Query(None, description="Filter: INFO, WARN, ERROR"),
min_level: bool = Query(False, description="If true, level is a floor — also includes higher-severity levels"),
search: Optional[str] = Query(None),
source: Optional[List[str]] = Query(None, description="Filter by source: log, info. Repeat param to include several."),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
logs, total = await db.get_logs(
device_serial, level=level, search=search,
limit=limit, offset=offset,
device_serial, level=level, search=search, source=source,
min_level=min_level, limit=limit, offset=offset, since=since, until=until,
)
return LogListResponse(logs=logs, total=total)
@@ -96,12 +126,14 @@ async def get_device_logs(
@router.get("/heartbeats/{device_serial}", response_model=HeartbeatListResponse)
async def get_device_heartbeats(
device_serial: str,
limit: int = Query(100, ge=1, le=1000),
limit: int = Query(100, ge=1, le=5000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
heartbeats, total = await db.get_heartbeats(
device_serial, limit=limit, offset=offset,
device_serial, limit=limit, offset=offset, since=since, until=until,
)
return HeartbeatListResponse(heartbeats=heartbeats, total=total)
@@ -119,6 +151,93 @@ async def get_device_commands(
return CommandListResponse(commands=commands, total=total)
@router.get("/alert-events/{device_serial}", response_model=AlertEventListResponse)
async def get_device_alert_events(
device_serial: str,
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
events, total = await db.get_alert_events(
device_serial, limit=limit, offset=offset,
)
return AlertEventListResponse(events=events, total=total)
@router.get("/boot-events/{device_serial}", response_model=BootEventListResponse)
async def get_device_boot_events(
device_serial: str,
limit: int = Query(100, ge=1, le=2000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
events, total = await db.get_boot_events(
device_serial, limit=limit, offset=offset, since=since, until=until,
)
return BootEventListResponse(events=events, total=total)
@router.get("/crash-groups", response_model=CrashGroupListResponse)
async def get_crash_groups(
since: Optional[datetime] = Query(None, description="ISO timestamp — only crashes at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only crashes at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
"""Fault boots across the whole fleet, grouped by crash signature."""
events = await db.get_crash_events(since=since, until=until)
return CrashGroupListResponse(groups=group_crashes(events), total_crashes=len(events))
@router.get("/ping-samples/{device_serial}", response_model=PingSampleListResponse)
async def get_device_ping_samples(
device_serial: str,
limit: int = Query(200, ge=1, le=5000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
samples, total = await db.get_ping_samples(
device_serial, limit=limit, offset=offset, since=since, until=until,
)
return PingSampleListResponse(samples=samples, total=total)
@router.get("/diagnostics-reports/{device_serial}", response_model=DiagnosticsReportListResponse)
async def get_device_diagnostics_reports(
device_serial: str,
limit: int = Query(200, ge=1, le=5000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
reports, total = await db.get_diagnostics_reports(
device_serial, limit=limit, offset=offset, since=since, until=until,
)
return DiagnosticsReportListResponse(reports=reports, total=total)
@router.get("/reports/{device_serial}", response_model=DeviceReportListResponse)
async def get_device_reports(
device_serial: str,
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
since: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/after this time"),
until: Optional[datetime] = Query(None, description="ISO timestamp — only rows at/before this time"),
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
):
"""Critical, unsolicited board-initiated events from control/reports
(currently only bell_overload). History/audit only — the tablets are the
real-time consumer of this data, not the console."""
reports, total = await db.get_reports(
device_serial, limit=limit, offset=offset, since=since, until=until,
)
return DeviceReportListResponse(reports=reports, total=total)
@router.websocket("/ws")
async def mqtt_websocket(websocket: WebSocket):
"""Live MQTT data stream. Auth via query param: ?token=JWT"""
@@ -0,0 +1,149 @@
"""
Backfill `device_serials` on Firestore `users` docs from the devices' `user_list`.
`device_serials` is what the MQTT ACL uses to decide which vesper/{serial}/...
topics an app user may reach (see mqtt/app_users.py). New assignments keep it
in sync automatically; this script builds it for assignments that predate that.
The devices' user_list is treated as the source of truth: each user's
device_serials is set to exactly the serials of the devices that list them.
Running it twice is a no-op the second time.
Also reports:
- users whose document ID != their `uid` field (or who have no uid) — the
MQTT layer resolves users by the uid field, so this is informational,
but a user without a uid can never connect via the app
- devices that have users but no serial (can't be reached over MQTT)
- user_list entries that point to users that don't exist
Run from backend/ (locally or inside the backend container):
python scripts/backfill_user_device_serials.py # dry run
python scripts/backfill_user_device_serials.py --apply # write changes
"""
import argparse
import sys
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from google.cloud.firestore_v1 import DocumentReference # noqa: E402
from shared.firebase import init_firebase, get_db # noqa: E402
from users.service import device_serial_of # noqa: E402
BATCH_SIZE = 400 # Firestore batch limit is 500 writes
def _entry_user_id(entry) -> str:
"""user_list entries are DocumentReferences, "users/{id}" paths or raw doc IDs."""
if isinstance(entry, DocumentReference):
return entry.id
if isinstance(entry, str) and entry.strip():
return entry.strip().split("/")[-1]
return ""
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--apply", action="store_true", help="write changes (default: dry run)")
args = parser.parse_args()
init_firebase()
db = get_db()
if db is None:
print("[ERROR] Firebase not initialized — check FIREBASE_SERVICE_ACCOUNT_PATH")
return 1
mode = "APPLY" if args.apply else "DRY RUN"
print(f"=== backfill device_serials ({mode}) ===\n")
# 1. devices -> {user_doc_id: {serials}}
wanted: dict[str, set[str]] = defaultdict(set)
devices_without_serial = []
device_count = 0
for doc in db.collection("devices").stream():
device_count += 1
data = doc.to_dict() or {}
user_ids = [u for u in (_entry_user_id(e) for e in (data.get("user_list") or [])) if u]
if not user_ids:
continue
serial = device_serial_of(data)
if not serial:
devices_without_serial.append(doc.id)
continue
for uid in user_ids:
wanted[uid].add(serial)
# 2. users
users = {doc.id: (doc.reference, doc.to_dict() or {}) for doc in db.collection("users").stream()}
by_uid_field = {data.get("uid"): doc_id for doc_id, (_, data) in users.items() if data.get("uid")}
# user_list entries that don't match a doc ID but do match a uid field
dangling = []
for ref_id in list(wanted):
if ref_id in users:
continue
if ref_id in by_uid_field:
wanted[by_uid_field[ref_id]].update(wanted.pop(ref_id))
else:
dangling.append((ref_id, sorted(wanted.pop(ref_id))))
id_mismatch = []
no_uid = []
changes = []
for doc_id, (ref, data) in sorted(users.items()):
uid = data.get("uid") or ""
if not uid:
no_uid.append(doc_id)
elif uid != doc_id:
id_mismatch.append((doc_id, uid))
current = data.get("device_serials")
desired = sorted(wanted.get(doc_id, set()))
if current is None and not desired:
continue # nothing assigned and field absent — leave the doc alone
if list(current or []) != desired:
changes.append((doc_id, ref, list(current or []), desired))
# 3. report
print(f"Scanned {device_count} devices, {len(users)} users.\n")
print(f"Users to update: {len(changes)}")
for doc_id, _, current, desired in changes:
print(f" users/{doc_id}: {current} -> {desired}")
print(f"\nUsers whose doc ID != uid: {len(id_mismatch)}")
for doc_id, uid in id_mismatch:
print(f" users/{doc_id} uid={uid}")
print(f"\nUsers with no uid field (cannot use app MQTT login): {len(no_uid)}")
for doc_id in no_uid:
print(f" users/{doc_id}")
print(f"\nDevices with users but no serial_number/device_id: {len(devices_without_serial)}")
for doc_id in devices_without_serial:
print(f" devices/{doc_id}")
print(f"\nuser_list entries pointing to missing users: {len(dangling)}")
for ref_id, serials in dangling:
print(f" {ref_id} (on devices {serials})")
# 4. write
if not args.apply:
print("\nDry run — nothing written. Re-run with --apply to write.")
return 0
for i in range(0, len(changes), BATCH_SIZE):
batch = db.batch()
for _, ref, _, desired in changes[i:i + BATCH_SIZE]:
batch.update(ref, {"device_serials": desired})
batch.commit()
print(f"\nWrote device_serials on {len(changes)} user docs.")
return 0
if __name__ == "__main__":
sys.exit(main())
+3 -2
View File
@@ -26,8 +26,9 @@ def _search_devices(q: str) -> list[dict]:
return []
out = []
for d in results[:LIMIT]:
label = d.device_name or d.serial_number or d.device_id or d.id
sublabel = d.serial_number if d.device_name else None
display_name = d.console_name or d.device_name
label = display_name or d.serial_number or d.device_id or d.id
sublabel = d.serial_number if display_name else None
out.append({
"type": "device",
"id": d.id,
+10
View File
@@ -0,0 +1,10 @@
from pydantic import BaseModel
from typing import Optional
class LogRetentionSettings(BaseModel):
days: int = 180
class LogRetentionSettingsUpdate(BaseModel):
days: Optional[int] = None
+31
View File
@@ -0,0 +1,31 @@
from shared.firebase import get_db
from settings.log_retention_models import LogRetentionSettings, LogRetentionSettingsUpdate
COLLECTION = "admin_settings"
DOC_ID = "log_retention"
def get_log_retention() -> LogRetentionSettings:
"""Get log retention settings from Firestore. Creates defaults if not found."""
db = get_db()
doc = db.collection(COLLECTION).document(DOC_ID).get()
if doc.exists:
return LogRetentionSettings(**doc.to_dict())
defaults = LogRetentionSettings()
db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump())
return defaults
def update_log_retention(data: LogRetentionSettingsUpdate) -> LogRetentionSettings:
"""Update log retention settings. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(DOC_ID)
doc = doc_ref.get()
existing = doc.to_dict() if doc.exists else LogRetentionSettings().model_dump()
update_data = data.model_dump(exclude_none=True)
existing.update(update_data)
normalized = LogRetentionSettings(**existing)
doc_ref.set(normalized.model_dump())
return normalized
+19
View File
@@ -4,8 +4,10 @@ from auth.dependencies import require_permission, require_roles
from auth.models import Role
from settings.models import MelodySettings, MelodySettingsUpdate
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
from settings.log_retention_models import LogRetentionSettings, LogRetentionSettingsUpdate
from settings import service
from settings import public_features_service
from settings import log_retention_service
router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -40,3 +42,20 @@ async def update_public_features(
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
):
return public_features_service.update_public_features(body)
# ── Log Retention Settings (sysadmin / admin only) ───────────────────────────
@router.get("/log-retention", response_model=LogRetentionSettings)
async def get_log_retention(
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
):
return log_retention_service.get_log_retention()
@router.put("/log-retention", response_model=LogRetentionSettings)
async def update_log_retention(
body: LogRetentionSettingsUpdate,
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
):
return log_retention_service.update_log_retention(body)
+5
View File
@@ -0,0 +1,5 @@
import sys
from pathlib import Path
# Tests import backend modules the same way the app does (cwd = backend/).
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+118
View File
@@ -0,0 +1,118 @@
"""
update_device must keep users.device_serials in sync when a PUT changes the
device's user_list (the MQTT ACL reads device_serials). Firestore is faked.
"""
import pytest
from google.cloud.firestore_v1 import ArrayRemove, ArrayUnion
from devices import service as devices_service
from devices.models import DeviceUpdate
from mqtt import app_users
SERIAL = "BSVSPR-26C13X-STD01R-X7KQA"
class _Snap:
def __init__(self, ref, data):
self.reference = ref
self.id = ref.id
self._data = data
self.exists = data is not None
def to_dict(self):
return dict(self._data) if self._data is not None else None
class _Ref:
def __init__(self, db, collection, doc_id):
self._db, self._col, self.id = db, collection, doc_id
def get(self):
return _Snap(self, self._db.docs[self._col].get(self.id))
def update(self, data):
self._db.apply(self._col, self.id, data)
class _Batch:
def __init__(self, db):
self._db, self.ops = db, []
def update(self, ref, data):
self.ops.append((ref, data))
def commit(self):
for ref, data in self.ops:
ref.update(data)
self._db.commits += 1
class _Collection:
def __init__(self, db, name):
self._db, self._name = db, name
def document(self, doc_id):
return _Ref(self._db, self._name, doc_id)
class FakeDB:
def __init__(self):
self.docs = {"devices": {}, "users": {}}
self.commits = 0
def collection(self, name):
return _Collection(self, name)
def batch(self):
return _Batch(self)
def apply(self, col, doc_id, data):
doc = self.docs[col][doc_id]
for key, val in data.items():
if isinstance(val, ArrayUnion):
doc[key] = list(doc.get(key) or []) + [v for v in val.values if v not in (doc.get(key) or [])]
elif isinstance(val, ArrayRemove):
doc[key] = [v for v in (doc.get(key) or []) if v not in val.values]
else:
doc[key] = val
@pytest.fixture
def db(monkeypatch):
fake = FakeDB()
fake.docs["devices"]["dev1"] = {
"serial_number": SERIAL, "mfg_status": "claimed", "user_list": ["users/alice", "users/bob"],
}
fake.docs["users"]["alice"] = {"uid": "alice", "device_serials": [SERIAL]}
fake.docs["users"]["bob"] = {"uid": "bob", "device_serials": [SERIAL, "OTHER"]}
fake.docs["users"]["carol"] = {"uid": "carol"}
monkeypatch.setattr(devices_service, "get_db", lambda: fake)
return fake
def test_put_user_list_syncs_added_and_removed_users(db, monkeypatch):
invalidated = []
monkeypatch.setattr(app_users, "invalidate", invalidated.append)
devices_service.update_device("dev1", DeviceUpdate(user_list=["users/alice", "users/carol"]))
assert db.docs["devices"]["dev1"]["user_list"] == ["users/alice", "users/carol"]
assert db.docs["users"]["alice"]["device_serials"] == [SERIAL] # unchanged
assert db.docs["users"]["bob"]["device_serials"] == ["OTHER"] # removed
assert db.docs["users"]["carol"]["device_serials"] == [SERIAL] # added
assert sorted(invalidated) == ["bob", "carol"]
assert db.commits == 1 # one atomic batch
def test_put_user_list_ignores_dangling_user_refs(db):
devices_service.update_device("dev1", DeviceUpdate(user_list=["users/alice", "users/bob", "users/ghost"]))
assert db.docs["devices"]["dev1"]["user_list"] == ["users/alice", "users/bob", "users/ghost"]
assert "ghost" not in db.docs["users"]
def test_put_without_user_list_does_not_touch_users(db):
devices_service.update_device("dev1", DeviceUpdate(device_name="Church"))
assert db.docs["devices"]["dev1"]["device_name"] == "Church"
assert db.commits == 0
assert db.docs["users"]["bob"]["device_serials"] == [SERIAL, "OTHER"]
+357
View File
@@ -0,0 +1,357 @@
"""
Tests for the mosquitto-go-auth HTTP backend endpoints (mqtt/auth.py):
POST /mqtt/auth/user and POST /mqtt/auth/acl.
firebase_admin token verification and Firestore are mocked; nothing here
touches the network.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from firebase_admin import auth as firebase_auth
from config import settings
from mqtt import app_users
from mqtt import auth as mqtt_auth
UID = "uidAlice123"
OTHER_UID = "uidBob456"
SERIAL = "BSVSPR-26C13X-STD01R-X7KQA"
FOREIGN_SERIAL = "PV25L22BP01R01"
APP_USER = f"app_{UID}"
APP_CLIENT = f"app_{UID}_phone1"
GOOD_TOKEN = "good-token"
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
class _FakeDoc:
def __init__(self, doc_id, data):
self.id = doc_id
self._data = data
def to_dict(self):
return dict(self._data)
class _FakeQuery:
def __init__(self, docs, field=None, value=None):
self._docs = docs
self._field = field
self._value = value
def where(self, field, op, value):
assert op == "=="
return _FakeQuery(self._docs, field, value)
def limit(self, _n):
return self
def stream(self):
return [d for d in self._docs if d.to_dict().get(self._field) == self._value]
class FakeFirestore:
"""Just enough of the Firestore client for app_users._load()."""
def __init__(self):
self.users: list[_FakeDoc] = []
self.reads = 0
def add_user(self, doc_id, **data):
self.users.append(_FakeDoc(doc_id, data))
def collection(self, name):
assert name == "users"
self.reads += 1
return _FakeQuery(self.users)
@pytest.fixture
def fs(monkeypatch):
db = FakeFirestore()
# Doc ID deliberately != uid, like Console-created users.
db.add_user("randomDocId1", uid=UID, status="active", device_serials=[SERIAL])
db.add_user(OTHER_UID, uid=OTHER_UID, status="blocked", device_serials=[FOREIGN_SERIAL])
monkeypatch.setattr(app_users, "get_db", lambda: db)
app_users.clear_cache()
yield db
app_users.clear_cache()
@pytest.fixture
def verify(monkeypatch):
"""Mock verify_id_token. Set `verify.result` to a dict or an exception."""
class _Verify:
result = {"uid": UID}
calls = []
def __call__(self, token, check_revoked=False):
self.calls.append((token, check_revoked))
if isinstance(self.result, Exception):
raise self.result
return self.result
v = _Verify()
v.calls = []
monkeypatch.setattr(mqtt_auth.firebase_auth, "verify_id_token", v)
return v
@pytest.fixture
def client():
app = FastAPI()
app.include_router(mqtt_auth.router)
return TestClient(app)
@pytest.fixture(autouse=True)
def _defaults(monkeypatch):
monkeypatch.setattr(settings, "mqtt_secret", "test-secret")
monkeypatch.setattr(settings, "mqtt_allow_legacy_password", True)
mqtt_auth._legacy_log_last.clear()
def auth_user(client, username, password, clientid="c1"):
return client.post(
"/mqtt/auth/user",
data={"username": username, "password": password, "clientid": clientid},
).status_code
def acl(client, username, topic, acc, clientid=APP_CLIENT):
return client.post(
"/mqtt/auth/acl",
data={"username": username, "topic": topic, "clientid": clientid, "acc": acc},
).status_code
# ---------------------------------------------------------------------------
# /mqtt/auth/user — devices
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("username", [SERIAL, FOREIGN_SERIAL, f"{FOREIGN_SERIAL}-kiosk"])
def test_device_hmac_ok(client, username):
assert auth_user(client, username, mqtt_auth._derive_password(username)) == 200
def test_device_hmac_wrong_password(client):
assert auth_user(client, SERIAL, "0" * 32) == 403
def test_device_hmac_of_other_serial_rejected(client):
assert auth_user(client, SERIAL, mqtt_auth._derive_password(FOREIGN_SERIAL)) == 403
def test_legacy_password_accepted_when_flag_on(client):
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 200
assert auth_user(client, f"{FOREIGN_SERIAL}-kiosk", "vesper") == 200
def test_legacy_password_rejected_when_flag_off(client, monkeypatch):
monkeypatch.setattr(settings, "mqtt_allow_legacy_password", False)
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 403
# HMAC still works with the flag off
assert auth_user(client, FOREIGN_SERIAL, mqtt_auth._derive_password(FOREIGN_SERIAL)) == 200
@pytest.mark.parametrize("username", ["somebody", "NodeRED", "pv25l22bp01r01", "PV-", "PV 25"])
def test_legacy_password_rejected_for_non_device_usernames(client, username):
assert auth_user(client, username, "vesper") == 403
def test_legacy_password_never_accepted_for_app_users(client, fs, verify):
verify.result = firebase_auth.InvalidIdTokenError("not a jwt")
assert auth_user(client, APP_USER, "vesper") == 403
def test_hmac_never_accepted_for_app_users(client, fs, verify):
verify.result = firebase_auth.InvalidIdTokenError("not a jwt")
assert auth_user(client, APP_USER, mqtt_auth._derive_password(APP_USER)) == 403
def test_legacy_login_logged_once_per_hour(client, caplog):
with caplog.at_level("WARNING", logger="mqtt.auth"):
for _ in range(3):
assert auth_user(client, FOREIGN_SERIAL, "vesper") == 200
assert auth_user(client, SERIAL, "vesper") == 200
legacy = [r for r in caplog.records if "legacy password" in r.getMessage()]
assert [r.getMessage().split(" for ")[1].split(" ")[0] for r in legacy] == [FOREIGN_SERIAL, SERIAL]
# ---------------------------------------------------------------------------
# /mqtt/auth/user — app users
# ---------------------------------------------------------------------------
def test_app_valid_token(client, fs, verify):
assert auth_user(client, APP_USER, GOOD_TOKEN) == 200
assert verify.calls == [(GOOD_TOKEN, True)] # check_revoked=True
def test_app_token_for_other_uid(client, fs, verify):
verify.result = {"uid": OTHER_UID}
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_revoked_token(client, fs, verify):
verify.result = firebase_auth.RevokedIdTokenError("revoked")
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_expired_token(client, fs, verify):
verify.result = firebase_auth.ExpiredIdTokenError("expired", cause=None)
assert auth_user(client, APP_USER, GOOD_TOKEN) == 403
def test_app_blocked_user(client, fs, verify):
verify.result = {"uid": OTHER_UID}
assert auth_user(client, f"app_{OTHER_UID}", GOOD_TOKEN) == 403
def test_app_unknown_user(client, fs, verify):
verify.result = {"uid": "ghost"}
assert auth_user(client, "app_ghost", GOOD_TOKEN) == 403
def test_app_empty_uid(client, fs, verify):
verify.result = {"uid": ""}
assert auth_user(client, "app_", GOOD_TOKEN) == 403
def test_app_deny_never_logs_token(client, fs, verify, caplog):
secret_token = "eyJhbGciOi.SECRET-TOKEN-VALUE.sig"
verify.result = {"uid": OTHER_UID}
with caplog.at_level("DEBUG"):
assert auth_user(client, APP_USER, secret_token) == 403
assert "token uid does not match" in caplog.text
assert secret_token not in caplog.text
# ---------------------------------------------------------------------------
# /mqtt/auth/acl — app users
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("leaf", ["control/ack", "status/heartbeat", "status/playback"])
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_read_subscribe_allowed(client, fs, leaf, acc):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", acc) == 200
def test_app_acl_publish_command_allowed(client, fs):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/command", mqtt_auth.ACC_WRITE) == 200
@pytest.mark.parametrize("leaf", ["control/ack", "status/heartbeat", "status/playback", "system/info"])
def test_app_acl_publish_other_topics_denied(client, fs, leaf):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", mqtt_auth.ACC_WRITE) == 403
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_SUBSCRIBE])
@pytest.mark.parametrize("leaf", ["control/command", "system/info", "system/alerts", "control/reports"])
def test_app_acl_read_subscribe_other_topics_denied(client, fs, acc, leaf):
assert acl(client, APP_USER, f"vesper/{SERIAL}/{leaf}", acc) == 403
@pytest.mark.parametrize("acc", [0, 3, 5, 8])
def test_app_acl_unknown_acc_denied(client, fs, acc):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/ack", acc) == 403
@pytest.mark.parametrize("topic", [
f"vesper/{SERIAL}/#",
f"vesper/{SERIAL}/status/+",
"vesper/+/control/ack",
"#",
])
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_WRITE, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_wildcards_denied(client, fs, topic, acc):
assert acl(client, APP_USER, topic, acc) == 403
@pytest.mark.parametrize("acc", [mqtt_auth.ACC_READ, mqtt_auth.ACC_WRITE, mqtt_auth.ACC_SUBSCRIBE])
def test_app_acl_foreign_serial_denied(client, fs, acc):
leaf = "control/command" if acc == mqtt_auth.ACC_WRITE else "control/ack"
assert acl(client, APP_USER, f"vesper/{FOREIGN_SERIAL}/{leaf}", acc) == 403
@pytest.mark.parametrize("clientid", [
"",
"phone1",
f"app_{UID}", # missing trailing "_"
f"app_{OTHER_UID}_x", # another user's prefix
f"app_{UID}x_phone", # uid prefix collision
])
def test_app_acl_wrong_clientid_denied(client, fs, clientid):
assert acl(client, APP_USER, f"vesper/{SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE, clientid=clientid) == 403
@pytest.mark.parametrize("topic", [
f"vesper/{SERIAL}/control/command/extra",
f"vesper/{SERIAL}/control",
f"other/{SERIAL}/control/command",
"vesper//control/command",
])
def test_app_acl_malformed_topics_denied(client, fs, topic):
assert acl(client, APP_USER, topic, mqtt_auth.ACC_WRITE) == 403
def test_app_acl_blocked_user_denied(client, fs):
user = f"app_{OTHER_UID}"
assert acl(client, user, f"vesper/{FOREIGN_SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE,
clientid=f"{user}_phone") == 403
def test_app_acl_unknown_user_denied(client, fs):
assert acl(client, "app_ghost", f"vesper/{SERIAL}/control/ack", mqtt_auth.ACC_SUBSCRIBE,
clientid="app_ghost_phone") == 403
def test_app_acl_uses_cache_and_invalidate_refreshes(client, fs):
topic = f"vesper/{SERIAL}/control/ack"
assert acl(client, APP_USER, topic, mqtt_auth.ACC_SUBSCRIBE) == 200
reads = fs.reads
for _ in range(5):
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
assert fs.reads == reads # served from cache
# Unassign the device; the cached entry still allows until invalidated.
fs.users[0]._data["device_serials"] = []
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
app_users.invalidate(UID)
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 403
def test_app_acl_cache_expires(client, fs, monkeypatch):
topic = f"vesper/{SERIAL}/control/ack"
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 200
fs.users[0]._data["device_serials"] = []
clock = app_users.time.monotonic() + app_users.CACHE_TTL_SECONDS + 1
monkeypatch.setattr(app_users.time, "monotonic", lambda: clock)
assert acl(client, APP_USER, topic, mqtt_auth.ACC_READ) == 403
# ---------------------------------------------------------------------------
# /mqtt/auth/acl — devices (unchanged behaviour)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("acc", [1, 2, 4])
def test_device_acl_own_topics(client, acc):
assert acl(client, SERIAL, f"vesper/{SERIAL}/status/heartbeat", acc, clientid="") == 200
@pytest.mark.parametrize("acc", [1, 2, 4])
def test_device_acl_foreign_topics_denied(client, acc):
assert acl(client, SERIAL, f"vesper/{FOREIGN_SERIAL}/status/heartbeat", acc, clientid="") == 403
def test_kiosk_acl_base_device_topics(client):
assert acl(client, f"{FOREIGN_SERIAL}-kiosk", f"vesper/{FOREIGN_SERIAL}/control/command", 2, clientid="") == 200
assert acl(client, f"{FOREIGN_SERIAL}-kiosk", f"vesper/{SERIAL}/control/command", 2, clientid="") == 403
def test_superuser_acl(client):
assert acl(client, "NodeRED", "vesper/#", 4, clientid="") == 200
+6 -2
View File
@@ -4,7 +4,7 @@ from typing import List, Optional
# --- Request / Response schemas ---
class UserCreate(BaseModel):
class UserProfile(BaseModel):
email: str = ""
display_name: str = ""
photo_url: str = ""
@@ -17,6 +17,10 @@ class UserCreate(BaseModel):
quickSettingsPIN: str = ""
class UserCreate(UserProfile):
password: str = "" # request-only — used to create the Firebase Auth account, never persisted to Firestore or echoed back
class UserUpdate(BaseModel):
email: Optional[str] = None
display_name: Optional[str] = None
@@ -29,7 +33,7 @@ class UserUpdate(BaseModel):
quickSettingsPIN: Optional[str] = None
class UserInDB(UserCreate):
class UserInDB(UserProfile):
id: str
created_time: str = ""
lastActive: str = ""
+111 -21
View File
@@ -1,6 +1,6 @@
from datetime import datetime
from datetime import datetime, timezone
from google.cloud.firestore_v1 import DocumentReference
from google.cloud.firestore_v1 import DocumentReference, ArrayUnion, ArrayRemove
from firebase_admin import auth as firebase_auth
from shared.firebase import get_db, get_bucket
@@ -9,6 +9,37 @@ from users.models import UserCreate, UserUpdate, UserInDB
COLLECTION = "users"
# Firestore "in" queries accept at most 30 values.
_IN_QUERY_LIMIT = 30
def device_serial_of(device_data: dict) -> str:
"""The board serial as used in MQTT topics (vesper/{serial}/...).
`serial_number` is what gets flashed into NVS and what the firmware uses
as its MQTT username/topic id. Legacy docs predating the lifecycle system
only carry it in `device_id`.
"""
return (device_data.get("serial_number") or device_data.get("device_id") or "").strip()
def stage_device_serial_link(batch, user_ref: DocumentReference, serial: str, linked: bool) -> None:
"""Add (linked=True) or remove the serial from the user's `device_serials`
inside the caller's write batch, so it commits together with the device's
`user_list` change."""
if not serial:
return
op = ArrayUnion([serial]) if linked else ArrayRemove([serial])
batch.update(user_ref, {"device_serials": op})
def invalidate_mqtt_acl_cache(user_data: dict | None) -> None:
"""Drop the MQTT ACL cache entry for this user's Firebase uid."""
uid = (user_data or {}).get("uid") or ""
if uid:
from mqtt.app_users import invalidate
invalidate(uid)
def _convert_firestore_value(val):
"""Convert Firestore-specific types (Timestamp, DocumentReference) to strings."""
@@ -84,15 +115,46 @@ def get_user(user_doc_id: str) -> UserInDB:
def create_user(data: UserCreate) -> UserInDB:
"""Create a new user document in Firestore."""
"""Create a new user: a Firebase Auth account (so they can log into the app
immediately) plus the matching Firestore profile document.
The profile doc ID MUST be the Firebase Auth uid. The FlutterFlow app looks
up `users/{uid}` on login and creates a bare doc there if it's missing, and
MQTT auth (mqtt/app_users.py) + device `user_list` paths assume the same.
"""
if not data.password or len(data.password) < 6:
raise ValidationError("Password must be at least 6 characters.")
# Firebase Auth lowercases emails; store the same form so the profile
# matches what the app sees for the signed-in user.
email = (data.email or "").strip().lower()
if not email:
raise ValidationError("Email is required.")
db = get_db()
doc_data = data.model_dump()
doc_data = data.model_dump(exclude={"password"})
doc_data["email"] = email
doc_data["friendsList"] = []
doc_data["friendsInvited"] = []
doc_data["created_time"] = datetime.now(timezone.utc)
_, doc_ref = db.collection(COLLECTION).add(doc_data)
try:
firebase_user = firebase_auth.create_user(
email=email,
password=data.password,
display_name=data.display_name or None,
)
except firebase_auth.EmailAlreadyExistsError:
raise ValidationError(f"A user with email {email} already exists.")
doc_data["uid"] = firebase_user.uid
return UserInDB(id=doc_ref.id, **doc_data)
try:
db.collection(COLLECTION).document(firebase_user.uid).set(doc_data)
except Exception:
# Don't leave an Auth account behind with no profile doc.
firebase_auth.delete_user(firebase_user.uid)
raise
return UserInDB(id=firebase_user.uid, **_sanitize_dict(doc_data))
def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
@@ -105,20 +167,29 @@ def update_user(user_doc_id: str, data: UserUpdate) -> UserInDB:
update_data = data.model_dump(exclude_none=True)
doc_ref.update(update_data)
invalidate_mqtt_acl_cache(doc.to_dict()) # status may have changed
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
def delete_user(user_doc_id: str) -> None:
"""Delete a user document from Firestore."""
"""Delete a user document from Firestore, plus their Firebase Auth account if one exists."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(user_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("User")
uid = doc.to_dict().get("uid", "")
if uid:
try:
firebase_auth.delete_user(uid)
except firebase_auth.UserNotFoundError:
pass
doc_ref.delete()
invalidate_mqtt_acl_cache(doc.to_dict())
def block_user(user_doc_id: str) -> UserInDB:
@@ -130,6 +201,7 @@ def block_user(user_doc_id: str) -> UserInDB:
raise NotFoundError("User")
doc_ref.update({"status": "blocked"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
@@ -143,6 +215,7 @@ def unblock_user(user_doc_id: str) -> UserInDB:
raise NotFoundError("User")
doc_ref.update({"status": "active"})
invalidate_mqtt_acl_cache(doc.to_dict())
updated_doc = doc_ref.get()
return _doc_to_user(updated_doc)
@@ -179,9 +252,15 @@ def assign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
already_assigned = True
break
# Device user_list and the user's device_serials are written in one atomic batch.
# device_serials is re-synced even when already assigned, to heal drift.
batch = db.batch()
if not already_assigned:
user_list.append(user_path)
device_ref.update({"user_list": user_list})
batch.update(device_ref, {"user_list": user_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=True)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
@@ -215,13 +294,17 @@ def unassign_device(user_doc_id: str, device_doc_id: str) -> UserInDB:
elif entry != user_path:
new_list.append(entry)
device_ref.update({"user_list": new_list})
batch = db.batch()
batch.update(device_ref, {"user_list": new_list})
stage_device_serial_link(batch, user_ref, device_serial_of(device_data), linked=False)
batch.commit()
invalidate_mqtt_acl_cache(user_doc.to_dict())
return _doc_to_user(user_ref.get())
def get_user_devices(user_doc_id: str) -> list[dict]:
"""Get all devices assigned to a user."""
"""Get all devices assigned to a user, via the user's `device_serials`."""
db = get_db()
# Verify user exists
@@ -230,17 +313,25 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
if not user_doc.exists:
raise NotFoundError("User")
user_path = f"users/{user_doc_id}"
serials = [s for s in dict.fromkeys(user_doc.to_dict().get("device_serials") or []) if s]
# Match on serial_number first; fall back to the legacy device_id field for the rest.
found: dict[str, object] = {}
for field in ("serial_number", "device_id"):
remaining = [s for s in serials if s not in found]
for i in range(0, len(remaining), _IN_QUERY_LIMIT):
chunk = remaining[i:i + _IN_QUERY_LIMIT]
for doc in db.collection("devices").where(field, "in", chunk).stream():
sn = doc.to_dict().get(field)
if sn not in found:
found[sn] = doc
# Search all devices for this user in their user_list
devices = []
for doc in db.collection("devices").stream():
for sn in serials:
doc = found.get(sn)
if doc is None:
continue
data = doc.to_dict()
user_list = data.get("user_list", [])
for entry in user_list:
entry_path = entry.path if isinstance(entry, DocumentReference) else entry
if entry_path == user_path:
devices.append({
"id": doc.id,
"device_name": data.get("device_name", ""),
@@ -248,7 +339,6 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
"device_location": data.get("device_location", ""),
"is_Online": data.get("is_Online", False),
})
break
return devices
@@ -256,7 +346,7 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
def set_password(user_doc_id: str, new_password: str) -> None:
"""Set a Firebase Auth password for a user via their Firestore document ID.
Requires the user document to have a non-empty `uid` field — populated
Requires the user document to have a non-empty `uid` field — populated
automatically for users who registered via the Flutter app.
"""
if not new_password or len(new_password) < 6:
@@ -270,7 +360,7 @@ def set_password(user_doc_id: str, new_password: str) -> None:
uid = doc.to_dict().get("uid", "")
if not uid:
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
raise ValidationError("This user has no Firebase Auth UID — they may not have signed up via the app yet.")
try:
firebase_auth.update_user(uid, password=new_password)
+18
View File
@@ -0,0 +1,18 @@
# Windows / Docker Desktop dev machines only — copy to docker-compose.override.yml
# (gitignored; Docker Compose loads it automatically). The server does NOT use this.
#
# Why: a ./data/postgres bind mount onto the Windows filesystem hits a
# WSL2/Docker Desktop bug where the 9p/virtiofs bridge reports normal
# postgres:postgres 0600 ownership but the kernel still refuses the postgres
# user's own open() calls for write — silent on read, fatal on any WAL write.
# A Docker-managed named volume lives inside the WSL2 VM's own filesystem and
# avoids it. Create it once with: docker volume create bellsystems-postgres-data
services:
postgres:
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:
name: bellsystems-postgres-data
external: true
+6
View File
@@ -2,6 +2,7 @@ services:
backend:
build: ./backend
container_name: bellsystems-backend
restart: unless-stopped
env_file: .env
volumes:
- ./backend:/app
@@ -22,12 +23,14 @@ services:
frontend:
build: ./frontend
container_name: bellsystems-frontend
restart: unless-stopped
networks:
- internal
nginx:
image: nginx:alpine
container_name: bellsystems-nginx
restart: unless-stopped
ports:
- "90:80" # access v2 on localhost:8001
volumes:
@@ -47,6 +50,9 @@ services:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
# Server default: plain folder on the host (Linux, no WSL2 issues).
# Windows dev machines override this with a Docker volume in
# docker-compose.override.yml — see docker-compose.override.example.yml.
- ./data/postgres:/var/lib/postgresql/data
networks:
- internal
+220
View File
@@ -0,0 +1,220 @@
# MQTT Authentication for Phone-App Users
## Why this exists
The remote phone app (FlutterFlow + Firebase Auth) talks to Vesper boards over MQTT.
We did not want to create an MQTT account per app user, so app users log into
Mosquitto with their **Firebase identity**, and the Console backend decides what
each one may do, based on which devices are assigned to them.
Mosquitto uses **mosquitto-go-auth** with two backends:
- **files** (passwd file on the VPS): `admin`, `bonamin`, `NodeRED`.
- **http** (this Console): `POST /mqtt/auth/user` (on CONNECT) and
`POST /mqtt/auth/acl` (on SUBSCRIBE, PUBLISH and every message delivery).
Code: `backend/mqtt/auth.py`, `backend/mqtt/app_users.py`.
The Mosquitto / go-auth config lives on the VPS, **not in this repo**.
## Connection contract for the app (read this if you build the app)
| Setting | Value |
|------------|------------------------------------------------------------------------|
| Username | `app_<firebase_uid>` |
| Password | The user's current **Firebase ID token** (`getIdToken()`) |
| Client ID | Must start with `app_<firebase_uid>_`, e.g. `app_<uid>_<installId>` |
| Transport | **MQTT over secure WebSockets**: `wss://mqtt.bellsystems.net:443`, path `/mqtt` |
| Wildcards | Not allowed (`+` / `#` are denied) |
**Never connect the app to port 1883.** It is plain TCP for the boards only. The broker
would still check the login there, but by then the token has already been sent
unencrypted. Keep the MQTT keepalive below 3600 s (e.g. 30–60 s).
Any other client ID is refused on every topic. The prefix rule stops one user from
reusing another user's client ID to kick them off.
Allowed topics, only for serials assigned to that user:
| Action | `acc` | Topics |
|---------------------------------|-------|------------------------------------------------------------------------|
| Publish | 2 | `vesper/{serial}/control/command` |
| Subscribe / receive messages | 4 / 1 | `vesper/{serial}/control/ack`, `vesper/{serial}/status/heartbeat`, `vesper/{serial}/status/playback` |
Everything else is denied (403).
Token lifetime: Mosquitto checks the password **only at CONNECT**. When the app
reconnects, it must fetch a fresh ID token (`getIdToken()` refreshes an expired one).
A connection stays open past token expiry. Blocking or unassigning still takes effect
on that connection, because every publish or delivery goes through the ACL (within
about 60 s of the go-auth ACL cache).
## Which field is the device serial
The serial in `vesper/{serial}/...` is the device doc's **`serial_number`**: it is
flashed into NVS and the firmware uses it as its MQTT id. Legacy docs only have it in
**`device_id`**. `users.service.device_serial_of()` holds that rule; always use it.
## How access is decided
- `users/{doc}.device_serials: [string]` lists the serials a user may reach.
It mirrors the devices' `user_list` and is written in the **same atomic Firestore
batch** by every Console path that edits `user_list`:
- `users.service.assign_device` / `unassign_device`
- `POST` / `DELETE /api/devices/{id}/user-list` (device Manage tab)
- `PUT /api/devices/{id}` when the body contains `user_list`
(`devices.service.update_device`)
- Users are resolved by the **`uid` field** (a query), **never by doc ID**. Console-
created users used to get random doc IDs (`.add()`) — fixed 2026-09-30, the Console
now writes `users/{uid}` like FlutterFlow does — but older docs may still have a
random ID, so keep resolving by the `uid` field.
- A user is refused when `status == "blocked"`.
- Lookups are cached in-process for 60 s (`mqtt/app_users.py`). Assign/unassign,
update, block/unblock and delete call `invalidate(uid)`. If you add a new code path
that changes `user_list`, `device_serials` or `status`, it must do the same.
The cache is per process: if uvicorn ever runs multiple workers, invalidation only
reaches one of them (entries still expire after 60 s).
- `app_` usernames never go through the device HMAC or the legacy password.
## Legacy "vesper" password
Boards on pre-HMAC firmware still use the shared password `vesper`.
`MQTT_ALLOW_LEGACY_PASSWORD` (default `true`) controls it. It is only accepted for
device-shaped usernames. Each use is logged at WARNING, once per board per hour:
MQTT legacy password accepted for <serial> — board still on pre-HMAC firmware
When those lines stop appearing, set `MQTT_ALLOW_LEGACY_PASSWORD=false`.
## Rollout checklist
1. Deploy the backend.
2. **Backfill** `device_serials` for existing assignments (run it right after the deploy):
cd backend
python scripts/backfill_user_device_serials.py # dry run, writes nothing
python scripts/backfill_user_device_serials.py --apply # writes
It only writes the `device_serials` field on `users` docs. It is idempotent: a second
run finds nothing to change. Until it runs, the Console shows existing users with no
devices, and app users are denied everything.
3. **go-auth config on the VPS** (R = required, r = recommended):
auth_opt_backends files, http # R files first, then http
auth_opt_http_host <host reaching the backend> # R
auth_opt_http_port 8000 # R
auth_opt_http_getuser_uri /mqtt/auth/user # R
auth_opt_http_aclcheck_uri /mqtt/auth/acl # R
auth_opt_http_params_mode form # R endpoints read form fields; go-auth's default is json
auth_opt_http_response_mode status # r
auth_opt_http_method POST # r
auth_opt_http_timeout 5 # r
auth_opt_disable_superuser true # r no superuser endpoint exists or is needed
auth_opt_cache true # r
auth_opt_cache_type go-cache # r
auth_opt_auth_cache_seconds 60 # r
auth_opt_acl_cache_seconds 60 # r
auth_opt_auth_jitter_seconds 10 # r
auth_opt_acl_jitter_seconds 10 # r
allow_anonymous false # R
4. **Files-backend ACL: verified OK (2026-09-30).** The VPS config sets no
`auth_opt_files_acl_path` (nor `acl_file`). A live test confirmed the files backend
does **not** grant ACLs to everyone, so every ACL check reaches the Console. If an
ACL file is ever added, only lines under `user admin` / `user bonamin` /
`user NodeRED` are safe. A `pattern` line, or a `topic` line placed before the first
`user` line, applies to every user (app users included) and must not cover `vesper/...`.
Re-run the isolation test after any broker config change (on the VPS host):
A=PV25L22BP01R01; B=PV26B02BP01R01
PW=$(docker exec bellsystems-backend python -c "from mqtt.auth import _derive_password; print(_derive_password('$A'))")
# own topics -> should print messages
docker exec mosquitto mosquitto_sub -h localhost -i acl-test-own -u "$A" -P "$PW" -t "vesper/$A/#" -v -W 5
# foreign topics -> must say "All subscription requests were denied."
docker exec mosquitto mosquitto_sub -h localhost -i acl-test-foreign -u "$A" -P "$PW" -t "vesper/$B/#" -v -W 5
5. **TLS for the app: done (2026-09-30).** See "Current VPS broker setup" below.
Test it from the VPS host (should print `connect: Success` and a `got:` line):
docker exec -i bellsystems-backend python - <<'EOF'
import time, paho.mqtt.client as m
from mqtt.auth import _derive_password
A = "PV25L22BP01R01"
c = m.Client(m.CallbackAPIVersion.VERSION2, client_id="wss-test", transport="websockets")
c.ws_set_options(path="/mqtt")
c.tls_set()
c.username_pw_set(A, _derive_password(A))
c.on_connect = lambda c, u, f, rc, p: (print("connect:", rc), c.subscribe(f"vesper/{A}/#"))
c.on_message = lambda c, u, msg: print("got:", msg.topic)
c.connect("mqtt.bellsystems.net", 443, 30)
c.loop_start(); time.sleep(8); c.loop_stop()
EOF
### Current VPS broker setup (as of 2026-09-30)
- Container `mosquitto`, image `iegomez/mosquitto-go-auth:latest`.
- Config: host `/home/bellsystems/stacks/mosquitto/config/mosquitto.conf`, mounted at
`/etc/mosquitto/mosquitto.conf` (Docker images usually use `/mosquitto/config`; this
one does not). Passwd file: `/home/bellsystems/stacks/mosquitto/passwd`.
- Already set correctly: `backends files,http`, `http_host 172.20.0.1` (Docker bridge),
`http_port 8000`, both URIs, `http_method post`, `params_mode form`,
`response_mode status`, `cache true`, `cache_reset true`.
- `allow_anonymous false` ✓. `auth_cache_seconds 60`, `acl_cache_seconds 60` ✓.
`auth_opt_hasher` is not set (a harmless "defaulting to PBKDF2" warning at start).
- Listeners:
- `listener 1883 0.0.0.0`: plain TCP, **boards only** (ESP32 can't spare RAM for TLS).
Published on the host as `1883:1883`.
- `listener 8083` + `protocol websockets`: plain WebSockets, **not published** on the
host. Only reachable from the Docker network `npm_npmnet`.
- TLS is terminated by **NPM** (NPMplus, container `npm-npm-1`), which also renews the
Let's Encrypt certificate. Proxy host: `mqtt.bellsystems.net` → `http://mosquitto:8083`,
Force SSL + HTTP/2 on. Advanced config:
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
(Without these, NPM drops idle MQTT connections after 60 s.) NPMplus has no
"Websockets Support" toggle; WebSockets are always on.
- Compose file: `/home/bellsystems/stacks/mosquitto/docker-compose.yml`. The service is
on `default` (reaches the backend at `172.20.0.1:8000`) **and** the external network
`npm_npmnet` (so NPM can reach `mosquitto:8083`). Removing `default` breaks all
board logins.
- Rollback for the TLS change: `.bak` copies of `mosquitto.conf` and `docker-compose.yml`
sit next to the originals. Restore them, run `docker compose up -d` in that folder,
then delete the NPM proxy host.
## Known gaps / decisions
- **Console backend port 8000 is public.** It is published as `0.0.0.0:8000` and the VPS
has no firewall (Docker-published ports bypass host firewall rules anyway), so anyone
can call `/mqtt/auth/user` and `/mqtt/auth/acl`. The passwords can't realistically be
guessed, so it's not urgent, but these endpoints are meant only for Mosquitto.
To be fixed separately, carefully, so nginx/NPM keep reaching the Console.
- **Old-firmware boards use a different command topic.** Broker logs (2026-09-30) show
some boards (e.g. `PV26B02BP01R01`, `BSVSPR-26C20B-STD10R-2KCDPH`) subscribing to
`vesper/{serial}/control`, not `vesper/{serial}/control/command`. The app ACL only
allows publishing to `control/command`, so the app cannot command those boards until
they run current firmware. (The Console's own `publish_command` has the same limitation.)
Do not widen the ACL to `control` without deciding it deliberately.
- **FlutterFlow writes to `user_list`.** If the app changes `user_list` directly in
Firestore (e.g. a claim flow), it must also update `device_serials` the same way
(ArrayUnion / ArrayRemove of the serial on the user doc). Otherwise that user
cannot reach the device over MQTT. The app owner will handle this in the app.
- **The `device_users` subcollection** on devices (older way of recording device users,
read first by `devices.service.get_device_users`) is **intentionally ignored** by
the backfill and the MQTT ACL. Left as is by decision.
- The two Console assignment paths store `user_list` entries in different formats
(path strings vs DocumentReferences). This predates this work; all readers accept both.
- ACL denies are logged at INFO, which is not shown by default. Auth denies and
legacy-password logins are WARNING.
## Tests
cd backend
pip install pytest # not in requirements.txt
python -m pytest tests
`tests/test_mqtt_auth.py` covers the auth and ACL endpoints.
`tests/test_device_serials_sync.py` covers the device PUT sync.
Firebase and Firestore are mocked.
+14 -3
View File
@@ -9,6 +9,7 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
import api from '@/lib/api'
import HeaderSearch from '@/components/ui/HeaderSearch'
import { getLocalizedValue } from '@/lib/melodyUtils'
// ─── Breadcrumb config ────────────────────────────────────────────────────────
@@ -52,10 +53,10 @@ const ENTITY_RESOLVERS = {
customers: { path: (id) => `/crm/customers/${id}`, label: (d) => [d.name, d.surname].filter(Boolean).join(' ') || d.organization || null },
products: { path: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
quotations: { path: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || null },
devices: { path: (id) => `/devices/${id}`, label: (d) => d.name || d.device_id || null },
devices: { path: (id) => `/devices/${id}`, label: (d) => d.console_name || d.device_name || d.device_id || null },
orders: { path: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
melodies: { path: (id) => `/melodies/${id}`, label: (d) => d.name || null },
archetypes: { path: (id) => `/builder/melodies/${id}`, label: (d) => d.name || null },
melodies: { path: (id) => `/melodies/${id}`, label: (d) => getLocalizedValue(d.information?.name, 'en', '') || null },
archetypes: { path: (id) => `/builder/melodies/${id}`, label: (d) => getLocalizedValue(d.information?.name, 'en', '') || null },
users: { path: (id) => `/users/${id}`, label: (d) => d.name || d.display_name || null },
notes: { path: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
staff: { path: (id) => `/staff/${id}`, label: (d) => d.name || null },
@@ -292,6 +293,16 @@ const SETTINGS_ITEMS = [
</svg>
),
},
{
to: '/settings/log-retention',
label: 'Log Retention',
icon: (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="8" cy="8" r="6"/>
<path d="M8 5v3l2 1.5"/>
</svg>
),
},
{
to: '/settings/pages',
label: 'Page Settings',
@@ -68,6 +68,7 @@ const Icons = {
commandCenter: () => <S><rect x="2" y="2" width="12" height="12" rx="1.5"/><path d="M5 6l2 2-2 2M9 10h2"/></S>,
blackBox: () => <SvgIcon Component={IcoBlackbox} />,
deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />,
onboard: () => <S><path d="M5 8l2 2 4-4"/><rect x="2" y="2" width="12" height="12" rx="2"/></S>,
appUsers: () => <S><circle cx="6" cy="5" r="2.5"/><path d="M2 13c0-2.2 1.8-4 4-4"/><circle cx="12" cy="7" r="2"/><path d="M9.5 13c0-1.65 1.12-3 2.5-3s2.5 1.35 2.5 3"/></S>,
melodies: () => <SvgIcon Component={IcoMelodies} />,
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
@@ -132,6 +133,7 @@ const navSections = [
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
{ to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true },
{ to: '/devices/onboard', label: 'Onboard', icon: 'onboard' },
],
},
{ to: '/users', label: 'App Users', icon: 'appUsers', permission: 'app_users' },
@@ -0,0 +1,71 @@
// frontend/src/components/shared/CopyableSerial.jsx
// Serial number display with click-to-copy — the whole text is clickable,
// not just the trailing icon, and the icon stays dimly visible at rest so
// its affordance isn't hover-only-discoverable (it was previously opacity:0
// at rest via an inline style, which beat the hover CSS rule and made the
// button invisible at all times).
import { useState } from 'react'
import Icon from '@/components/ui/Icon'
function copyText(text, onDone) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onDone).catch(() => {
const ta = document.createElement('textarea')
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(ta); ta.select()
try { document.execCommand('copy'); onDone() } catch { /* clipboard unavailable */ }
document.body.removeChild(ta)
})
}
}
export default function CopyableSerial({ serial, className = '', style = {}, textStyle = {}, iconSize = 12 }) {
const [copied, setCopied] = useState(false)
if (!serial) return <span className={className} style={{ ...textStyle, ...style }}>—</span>
const handleCopy = () => {
copyText(serial, () => { setCopied(true); setTimeout(() => setCopied(false), 1500) })
}
return (
<button
type="button"
onClick={handleCopy}
title={copied ? 'Copied!' : 'Click to copy serial number'}
aria-label={copied ? 'Serial number copied' : 'Copy serial number'}
className={['copyable-serial', className].filter(Boolean).join(' ')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
font: 'inherit',
color: 'inherit',
...style,
}}
>
<span style={textStyle}>{serial}</span>
<Icon
name={copied ? 'check' : 'copy'}
size={iconSize}
color={copied ? 'var(--color-success)' : 'currentColor'}
className="copyable-serial__icon"
/>
<style>{`
.copyable-serial .copyable-serial__icon {
opacity: 0.45;
transition: opacity 0.15s, color 0.15s;
}
.copyable-serial:hover .copyable-serial__icon,
.copyable-serial:focus-visible .copyable-serial__icon {
opacity: 1;
}
`}</style>
</button>
)
}
@@ -0,0 +1,200 @@
// frontend/src/components/shared/CrashDetail.jsx
// Crash detail for one fault boot event (firmware F-070): headline, task,
// uptime + heap snapshot at crash, PC/backtrace in ESP serial format, a
// ready-to-run addr2line command, the crashing build's ELF id, and — when the
// server has that build's firmware.elf and the xtensa addr2line binary —
// in-console decoding to function + file:line.
//
// Used by the device Health tab's boot history and the fleet Crash Report.
//
// Props:
// event — a boot event from /mqtt/boot-events (crash / pre_crash objects,
// or only the legacy crash_* columns for older firmware)
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import { copyText } from '@/lib/clipboard'
import {
crashOf, preCrashOf, crashHeadline, excCauseName, hex32, isHeapExhaustion,
addressLine, addr2lineCommand, decodeAddresses, fmtBytes, fmtUptime,
} from '@/lib/crashReport'
const labelStyle = {
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)',
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)',
}
const monoBlockStyle = {
flex: 1, minWidth: 0, margin: 0,
padding: 'var(--space-2) var(--space-3)',
background: 'var(--color-bg-abyss)', border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-primary)',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}
function Field({ label, value, muted, mono = true }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
<span style={labelStyle}>{label}</span>
<span style={{
fontSize: 'var(--font-size-sm)',
fontFamily: mono ? 'var(--font-family-mono)' : undefined,
color: muted ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
wordBreak: 'break-all',
}}>
{value ?? '—'}
</span>
</div>
)
}
function CopyRow({ label, text }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
copyText(text).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={labelStyle}>{label}</span>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)' }}>
<pre style={monoBlockStyle}>{text}</pre>
<Button
variant="ghost"
size="sm"
icon={<Icon name={copied ? 'check' : 'copy'} size={14} />}
onClick={handleCopy}
aria-label={`Copy ${label}`}
>
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
</div>
)
}
function DecodePanel({ event, elfSha256 }) {
const [info, setInfo] = useState(null) // /firmware/elf/{sha}
const [results, setResults] = useState(null) // decoded frames
const [decoding, setDecoding] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setInfo(null); setResults(null); setError('')
api.get(`/firmware/elf/${elfSha256}`)
.then(res => { if (!cancelled) setInfo(res) })
.catch(() => { if (!cancelled) setInfo({ exists: false, decoder_available: false, unavailable: true }) })
return () => { cancelled = true }
}, [elfSha256])
const handleDecode = async () => {
setDecoding(true); setError('')
try {
const res = await api.post(`/firmware/elf/${elfSha256}/decode`, { addresses: decodeAddresses(event) })
setResults(res.results || [])
} catch (err) {
setError(err.message || 'Decoding failed.')
} finally {
setDecoding(false)
}
}
if (!info) return null
let note = null
if (info.unavailable) note = 'Could not check the symbol store for this build.'
else if (!info.exists) note = `No firmware.elf uploaded for build ${elfSha256}. Attach it to the firmware release to decode here — or use the command above.`
else if (!info.decoder_available) note = 'The ELF for this build is on the server, but the xtensa addr2line decoder is not installed there — use the command above.'
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<span style={labelStyle}>Decoded backtrace</span>
{note ? (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{note}</span>
) : results ? (
results.length === 0 ? (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>No addresses to decode.</span>
) : (
<pre style={monoBlockStyle}>
{results.map(r => `${r.address}: ${r.frames.join('\n ')}`).join('\n')}
</pre>
)
) : (
<div>
<Button variant="secondary" size="sm" onClick={handleDecode} loading={decoding}>
Decode with firmware.elf
</Button>
</div>
)}
{error && <span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</span>}
</div>
)
}
export default function CrashDetail({ event }) {
const crash = crashOf(event)
const pre = preCrashOf(event)
if (!crash && !pre) return null
const abortMsg = pre?.abort_msg || null
const headline = crashHeadline(event)
const heapLow = isHeapExhaustion(pre)
const addrs = addressLine(crash)
const command = addr2lineCommand(event)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', minWidth: 0 }}>
{/* Headline — abort_msg when present, else the exception cause name */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<span style={{
fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-md)',
fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-danger)', wordBreak: 'break-word',
}}>
{headline || 'Fault reset (no coredump)'}
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{heapLow && <StatusBadge variant="warning" size="sm">Likely heap exhaustion / fragmentation</StatusBadge>}
{crash?.backtraceCorrupted && <StatusBadge variant="warning" size="sm">Backtrace corrupted</StatusBadge>}
{!crash && <StatusBadge variant="neutral" size="sm">No coredump</StatusBadge>}
</div>
{abortMsg && crash && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
This was an abort. The PC and exception below describe the abort() mechanism, not the cause,
and the first 2–4 backtrace frames are abort internals — the real call site is further down.
</span>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 'var(--space-3)' }}>
<Field label="Faulting task" value={crash?.task} />
<Field label="Uptime at crash" value={pre ? fmtUptime(pre.uptime_ms) : null} />
<Field label="Free heap" value={pre ? fmtBytes(pre.free_heap) : null} />
<Field label="Largest free block" value={pre ? fmtBytes(pre.largest_block) : null} />
<Field label="Min free heap" value={pre ? fmtBytes(pre.min_free_heap) : null} />
{crash && (
<>
<Field
label="Exception"
value={crash.excCause != null ? `${excCauseName(crash.excCause)} (${crash.excCause})` : null}
muted={!!abortMsg}
/>
<Field label="Program counter" value={hex32(crash.pc)} muted={!!abortMsg} />
<Field label="Faulting address" value={hex32(crash.excVaddr)} muted={!!abortMsg} />
<Field label="Build (ELF SHA-256)" value={crash.elfSha256} />
</>
)}
</div>
{addrs && <CopyRow label="PC + backtrace" text={addrs} />}
{command && <CopyRow label="addr2line command" text={command} />}
{crash?.elfSha256 && command && <DecodePanel event={event} elfSha256={crash.elfSha256} />}
</div>
)
}
+123
View File
@@ -0,0 +1,123 @@
// src/components/ui/EditableText.jsx
// Inline hover-to-edit text — shows a pencil icon on hover, click swaps to a
// text input in place. Enter/blur saves, Escape cancels. Use for a single
// display value that's editable in-place without a modal (e.g. a page hero
// title) — for anything with more than one field, use a Modal instead.
//
// Props:
// value — string — current display value
// placeholder — string — shown (italic, muted) when value is empty
// onSave — (newValue) => void | Promise — called on confirm; not called if unchanged
// editable — bool — hide the pencil / disable editing entirely (default: true)
// textStyle — style object applied to the display span AND the input, so
// swapping between them doesn't shift surrounding layout
// className — extra class on the root element
// inputAriaLabel — string — accessible label for the input while editing
import { useState, useRef, useEffect } from 'react'
import Icon from '@/components/ui/Icon'
export default function EditableText({
value,
placeholder = 'Untitled',
onSave,
editable = true,
textStyle = {},
className = '',
inputAriaLabel = 'Edit value',
}) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(value || '')
const [saving, setSaving] = useState(false)
const inputRef = useRef(null)
useEffect(() => {
if (editing) {
setDraft(value || '')
// Wait a tick so the input exists before focusing.
requestAnimationFrame(() => inputRef.current?.select())
}
}, [editing, value])
const commit = async () => {
const trimmed = draft.trim()
setEditing(false)
if (trimmed === (value || '')) return
setSaving(true)
try {
await onSave?.(trimmed)
} finally {
setSaving(false)
}
}
const cancel = () => {
setDraft(value || '')
setEditing(false)
}
if (editing) {
return (
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); commit() }
if (e.key === 'Escape') { e.preventDefault(); cancel() }
}}
aria-label={inputAriaLabel}
disabled={saving}
className={className}
style={{
...textStyle,
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border-strong)',
borderRadius: 'var(--radius-sm)',
padding: '1px 6px',
outline: 'none',
minWidth: '120px',
}}
/>
)
}
return (
<span
className={['editable-text', className].filter(Boolean).join(' ')}
style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}
>
<span style={{ ...textStyle, ...(value ? {} : { color: 'var(--color-text-muted)', fontStyle: 'italic' }) }}>
{value || placeholder}
</span>
{editable && (
<button
type="button"
onClick={() => setEditing(true)}
aria-label={inputAriaLabel}
title={inputAriaLabel}
className="editable-text__pencil"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 24,
height: 24,
padding: 0,
border: 'none',
borderRadius: 'var(--radius-sm)',
background: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
opacity: 0,
transition: 'opacity 0.15s, color 0.15s, background 0.15s',
}}
>
<Icon name="edit" size={14} />
</button>
)}
</span>
)
}
+514
View File
@@ -0,0 +1,514 @@
// src/components/ui/LineChart.jsx
// Time-series line chart — hand-rolled SVG, no charting library. Single primitive
// reused for every metric-over-time graph in the console (RSSI, ping latency,
// free heap, etc). Never build a one-off inline chart in a page — use this.
//
// Props:
// data — array<{ x: number|string (ms epoch or index), y: number|null }>
// null y-values render as gaps (device offline / no sample), not zeros.
// color — CSS color token, e.g. 'var(--color-primary)' (default: 'var(--color-primary)')
// Ignored if colorStops is set.
// colorStops — array<{ value: number, color: string }>, sorted ascending by
// value — colors the line/fill as a smooth vertical gradient
// keyed to the Y VALUE (not x-position), so "danger red" always
// means the same y-value everywhere on the chart regardless of
// when it occurred. Requires >= 2 stops.
// height — number, px (default: 160)
// formatValue — (y) => string — used in tooltip + trailing value badge
// formatX — (x) => string — used in tooltip
// unit — string appended after formatValue in the trailing badge, e.g. 'dBm', 'ms', 'KB'
// xMin, xMax — number (ms epoch) — explicit axis bounds. When provided, the
// chart renders this FULL span even if data covers less of it
// (e.g. a 30-day range with 1 day of real data shows the data
// bunched at one end, not silently zoomed to fit). Omit to
// auto-fit to the data's own x-range (previous behaviour).
// gapThresholdMs — if the x-gap between two consecutive points exceeds this,
// the segment break is drawn as a dotted "possibly offline"
// connector instead of a silent gap. Omit to just break silently.
// offlineWindows — array<{ start: number, end: number }> (ms epoch) — explicit
// "known offline" windows to render as dotted baseline
// regardless of this chart's own data gaps. Use this instead
// of (or alongside) gapThresholdMs when offline periods are
// computed once from a shared/authoritative signal (e.g. the
// device's heartbeat cadence) and applied identically across
// several charts whose own series may be too sparse for
// per-series gap detection to find the same windows —
// otherwise a chart fed from a low-frequency series (ping,
// diagnostics) can under- or over-detect gaps relative to one
// fed from a high-frequency series (heartbeats) even though
// both describe the same device over the same real outage.
// Always rendered in the 'baseline' style (see gapStyle)
// since these are known facts, not inferred from this
// chart's own two neighboring points.
// gapStyle — 'connector' | 'baseline' (default: 'connector')
// 'connector' draws the dotted line directly between the last point
// before the gap and the first point after it (diagonal if their
// y-values differ). 'baseline' instead draws it as a flat dotted
// line along the chart's bottom edge, spanning the same x-range —
// reads as "no data" rather than implying a value in between.
// emptyLabel — string shown when data has no non-null points
// className — extra classes on the root element
import { useMemo, useState, useRef, useId } from 'react'
const PAD = { top: 12, right: 12, bottom: 22, left: 8 }
// Above this many raw points, downsample to ~2 points (min+max) per pixel
// column so spikes survive being zoomed out over a wide time range instead
// of getting smoothed away by naive decimation.
const DOWNSAMPLE_THRESHOLD = 400
// Centripetal Catmull-Rom → cubic Bezier. Using each segment's actual
// Euclidean distance (not a fixed 1/6 fraction) keeps tangents proportional
// to real point spacing — with the naive uniform version, a run of tightly-
// packed points followed by a big gap produces an overshooting tangent that
// visibly loops the curve backward before continuing forward.
function clampToRange(v, a, b) {
return Math.min(Math.max(v, Math.min(a, b)), Math.max(a, b))
}
function smoothPath(seg) {
if (seg.length < 2) return `M${seg[0].px},${seg[0].py}`
if (seg.length === 2) return `M${seg[0].px},${seg[0].py}L${seg[1].px},${seg[1].py}`
const dist = (a, b) => Math.max(1e-3, Math.hypot(b.px - a.px, b.py - a.py))
const n = seg.length
let d = `M${seg[0].px},${seg[0].py}`
for (let i = 0; i < n - 1; i++) {
const p0 = seg[i - 1] || seg[i]
const p1 = seg[i]
const p2 = seg[i + 1]
const p3 = seg[i + 2] || p2
const d1 = dist(p0, p1)
const d2 = dist(p1, p2)
const d3 = dist(p2, p3)
// Tangent at p1, scaled by the ratio of the adjacent segment length to
// the sum of both neighboring segments — this is what makes it
// "centripetal": short segments pull the tangent in, long ones don't
// over-extend it.
const t1 = d1 / (d1 + d2)
let c1x = p1.px + (p2.px - p0.px) * (t1 / 3)
const c1y = p1.py + (p2.py - p0.py) * (t1 / 3)
const t2 = d2 / (d2 + d3)
let c2x = p2.px - (p3.px - p1.px) * ((1 - t2) / 3)
const c2y = p2.py - (p3.py - p1.py) * ((1 - t2) / 3)
// X is always time in these charts, so it must be monotonic — a cubic
// segment whose control point x strays outside its own [p1.x, p2.x] span
// renders as the line visibly reversing direction (looping backward)
// before continuing forward. This happens whenever a neighboring point
// pulls the tangent estimate too far: most commonly right at a segment's
// open boundary (next to a data gap, where there's no further point to
// temper the tangent), and also wherever the min/max-per-bucket
// downsampler emits two points with nearly identical x — a near-vertical
// spike that the same tangent math overshoots on either side of. Clamping
// x keeps every curve horizontally monotonic without altering its y
// shape, which is what removes the loop while leaving normal smoothing
// (evenly spaced points, no reordering) completely unaffected.
c1x = clampToRange(c1x, p1.px, p2.px)
c2x = clampToRange(c2x, p1.px, p2.px)
d += `C${c1x},${c1y} ${c2x},${c2y} ${p2.px},${p2.py}`
}
return d
}
function buildSegments(points) {
// Splits into contiguous segments at null gaps — each becomes its own path.
const segments = []
let current = []
for (const p of points) {
if (p.y == null) {
if (current.length) segments.push(current)
current = []
} else {
current.push(p)
}
}
if (current.length) segments.push(current)
return segments
}
// Min/max-per-pixel-bucket downsampling. Guarantees any spike/dip within a
// bucket survives (both the min AND max point are kept), unlike averaging
// which can smooth a brief extreme out of existence when zoomed out over a
// wide range. Null (gap) points pass through untouched — they carry their
// own meaning and must never be merged into a bucket.
function downsample(points, targetBuckets) {
if (points.length <= targetBuckets * 2) return points
const validXs = points.filter(p => p.y != null).map(p => p.x)
if (validXs.length === 0) return points
const xMin = Math.min(...validXs)
const xMax = Math.max(...validXs)
const span = Math.max(1, xMax - xMin)
const buckets = new Map()
for (const p of points) {
if (p.y == null) continue
const bucketIdx = Math.min(targetBuckets - 1, Math.floor(((p.x - xMin) / span) * targetBuckets))
const existing = buckets.get(bucketIdx)
if (!existing) {
buckets.set(bucketIdx, { min: p, max: p })
} else {
if (p.y < existing.min.y) existing.min = p
if (p.y > existing.max.y) existing.max = p
}
}
// Each bucket contributes its min and max point (the same point twice if
// only one sample fell in that bucket) — order doesn't matter here since
// gap markers are merged back in and everything is sorted by x afterward.
const flattened = []
for (const { min, max } of buckets.values()) {
flattened.push(min)
if (max !== min) flattened.push(max)
}
const gaps = points.filter(p => p.y == null)
return [...flattened, ...gaps].sort((a, b) => a.x - b.x)
}
export default function LineChart({
data = [],
color = 'var(--color-primary)',
colorStops = null,
height = 160,
formatValue = v => String(v),
formatX = x => String(x),
unit = '',
xMin: xMinProp = null,
xMax: xMaxProp = null,
gapThresholdMs = null,
offlineWindows = null,
gapStyle = 'connector',
emptyLabel = 'No data yet.',
className = '',
}) {
const containerRef = useRef(null)
const [width, setWidth] = useState(600)
const [hoverIdx, setHoverIdx] = useState(null)
const gradientId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
// Measure container width responsively — ResizeObserver is the only
// chart-sizing mechanism in the app.
const measureRef = el => {
containerRef.current = el
if (!el) return
const ro = new ResizeObserver(entries => {
const w = entries[0]?.contentRect?.width
if (w) setWidth(w)
})
ro.observe(el)
el._ro = ro
}
const validPoints = data.filter(d => d.y != null)
const hasData = validPoints.length > 0
const { points, yMin, yMax, xMin, xMax } = useMemo(() => {
if (!hasData) return { points: [], yMin: 0, yMax: 1, xMin: 0, xMax: 1 }
const xs = data.map(d => Number(d.x))
const ys = validPoints.map(d => Number(d.y))
// Explicit range props win — the chart must render the FULL requested
// span (e.g. "last 30 days") even when real data only covers a sliver of
// it, rather than silently auto-zooming to whatever data happens to exist.
let xMin = xMinProp != null ? Number(xMinProp) : Math.min(...xs)
let xMax = xMaxProp != null ? Number(xMaxProp) : Math.max(...xs)
let yMin = Math.min(...ys)
let yMax = Math.max(...ys)
// colorStops are value-anchored on an absolute scale (e.g. alert
// thresholds), not the data's own range — if the y-axis only auto-fit to
// the data, stops outside that range would clamp to the same 0%/100%
// gradient offset and collapse into a hard edge instead of a smooth
// blend. Widening yMin/yMax to cover every stop keeps the full gradient
// visible even when the data currently sits well inside a safe band.
if (colorStops && colorStops.length) {
const stopValues = colorStops.map(s => s.value)
yMin = Math.min(yMin, ...stopValues)
yMax = Math.max(yMax, ...stopValues)
}
if (xMin === xMax) { xMin -= 1; xMax += 1 }
if (yMin === yMax) { yMin -= 1; yMax += 1 }
// Headroom so the line/dots never touch the plot edge.
const yPad = (yMax - yMin) * 0.12
yMin -= yPad
yMax += yPad
const innerW = Math.max(1, width - PAD.left - PAD.right)
const innerH = Math.max(1, height - PAD.top - PAD.bottom)
let mapped = data.map(d => {
const x = Number(d.x)
const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
const py = d.y == null ? null : PAD.top + innerH - ((Number(d.y) - yMin) / (yMax - yMin)) * innerH
return { x, y: d.y, px, py: py ?? 0 }
})
// Drop points that fall outside the explicit range entirely (can happen
// if a caller passes data slightly wider than xMin/xMax).
mapped = mapped.filter(p => p.x >= xMin - 1 && p.x <= xMax + 1)
// Cut every real segment at explicit offline-window boundaries —
// offlineWindows is computed externally (e.g. from a denser series like
// heartbeats) and can disagree with THIS series' own point spacing. Two
// distinct cases both need handling, not just one:
// 1. A real sample happens to fall inside the window (mask it to null).
// 2. No sample at all falls inside a narrow window in a sparse series
// (ping every 60s, diagnostics every 5min) — the point just before
// and just after the window are still adjacent in the array with
// nothing between them, so without inserting explicit boundary gap
// markers, smoothPath() draws one continuous curve straight across
// the "offline" stretch, right on top of the dotted connector.
// Inserting a null-y marker at both edges of the window forces
// buildSegments() to break there even when case 2 applies.
if (offlineWindows && offlineWindows.length) {
mapped = mapped.map(p => {
if (p.y == null) return p
const inWindow = offlineWindows.some(w => p.x >= w.start && p.x <= w.end)
return inWindow ? { ...p, y: null } : p
})
const boundaryMarkers = []
for (const w of offlineWindows) {
const spansWindow = mapped.some(p => p.x < w.start) && mapped.some(p => p.x > w.end)
if (!spansWindow) continue
for (const x of [w.start, w.end]) {
if (x < xMin || x > xMax) continue
const px = PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
boundaryMarkers.push({ x, y: null, px, py: 0 })
}
}
if (boundaryMarkers.length) {
mapped = [...mapped, ...boundaryMarkers].sort((a, b) => a.x - b.x)
}
}
const targetBuckets = Math.max(50, Math.ceil(width / 3))
if (mapped.length > DOWNSAMPLE_THRESHOLD) {
mapped = downsample(mapped, targetBuckets)
}
return { points: mapped, yMin, yMax, xMin, xMax }
}, [data, width, height, hasData, validPoints, xMinProp, xMaxProp, colorStops, offlineWindows])
if (!hasData) {
return (
<div
className={['linechart linechart--empty', className].filter(Boolean).join(' ')}
style={{
height,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--color-text-muted)',
fontSize: 'var(--font-size-sm)',
fontStyle: 'italic',
}}
>
{emptyLabel}
</div>
)
}
const segments = buildSegments(points)
const innerW = Math.max(1, width - PAD.left - PAD.right)
const innerH = Math.max(1, height - PAD.top - PAD.bottom)
const baseline = PAD.top + innerH
const hovered = hoverIdx != null ? points[hoverIdx] : null
const latest = [...points].reverse().find(p => p.y != null)
// colorStops → an SVG linearGradient keyed to Y VALUE. gradientUnits
// userSpaceOnUse + y1/y2 in pixel-space (not objectBoundingBox) is what
// makes "red" always mean the same underlying value across every chart,
// rather than always meaning "top of whatever this chart's own range is."
const strokeColor = colorStops ? `url(#${gradientId})` : color
const sortedStops = colorStops ? [...colorStops].sort((a, b) => a.value - b.value) : null
// Dotted "possibly offline" connectors — drawn between the last point of one
// segment and the first point of the next, only when the real x-gap between
// them exceeds gapThresholdMs. A shorter gap (e.g. one missed 30s heartbeat)
// is just a silent break; a longer one is worth flagging visually.
// gapStyle 'baseline' flattens the connector to the chart's bottom edge
// instead of a diagonal between the two real y-values — a straight line
// at the actual readings would visually imply an interpolated value that
// was never measured, which is misleading for a gap that means "offline."
const offlineConnectors = []
if (gapThresholdMs) {
for (let i = 0; i < segments.length - 1; i++) {
const end = segments[i][segments[i].length - 1]
const start = segments[i + 1][0]
if (start.x - end.x > gapThresholdMs) {
if (gapStyle === 'baseline') {
offlineConnectors.push({ x1: end.px, y1: baseline, x2: start.px, y2: baseline })
} else {
offlineConnectors.push({ x1: end.px, y1: end.py, x2: start.px, y2: start.py })
}
}
}
}
// Explicit offline windows (computed once, shared across charts — see the
// prop doc above) mapped straight into this chart's own pixel space using
// the same xMin/xMax/innerW as the data points, and always drawn along the
// baseline — these are known facts about the device/console, not something
// inferred from this particular series' own two neighboring points, so they
// render even in a stretch where this series happens to have no points at
// all on either side of the window.
if (offlineWindows && offlineWindows.length) {
const toPx = x => PAD.left + ((x - xMin) / (xMax - xMin)) * innerW
for (const w of offlineWindows) {
const clampedStart = Math.max(w.start, xMin)
const clampedEnd = Math.min(w.end, xMax)
if (clampedEnd <= clampedStart) continue
offlineConnectors.push({ x1: toPx(clampedStart), y1: baseline, x2: toPx(clampedEnd), y2: baseline })
}
}
const handleMove = e => {
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
const relX = e.clientX - rect.left
// Nearest point by px distance.
let nearest = 0
let nearestDist = Infinity
points.forEach((p, i) => {
const d = Math.abs(p.px - relX)
if (d < nearestDist) { nearestDist = d; nearest = i }
})
setHoverIdx(nearest)
}
// Gridlines: 3 horizontal bands (min/mid/max) — recessive, label-free except axis text.
const gridY = [yMin + (yMax - yMin) * 0.0, yMin + (yMax - yMin) * 0.5, yMin + (yMax - yMin) * 1.0]
const legendColor = sortedStops
? (latest ? sortedStops.reduce((closest, s) => Math.abs(s.value - latest.y) < Math.abs(closest.value - latest.y) ? s : closest, sortedStops[0]).color : sortedStops[0].color)
: color
return (
<div
ref={measureRef}
className={['linechart', className].filter(Boolean).join(' ')}
style={{ position: 'relative', width: '100%', height }}
onMouseMove={handleMove}
onMouseLeave={() => setHoverIdx(null)}
>
{/* Trailing value badge — top-right, current/most-recent reading */}
{latest && (
<div style={{
position: 'absolute', top: 0, right: 0,
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
color: legendColor, fontFamily: 'var(--font-family-mono)', zIndex: 1,
}}>
{formatValue(latest.y)}{unit}
</div>
)}
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', overflow: 'visible' }}>
{sortedStops && (
<defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1={0} y1={PAD.top + innerH} x2={0} y2={PAD.top}>
{sortedStops.map((s, i) => {
const py = PAD.top + innerH - ((s.value - yMin) / (yMax - yMin)) * innerH
const offset = Math.max(0, Math.min(1, (py - PAD.top) / innerH))
// Gradient offsets run top(1)→bottom(0) in our y1/y2 direction
// (y1 at bottom = offset 0), so invert: a stop's pixel-y needs
// to map to (1 - offset-from-top).
return <stop key={i} offset={`${(1 - offset) * 100}%`} stopColor={s.color} />
})}
</linearGradient>
</defs>
)}
{/* Gridlines */}
{gridY.map((gy, i) => {
const py = PAD.top + innerH - ((gy - yMin) / (yMax - yMin)) * innerH
return (
<line key={i} x1={PAD.left} x2={PAD.left + innerW} y1={py} y2={py}
stroke="var(--color-border)" strokeWidth={1} />
)
})}
{/* Area fill under each segment — same color/gradient as the line,
low opacity, closed down to the plot baseline. Each fill path is
its own closed shape per segment, so a gap never produces a
spurious rectangle spanning the whole chart. */}
{segments.map((seg, i) => {
if (seg.length < 2) return null
const curve = smoothPath(seg)
const fillPath = `${curve}L${seg[seg.length - 1].px},${baseline}L${seg[0].px},${baseline}Z`
return <path key={`fill-${i}`} d={fillPath} fill={strokeColor} opacity={0.1} stroke="none" />
})}
{/* Dotted connectors across long gaps — "device likely offline" cue,
drawn under the solid segments so segment ends stay crisp on top. */}
{offlineConnectors.map((c, i) => (
<line key={`gap-${i}`} x1={c.x1} y1={c.y1} x2={c.x2} y2={c.y2}
stroke="var(--color-text-muted)" strokeWidth={1.5} strokeDasharray="3,4" opacity={0.6} />
))}
{/* Line segments (smoothed, gaps at null y) */}
{segments.map((seg, i) => (
<path key={i} d={smoothPath(seg)} fill="none" stroke={strokeColor} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" />
))}
{/* Rounded data-end on the most recent point */}
{latest && (
<circle cx={latest.px} cy={latest.py} r={4} fill={legendColor} stroke="var(--color-bg-surface)" strokeWidth={2} />
)}
{/* Hover crosshair + marker */}
{hovered && (
<g>
<line x1={hovered.px} x2={hovered.px} y1={PAD.top} y2={PAD.top + innerH}
stroke="var(--color-border-strong)" strokeWidth={1} strokeDasharray="3,3" />
{hovered.y != null && (
<circle cx={hovered.px} cy={hovered.py} r={5} fill={strokeColor} stroke="var(--color-bg-surface)" strokeWidth={2} />
)}
</g>
)}
{/* X-axis endpoints — always the resolved range bounds, not just
whatever the first/last real sample happens to be, so the axis
reflects the selected time range even when data is sparse. */}
<text x={PAD.left} y={height - 6} fontSize="10" fill="var(--color-text-muted)" fontFamily="var(--font-family-base)">
{formatX(xMin)}
</text>
<text x={PAD.left + innerW} y={height - 6} fontSize="10" fill="var(--color-text-muted)"
fontFamily="var(--font-family-base)" textAnchor="end">
{formatX(xMax)}
</text>
</svg>
{/* Tooltip */}
{hovered && (
<div style={{
position: 'absolute',
left: Math.min(Math.max(hovered.px, 60), width - 60),
top: 0,
transform: 'translateX(-50%)',
background: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border-strong)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-1) var(--space-2)',
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-primary)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
boxShadow: 'var(--shadow-md)',
zIndex: 2,
}}>
<div style={{ color: 'var(--color-text-muted)' }}>{formatX(hovered.x)}</div>
<div style={{ fontWeight: 'var(--font-weight-semibold)', color: hovered.y != null ? legendColor : 'var(--color-text-muted)' }}>
{hovered.y != null ? `${formatValue(hovered.y)}${unit}` : 'No data'}
</div>
</div>
)}
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
// src/components/ui/MultiSelect.jsx
// Multi-select dropdown — same visual language as Select.jsx (floating portal
// menu, .select-* trigger styling) but with checkbox rows so multiple options
// can be chosen at once. Trigger shows "All", a single label, or "N selected".
//
// Props:
// options — Array<{ value: string, label: ReactNode }>
// value — Array<string> — currently selected values (empty = "all")
// onChange — (nextValues: string[]) => void
// placeholder — shown when value is empty (default: 'All')
// disabled — boolean
// className — extra classes on the trigger button
// id — forwarded for label association
import { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
export default function MultiSelect({
options = [],
value = [],
onChange,
placeholder = 'All',
disabled = false,
className = '',
id,
...props
}) {
const [open, setOpen] = useState(false)
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 })
const triggerRef = useRef(null)
const menuRef = useRef(null)
useEffect(() => {
if (!open) return
function close(e) {
if (triggerRef.current?.contains(e.target)) return
if (menuRef.current?.contains(e.target)) return
setOpen(false)
}
function closeOnScroll(e) {
if (menuRef.current?.contains(e.target)) return
if (triggerRef.current?.contains(e.target)) return
setOpen(false)
}
document.addEventListener('mousedown', close)
document.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
document.removeEventListener('scroll', closeOnScroll, true)
}
}, [open])
function openMenu() {
if (disabled) return
if (triggerRef.current) {
setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
}
setOpen(true)
}
function handleToggle() {
if (open) setOpen(false)
else openMenu()
}
function toggleValue(v) {
const next = value.includes(v) ? value.filter((x) => x !== v) : [...value, v]
onChange?.(next)
}
const label = value.length === 0
? placeholder
: value.length === 1
? (options.find((o) => o.value === value[0])?.label ?? value[0])
: `${value.length} selected`
const menu = open && createPortal(
<div
ref={menuRef}
className="select-menu multi-select-menu"
style={{ top: menuPos.top, bottom: menuPos.bottom, left: menuPos.left, width: menuPos.width, maxHeight: menuPos.maxHeight }}
role="listbox"
aria-multiselectable="true"
aria-label="Options"
>
{value.length > 0 && (
<div
className="select-option multi-select-clear"
onMouseDown={(e) => { e.preventDefault(); onChange?.([]) }}
>
<span>Clear selection</span>
</div>
)}
{options.map((opt) => {
const checked = value.includes(opt.value)
return (
<div
key={opt.value}
role="option"
aria-selected={checked}
className={['select-option', checked ? 'select-option-selected' : ''].filter(Boolean).join(' ')}
onMouseDown={(e) => { e.preventDefault(); toggleValue(opt.value) }}
>
<input
type="checkbox"
checked={checked}
readOnly
tabIndex={-1}
style={{ accentColor: 'var(--color-primary)', flexShrink: 0, pointerEvents: 'none' }}
/>
<span>{opt.label}</span>
</div>
)
})}
</div>,
document.body
)
return (
<>
<button
ref={triggerRef}
type="button"
id={id}
disabled={disabled}
onClick={handleToggle}
className={[
'input',
'select-trigger',
open ? 'select-trigger-open' : '',
className,
].filter(Boolean).join(' ')}
aria-haspopup="listbox"
aria-expanded={open}
{...props}
>
<span className={value.length ? 'select-value' : 'select-placeholder'}>
{label}
</span>
<span className="select-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{menu}
</>
)
}
@@ -0,0 +1,124 @@
// src/components/ui/PressHoldButton.jsx
// A button that requires holding it down for a fixed duration to confirm,
// rather than a single click — for destructive actions where a misclick
// should not be able to trigger the action (a slip releases instantly and
// does nothing). Renders a fill sweeping left-to-right as visual progress.
//
// Props:
// holdMs — number — how long the button must be held, in ms (default: 3000)
// onConfirm — () => void — called once the hold completes
// variant — 'danger' | 'primary' (default: 'danger')
// size — 'sm' | 'md' | 'lg' (default: 'md')
// disabled — boolean
// children — ReactNode — label shown when not held
// holdingLabel — ReactNode — label shown while holding (falls back to children)
// className — extra classes
import { useRef, useState, useCallback } from 'react'
export default function PressHoldButton({
holdMs = 3000,
onConfirm,
variant = 'danger',
size = 'md',
disabled = false,
children,
holdingLabel,
className = '',
}) {
const [progress, setProgress] = useState(0) // 0–100
const [holding, setHolding] = useState(false)
const rafRef = useRef(null)
const startRef = useRef(0)
const stop = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current)
rafRef.current = null
setHolding(false)
setProgress(0)
}, [])
// Named function expression — `tickFrame` inside the body refers to the
// function itself via its own name, not the outer `tick` const binding,
// so there's no self-reference-before-declaration issue on the recursive
// requestAnimationFrame call.
const tick = useCallback(function tickFrame() {
const elapsed = Date.now() - startRef.current
const pct = Math.min(100, (elapsed / holdMs) * 100)
setProgress(pct)
if (pct >= 100) {
setHolding(false)
setProgress(0)
onConfirm?.()
return
}
rafRef.current = requestAnimationFrame(tickFrame)
}, [holdMs, onConfirm])
const start = useCallback(() => {
if (disabled) return
startRef.current = Date.now()
setHolding(true)
rafRef.current = requestAnimationFrame(tick)
}, [disabled, tick])
const color = variant === 'danger' ? 'var(--color-danger)' : 'var(--color-primary)'
const bg = variant === 'danger' ? 'var(--color-danger-bg)' : 'var(--color-primary-subtle)'
const sizeStyles = {
sm: { height: 32, paddingInline: 'var(--space-3)', fontSize: 'var(--font-size-xs)' },
md: { height: 38, paddingInline: 'var(--space-4)', fontSize: 'var(--font-size-sm)' },
lg: { height: 44, paddingInline: 'var(--space-5)', fontSize: 'var(--font-size-base)' },
}[size] || {}
return (
<button
type="button"
disabled={disabled}
onMouseDown={start}
onMouseUp={stop}
onMouseLeave={stop}
onTouchStart={start}
onTouchEnd={stop}
onTouchCancel={stop}
className={className}
aria-pressed={holding}
style={{
position: 'relative',
overflow: 'hidden',
border: `1px solid ${color}`,
borderRadius: 'var(--radius-md)',
background: bg,
color,
fontWeight: 'var(--font-weight-semibold)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
WebkitUserSelect: 'none',
touchAction: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-2)',
transition: 'opacity 0.15s',
...sizeStyles,
}}
>
<span
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
width: `${progress}%`,
background: color,
opacity: 0.28,
transition: progress === 0 ? 'width 0.15s ease-out' : 'none',
pointerEvents: 'none',
}}
/>
<span style={{ position: 'relative' }}>
{holding ? (holdingLabel ?? children) : children}
</span>
</button>
)
}
+4 -4
View File
@@ -16,6 +16,7 @@
import { useState, useRef, useEffect, Children, isValidElement } from 'react'
import { createPortal } from 'react-dom'
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
// Parse React <option> children into a flat [{value, label, disabled}] array
function parseOptions(children) {
@@ -46,7 +47,7 @@ export default function Select({
...props
}) {
const [open, setOpen] = useState(false)
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0 })
const [menuPos, setMenuPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 280 })
const triggerRef = useRef(null)
const menuRef = useRef(null)
@@ -105,8 +106,7 @@ export default function Select({
function openMenu() {
if (disabled) return
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect()
setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width })
setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
}
setOpen(true)
}
@@ -130,7 +130,7 @@ export default function Select({
<div
ref={menuRef}
className="select-menu"
style={{ top: menuPos.top, left: menuPos.left, width: menuPos.width }}
style={{ top: menuPos.top, bottom: menuPos.bottom, left: menuPos.left, width: menuPos.width, maxHeight: menuPos.maxHeight }}
role="listbox"
aria-label="Options"
>
@@ -0,0 +1,120 @@
// src/components/ui/SignalIndicator.jsx
// Wi-Fi signal-strength glyph — concentric arcs + dot, filled by RSSI (dBm).
// Never use a raw icon/emoji for signal strength — always use SignalIndicator.
//
// Props:
// rssi — number | null | undefined — signal strength in dBm (e.g. -62). null/undefined = unknown.
// isOnline — boolean | undefined — when explicitly false, renders a fully grey
// (unlit) glyph regardless of rssi, so a stale last-known reading from
// an offline device never reads as a live "full signal". When true (or undefined) but rssi is null/undefined,
// renders an amber "?" instead — legacy v1-firmware devices report a
// heartbeat with no rssi field at all (see API Reference: legacy
// heartbeats predate rssi/state/ok), so "online with unknown signal"
// is a distinct, expected state from "we haven't loaded it yet".
// size — 'sm' | 'md' (default: 'md')
// className — extra classes
//
// RSSI → level (4 levels: dot + 3 arcs, lit from the center outward):
// > -50 dBm → Excellent (4/4 lit), var(--color-success)
// >= -60 dBm → Good (3/4 lit), var(--color-info)
// >= -70 dBm → Fair (2/4 lit), var(--color-warning)
// < -70 dBm → Weak (1/4 lit), var(--color-danger)
// null/undefined → 0/4 lit, var(--color-text-muted) — "unknown", not "bad"
function rssiToLevel(rssi) {
if (rssi > -50) return 4
if (rssi >= -60) return 3
if (rssi >= -70) return 2
return 1
}
function rssiToColor(rssi) {
if (rssi == null) return 'var(--color-text-muted)'
if (rssi > -50) return 'var(--color-success)'
if (rssi >= -60) return 'var(--color-info)'
if (rssi >= -70) return 'var(--color-warning)'
return 'var(--color-danger)'
}
const SIZES = {
sm: { box: 16, strokeWidth: 1.6 },
md: { box: 20, strokeWidth: 1.8 },
}
const DIM_COLOR = 'var(--color-bg-island)'
export default function SignalIndicator({ rssi, isOnline, size = 'md', className = '' }) {
const offline = isOnline === false
const known = !offline && rssi != null && Number.isFinite(rssi)
// Online, but the device firmware never reports rssi at all (legacy v1 API).
const unsupported = !offline && !known && isOnline === true
const muted = offline || unsupported
const level = known ? rssiToLevel(rssi) : 0
const color = unsupported ? 'var(--color-warning)' : rssiToColor(known ? rssi : null)
const { box, strokeWidth } = SIZES[size] || SIZES.md
const label = offline
? 'Signal strength unavailable — device offline'
: unsupported
? 'Signal strength not reported by this device\'s firmware'
: known
? `Signal strength: ${['weak', 'fair', 'good', 'excellent'][level - 1]} (${rssi} dBm)`
: 'Signal strength unknown'
return (
<svg
width={box}
height={box}
viewBox="0 0 24 24"
className={['signal-indicator', className].filter(Boolean).join(' ')}
role="img"
aria-label={label}
style={{ flexShrink: 0 }}
>
<title>{label}</title>
{/* Center dot — level 1 */}
<circle cx="12" cy="18.5" r="1.6" fill={muted ? DIM_COLOR : (level >= 1 ? color : DIM_COLOR)} style={{ transition: 'fill 0.2s' }} />
{/* Inner arc — level 2 */}
<path
d="M8.2 14.8a5.4 5.4 0 0 1 7.6 0"
fill="none"
stroke={muted ? DIM_COLOR : (level >= 2 ? color : DIM_COLOR)}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
{/* Middle arc — level 3 */}
<path
d="M5 11.6a9.8 9.8 0 0 1 14 0"
fill="none"
stroke={muted ? DIM_COLOR : (level >= 3 ? color : DIM_COLOR)}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
{/* Outer arc — level 4 */}
<path
d="M1.8 8.4a14.2 14.2 0 0 1 20.4 0"
fill="none"
stroke={muted ? DIM_COLOR : (level >= 4 ? color : DIM_COLOR)}
strokeWidth={strokeWidth}
strokeLinecap="round"
style={{ transition: 'stroke 0.2s' }}
/>
{/* Unsupported overlay — amber "?", device is online but its firmware never reports rssi (v1 API) */}
{unsupported && (
<text
x="12"
y="16.5"
textAnchor="middle"
fontSize="11"
fontWeight="700"
fontFamily="var(--font-family-base, sans-serif)"
fill={color}
stroke="none"
>
?
</text>
)}
</svg>
)
}
@@ -0,0 +1,83 @@
// src/components/ui/TimeRangeSelect.jsx
// Time-range picker for history/telemetry pages — a preset dropdown (10m
// through all-time) plus a "Custom…" option opening a small range modal.
// Drives every time-scoped section on a page from one control: pass the
// resolved { since, until } down to whatever fetches/filters data.
//
// Props:
// value — { key: string, since: Date|null, until: Date|null } — since=null means "all-time"
// onChange — (value) => void
// className — extra classes on the trigger
import { useState } from 'react'
import Select from '@/components/ui/Select'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import DateTimePicker from '@/components/ui/DateTimePicker'
import { PRESETS, resolveRange } from '@/lib/timeRange'
function CustomRangeModal({ open, onClose, initialSince, initialUntil, onApply }) {
const [since, setSince] = useState(initialSince ? initialSince.toISOString() : '')
const [until, setUntil] = useState(initialUntil ? initialUntil.toISOString() : '')
const apply = () => {
onApply({
since: since ? new Date(since) : null,
until: until ? new Date(until) : null,
})
onClose()
}
return (
<Modal open={open} onClose={onClose} title="Custom Time Range" size="sm">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<DateTimePicker label="From" name="range-since" value={since} onChange={setSince} />
<DateTimePicker label="To" name="range-until" value={until} onChange={setUntil} placeholder="Leave empty for now" />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-2)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!since} onClick={apply}>Apply</Button>
</div>
</div>
</Modal>
)
}
export default function TimeRangeSelect({ value, onChange, className = '' }) {
const [showCustom, setShowCustom] = useState(false)
const handleSelectChange = e => {
const key = e.target.value
if (key === 'custom') {
setShowCustom(true)
return
}
onChange(resolveRange(key))
}
const selectValue = value?.key === 'custom' ? 'custom' : (value?.key || '7d')
return (
<>
<Select
id="time-range-select"
name="time-range"
value={selectValue}
onChange={handleSelectChange}
className={className}
>
{PRESETS.map(p => (
<option key={p.key} value={p.key}>{p.label}</option>
))}
<option value="custom">Custom range…</option>
</Select>
<CustomRangeModal
open={showCustom}
onClose={() => setShowCustom(false)}
initialSince={value?.since}
initialUntil={value?.until}
onApply={({ since, until }) => onChange({ key: 'custom', since, until })}
/>
</>
)
}
+27 -9
View File
@@ -12,8 +12,9 @@
// Each toast auto-dismisses after `duration` ms (default 4000).
// ToastProvider renders the stack itself via a portal — no extra component needed.
import { createContext, useContext, useState, useCallback, useRef } from 'react'
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import Spinner from '@/components/ui/Spinner'
// ---------------------------------------------------------------------------
// Context
@@ -49,7 +50,7 @@ function ToastIcon({ variant }) {
// ---------------------------------------------------------------------------
// Single Toast item
// ---------------------------------------------------------------------------
function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDismiss }) {
function ToastItem({ id, variant = 'info', title, message, duration = 4000, pending = false, onDismiss }) {
const [exiting, setExiting] = useState(false)
const dismiss = useCallback(() => {
@@ -57,15 +58,21 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
setTimeout(() => onDismiss(id), 300)
}, [id, onDismiss])
// Auto-dismiss timer
// Auto-dismiss timer — does not run while `pending` (e.g. waiting on a command reply)
const timerRef = useRef(null)
const startTimer = () => {
if (pending) return
timerRef.current = setTimeout(dismiss, duration)
}
const clearTimer = () => clearTimeout(timerRef.current)
// Start timer on mount
useState(() => { startTimer() })
// (Re)start the timer whenever `pending` resolves to false, or on mount if already resolved
useEffect(() => {
clearTimer()
startTimer()
return clearTimer
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, duration])
return (
<div
@@ -76,7 +83,7 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
onMouseLeave={startTimer}
>
<div className="toast-icon">
<ToastIcon variant={variant} />
{pending ? <Spinner size="sm" /> : <ToastIcon variant={variant} />}
</div>
<div className="toast-content">
@@ -94,11 +101,13 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
</svg>
</button>
{/* Progress bar — shrinks over duration */}
{/* Progress bar — shrinks over duration. Hidden while pending (no timeout running). */}
{!pending && (
<div
className="toast-progress"
style={{ animationDuration: `${duration}ms` }}
/>
)}
</div>
)
}
@@ -111,9 +120,9 @@ let _nextId = 1
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((variant, title, message, duration) => {
const addToast = useCallback((variant, title, message, duration, pending = false) => {
const id = _nextId++
setToasts((prev) => [...prev, { id, variant, title, message, duration }])
setToasts((prev) => [...prev, { id, variant, title, message, duration, pending }])
return id
}, [])
@@ -121,11 +130,20 @@ export function ToastProvider({ children }) {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
// Update an existing toast in place — e.g. flip a `pending` (blue) toast to
// success/danger/warning once a command reply (or timeout) arrives.
const update = useCallback((id, patch) => {
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}, [])
const toast = {
success: (title, message, duration) => addToast('success', title, message, duration),
danger: (title, message, duration) => addToast('danger', title, message, duration),
warning: (title, message, duration) => addToast('warning', title, message, duration),
info: (title, message, duration) => addToast('info', title, message, duration),
// pending: creates a toast with no auto-dismiss timer until update() clears `pending`
pending: (title, message) => addToast('info', title, message, 4000, true),
update,
dismiss,
}
+129
View File
@@ -0,0 +1,129 @@
// frontend/src/hooks/useDeviceCommand.js
// Shared MQTT command-send + ack-tracking machinery, extracted from
// DeviceDetail.jsx so any page (DeviceDetail, the onboarding wizard, …) can
// send a command to a device and await its reply the same way.
//
// Device replies arrive on control/ack. They can optionally echo a req_id
// (see CommandEnvelope.hpp / vesper_mqtt_topic_spec_v2.md), but this hook
// doesn't send one yet, so in-flight commands are matched FIFO against
// whichever command is oldest for this device — see the API Reference's wire
// envelope: { type: 'control/ack', device_serial, payload: { status, type, message, data } }.
//
// Usage:
// const { sendMqttCommand, sendMqttCommandSilent, sendingCmd, connected } =
// useDeviceCommand({ deviceSerial, onNonAckMessage, onCommandSent })
//
// sendMqttCommand(cmd, contents) — shows a live-updating toast (blue → success/danger/warning)
// sendMqttCommandSilent(cmd, contents, timeoutMs) — no toast, used for background GET-config refreshes
// onNonAckMessage(msg) — optional: receives every WS message that ISN'T a
// 'control/ack' reply for this device (e.g. status/heartbeat),
// so callers don't need a second WebSocket connection.
// onCommandSent(cmd) — optional: fires the instant a (non-silent) command is
// accepted for sending (POST resolved), before the device
// has replied — e.g. to refresh a command-history list.
import { useRef, useCallback, useState } from 'react'
import api from '@/lib/api'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
import { useToast } from '@/components/ui/Toast'
export function useDeviceCommand({ deviceSerial, onNonAckMessage, onCommandSent } = {}) {
const { toast } = useToast()
const [sendingCmd, setSendingCmd] = useState('')
// [{ cmd, toastId?, resolve, reject, timeoutId }]
const pendingAcksRef = useRef([])
const { connected } = useMqttWebSocket({
enabled: !!deviceSerial,
onMessage: (msg) => {
if (msg?.device_serial !== deviceSerial) return
if (msg.type !== 'control/ack') {
onNonAckMessage?.(msg)
return
}
const ack = pendingAcksRef.current.shift() // oldest in-flight command
if (!ack) return
const payload = msg.payload || {}
clearTimeout(ack.timeoutId)
const isSuccess = payload.status === 'SUCCESS'
if (ack.toastId != null) {
toast.update(ack.toastId, {
variant: isSuccess ? 'success' : 'danger',
title: isSuccess ? 'Success' : 'Failed',
message: payload.message || `Command "${payload.type || ack.cmd}" ${isSuccess ? 'succeeded' : 'failed'}.`,
pending: false,
duration: isSuccess ? 1000 : 6000,
})
}
if (isSuccess) ack.resolve?.(payload)
else ack.reject?.(new Error(payload.message || `Command "${ack.cmd}" failed.`))
},
})
// Sends a command and shows a live-updating toast. Returns a promise
// resolving with the reply payload on SUCCESS and rejecting on
// ERROR/timeout/send-failure.
const sendMqttCommand = useCallback((cmd, contents = {}) => {
if (!deviceSerial) return Promise.reject(new Error('No device id.'))
setSendingCmd(cmd)
const toastId = toast.pending(cmd, 'Sent — waiting for reply…')
return new Promise((resolve, reject) => {
api.post(`/mqtt/command/${deviceSerial}`, { cmd, contents })
.then(() => {
onCommandSent?.(cmd)
const ackEntry = { cmd, toastId, resolve, reject, timeoutId: null }
ackEntry.timeoutId = setTimeout(() => {
pendingAcksRef.current = pendingAcksRef.current.filter((a) => a !== ackEntry)
toast.update(toastId, {
variant: 'warning',
title: 'No reply received',
message: `"${cmd}" was sent but the device did not reply in time.`,
pending: false,
duration: 4000,
})
reject(new Error(`"${cmd}" timed out waiting for a reply.`))
}, 5000)
pendingAcksRef.current.push(ackEntry)
})
.catch((err) => {
toast.update(toastId, {
variant: 'danger',
title: 'Failed to send',
message: err.message || 'Failed to send command.',
pending: false,
duration: 6000,
})
reject(err)
})
.finally(() => setSendingCmd(''))
})
}, [deviceSerial, toast, onCommandSent])
// Silent variant — no toast, no sendingCmd flicker. Used for background
// GET-config refreshes and other non-user-initiated commands.
const sendMqttCommandSilent = useCallback((cmd, contents = {}, timeoutMs = 5000) => {
return new Promise((resolve, reject) => {
if (!deviceSerial) { reject(new Error('No device id.')); return }
api.post(`/mqtt/command/${deviceSerial}`, { cmd, contents })
.then(() => {
const ackEntry = { cmd, resolve, reject, timeoutId: null }
ackEntry.timeoutId = setTimeout(() => {
pendingAcksRef.current = pendingAcksRef.current.filter((a) => a !== ackEntry)
reject(new Error(`"${cmd}" timed out waiting for a reply.`))
}, timeoutMs)
pendingAcksRef.current.push(ackEntry)
})
.catch(reject)
})
}, [deviceSerial])
return { sendMqttCommand, sendMqttCommandSilent, sendingCmd, connected }
}
export default useDeviceCommand
+61 -2
View File
@@ -1,2 +1,61 @@
// TODO: implement
export function useMqttWebSocket() {}
// frontend/src/hooks/useMqttWebSocket.js
// Subscribes to /api/mqtt/ws — broadcasts every inbound MQTT message
// (status/heartbeat, status/alerts, status/info, logs, data) as it arrives.
//
// Usage:
// useMqttWebSocket({ onMessage: (msg) => { ... } })
// msg shape: { type: 'data'|'status/heartbeat'|..., device_serial, payload, topic }
import { useEffect, useRef, useState } from 'react'
export function useMqttWebSocket({ enabled = true, onMessage } = {}) {
const [connected, setConnected] = useState(false)
const onMessageRef = useRef(onMessage)
useEffect(() => {
onMessageRef.current = onMessage
}, [onMessage])
useEffect(() => {
if (!enabled) return
let ws = null
let reconnectTimer = null
let stopped = false
const connect = () => {
const token = localStorage.getItem('access_token')
if (!token) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${protocol}//${window.location.host}/api/mqtt/ws?token=${token}`)
ws.onopen = () => setConnected(true)
ws.onclose = () => {
setConnected(false)
if (!stopped) reconnectTimer = setTimeout(connect, 5000)
}
ws.onerror = () => {}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
onMessageRef.current?.(data)
} catch {
// ignore invalid JSON
}
}
}
connect()
return () => {
stopped = true
clearTimeout(reconnectTimer)
ws?.close()
}
}, [enabled])
return { connected }
}
export default useMqttWebSocket
+113
View File
@@ -0,0 +1,113 @@
// frontend/src/hooks/useSerialConnection.js
// Owns one WebSerial port connection across the Provisioning Wizard's Flash
// and Verify steps. Lifted out of StepFlash so the same live serial session
// (and its accumulated log) survives the step transition — a fresh device
// reboot after flashing is exactly the window the Verify step needs to watch.
import { useState, useRef, useCallback } from 'react'
export function useSerialConnection() {
const [portConnected, setPortConnected] = useState(false)
const [portName, setPortName] = useState('')
const [monitoring, setMonitoring] = useState(false)
const [lines, setLines] = useState([])
const portRef = useRef(null)
const readerRef = useRef(null)
const monitorActiveRef = useRef(false)
const appendLine = useCallback((msg) => {
setLines((prev) => [...prev, String(msg)])
}, [])
const clearLines = useCallback(() => setLines([]), [])
const requestPort = useCallback(async () => {
const port = await navigator.serial.requestPort()
portRef.current = port
const info = port.getInfo?.() || {}
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: 'Serial Port'
setPortName(label)
setPortConnected(true)
return port
}, [])
// Starts (or restarts) the 115200-baud monitor on the currently held port.
// Safe to call after esptool has just disconnected its own transport —
// the small delay lets the OS release the port before we reopen it.
const startMonitor = useCallback(async (port, { delayMs = 1000 } = {}) => {
const target = port || portRef.current
if (!target) return
monitorActiveRef.current = true
setMonitoring(true)
if (delayMs) await new Promise((r) => setTimeout(r, delayMs))
try {
await target.open({ baudRate: 115200 })
} catch (openErr) {
appendLine(`[Error opening port: ${openErr.message}]`)
setMonitoring(false)
return
}
let reader
try {
reader = target.readable.getReader()
} catch (readerErr) {
appendLine(`[Error getting reader: ${readerErr.message}]`)
try { await target.close() } catch (_) {}
setMonitoring(false)
return
}
readerRef.current = reader
const dec = new TextDecoder()
let buf = ''
try {
while (monitorActiveRef.current) {
const { value, done } = await reader.read()
if (done) break
buf += dec.decode(value, { stream: true })
const parts = buf.split(/\r?\n/)
buf = parts.pop()
for (const line of parts) { if (line.trim()) appendLine(line) }
}
} catch (_) {
// reader cancelled/port lost — fall through to cleanup
} finally {
try { reader.releaseLock() } catch (_) {}
setMonitoring(false)
}
}, [appendLine])
const stopMonitor = useCallback(async () => {
monitorActiveRef.current = false
try { await readerRef.current?.cancel() } catch (_) {}
try { readerRef.current?.releaseLock() } catch (_) {}
setMonitoring(false)
}, [])
const disconnect = useCallback(async () => {
await stopMonitor()
try { await portRef.current?.close() } catch (_) {}
portRef.current = null
setPortConnected(false)
setPortName('')
appendLine('[Port disconnected]')
}, [stopMonitor, appendLine])
return {
portRef,
portConnected,
portName,
monitoring,
lines,
appendLine,
clearLines,
requestPort,
startMonitor,
stopMonitor,
disconnect,
}
}
export default useSerialConnection
+28
View File
@@ -0,0 +1,28 @@
// frontend/src/lib/clipboard.js
// Copy text to the clipboard, with a textarea fallback for non-secure
// contexts (plain-HTTP LAN access) where navigator.clipboard is undefined.
export function copyText(text) {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text))
}
return fallbackCopy(text)
}
function fallbackCopy(text) {
return new Promise((resolve, reject) => {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', '')
ta.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy') ? resolve() : reject(new Error('Copy failed'))
} catch (err) {
reject(err)
} finally {
document.body.removeChild(ta)
}
})
}
+124
View File
@@ -0,0 +1,124 @@
// frontend/src/lib/crashReport.js
// Helpers for firmware crash detail (F-070) on boot events: the `crash`
// (coredump summary) and `pre_crash` (heap/uptime snapshot + optional
// abort_msg) objects from boot_report, with fallback to the legacy flat
// crash_* columns for older rows/firmware.
//
// Key rule: when pre_crash.abort_msg is present, pc / exc_cause 29 /
// exc_vaddr 0 describe the abort() mechanism, NOT the real cause — the
// abort_msg is the headline and those fields are de-emphasised.
// Xtensa EXCCAUSE values the ESP32 actually produces.
export const EXC_CAUSE_NAMES = {
0: 'IllegalInstruction',
2: 'InstructionFetchError',
3: 'LoadStoreError',
6: 'IntegerDivideByZero',
9: 'LoadStoreAlignment',
28: 'LoadProhibited',
29: 'StoreProhibited',
}
// Firmware F-055 thresholds: below these the crash was most likely caused by
// heap exhaustion or fragmentation rather than a logic bug.
export const HEAP_LARGEST_BLOCK_WARN = 12000
export const HEAP_FREE_WARN = 25000
export function excCauseName(cause) {
if (cause == null) return 'Unknown exception'
return EXC_CAUSE_NAMES[cause] ?? `Exception cause ${cause}`
}
// 0x%08X — the ESP serial monitor's address format.
export function hex32(n) {
if (n == null || !Number.isFinite(Number(n))) return '—'
return `0x${(Number(n) >>> 0).toString(16).toUpperCase().padStart(8, '0')}`
}
// Merge the JSON `crash` object with the legacy flat columns so callers deal
// with one shape. Returns null when the boot has no coredump summary at all.
export function crashOf(event) {
const c = event?.crash || {}
const task = c.task ?? event?.crash_task
const pc = c.pc ?? event?.crash_pc
if (task == null && pc == null && !event?.crash) return null
return {
task,
pc,
excCause: c.exc_cause ?? event?.crash_exc_cause,
excVaddr: c.exc_vaddr ?? event?.crash_exc_vaddr,
backtrace: Array.isArray(c.backtrace) ? c.backtrace : [],
backtraceCorrupted: !!c.backtrace_corrupted,
elfSha256: c.elf_sha256 || null,
}
}
export function preCrashOf(event) {
return event?.pre_crash || null
}
export function hasCrashDetail(event) {
return !!(crashOf(event) || preCrashOf(event))
}
// "abort() was called at PC 0x400d8a3f on core 0" → 0x400D8A3F (the caller of
// abort(), worth decoding too). Only that phrasing carries a PC.
export function abortCallerPc(abortMsg) {
const m = /abort\(\) was called at PC (0x[0-9a-fA-F]+)/.exec(abortMsg || '')
return m ? parseInt(m[1], 16) : null
}
export function crashHeadline(event) {
const abortMsg = preCrashOf(event)?.abort_msg
if (abortMsg) return abortMsg
const crash = crashOf(event)
if (crash) return excCauseName(crash.excCause)
return null
}
export function isHeapExhaustion(preCrash) {
if (!preCrash) return false
return (preCrash.largest_block != null && preCrash.largest_block < HEAP_LARGEST_BLOCK_WARN)
|| (preCrash.free_heap != null && preCrash.free_heap < HEAP_FREE_WARN)
}
// One line, space separated, like the ESP panic handler prints it.
export function addressLine(crash) {
if (!crash) return ''
return [crash.pc, ...crash.backtrace].filter(a => a != null).map(hex32).join(' ')
}
// Every address worth decoding: PC, backtrace, then the abort() caller PC.
export function decodeAddresses(event) {
const crash = crashOf(event)
const out = []
if (crash?.pc != null) out.push(Number(crash.pc))
for (const a of crash?.backtrace || []) out.push(Number(a))
const caller = abortCallerPc(preCrashOf(event)?.abort_msg)
if (caller != null) out.push(caller)
return out
}
export function addr2lineCommand(event) {
const addrs = decodeAddresses(event)
if (!addrs.length) return ''
return `xtensa-esp32-elf-addr2line -pfiaC -e firmware.elf ${addrs.map(hex32).join(' ')}`
}
export function fmtBytes(n) {
if (n == null) return '—'
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`
return `${n} B`
}
export function fmtUptime(ms) {
if (ms == null) return '—'
const s = Math.floor(ms / 1000)
const d = Math.floor(s / 86400)
const h = Math.floor((s % 86400) / 3600)
const m = Math.floor((s % 3600) / 60)
const sec = s % 60
const parts = [d && `${d}d`, h && `${h}h`, m && `${m}m`]
if (!d && !h) parts.push(`${sec}s`)
return parts.filter(Boolean).join(' ') || '0s'
}
+32
View File
@@ -0,0 +1,32 @@
// frontend/src/lib/floatingMenuPosition.js
// Positions a fixed-position dropdown menu relative to its trigger, flipping
// above the trigger and clamping its height when there isn't enough room
// below in the viewport (e.g. a Select near the bottom of the screen).
//
// Used by Select.jsx and MultiSelect.jsx — both render their menu via
// createPortal(..., document.body) with position: fixed, so they need to
// account for viewport edges themselves (page scroll doesn't move a
// position:fixed element into view).
const MENU_GAP = 4
const MENU_MARGIN = 8 // minimum breathing room from the viewport edge
const PREFERRED_MAX_HEIGHT = 280 // matches .select-menu's CSS max-height
export function computeFloatingMenuPosition(triggerRect) {
const spaceBelow = window.innerHeight - triggerRect.bottom - MENU_GAP - MENU_MARGIN
const spaceAbove = triggerRect.top - MENU_GAP - MENU_MARGIN
// Prefer opening below; flip above only when below doesn't fit but above has more room.
const openAbove = spaceBelow < PREFERRED_MAX_HEIGHT && spaceAbove > spaceBelow
const maxHeight = Math.max(120, Math.min(PREFERRED_MAX_HEIGHT, openAbove ? spaceAbove : spaceBelow))
return {
left: triggerRect.left,
width: triggerRect.width,
maxHeight,
...(openAbove
? { bottom: window.innerHeight - triggerRect.top + MENU_GAP }
: { top: triggerRect.bottom + MENU_GAP }),
}
}
+8
View File
@@ -110,6 +110,14 @@ export function fmtTime24(iso) {
return d.toLocaleTimeString(LOCALE_LONG, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
}
/** "2026-07-14 19:03:21" — dense log-row timestamp (date + time incl. seconds, 24h) */
export function fmtLogTimestamp(iso) {
const d = safeDate(iso)
if (!d) return '—'
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** Relative time: "just now", "5 minutes ago", "3 days ago", etc. */
export function fmtRelative(iso) {
const d = safeDate(iso)
+26
View File
@@ -0,0 +1,26 @@
// frontend/src/lib/timeRange.js
// Shared time-range presets + resolution logic for TimeRangeSelect and any
// page/component that needs to compute a { since, until } window from a key.
export const PRESETS = [
{ key: '10m', label: 'Last 10 minutes', ms: 10 * 60 * 1000 },
{ key: '1h', label: 'Last hour', ms: 60 * 60 * 1000 },
{ key: '3h', label: 'Last 3 hours', ms: 3 * 60 * 60 * 1000 },
{ key: '24h', label: 'Last 24 hours', ms: 24 * 60 * 60 * 1000 },
{ key: '3d', label: 'Last 3 days', ms: 3 * 24 * 60 * 60 * 1000 },
{ key: '7d', label: 'Last 7 days', ms: 7 * 24 * 60 * 60 * 1000 },
{ key: '30d', label: 'Last 30 days', ms: 30 * 24 * 60 * 60 * 1000 },
{ key: '3mo', label: 'Last 3 months', ms: 90 * 24 * 60 * 60 * 1000 },
{ key: '6mo', label: 'Last 6 months', ms: 182 * 24 * 60 * 60 * 1000 },
{ key: 'all', label: 'All time', ms: null },
]
/** Resolve a preset key (or 'custom') into { key, since: Date|null, until: Date|null }. */
export function resolveRange(key, customSince, customUntil) {
if (key === 'custom') {
return { key, since: customSince ?? null, until: customUntil ?? null }
}
const preset = PRESETS.find(p => p.key === key) || PRESETS[5] // default: 7d
if (preset.ms == null) return { key: preset.key, since: null, until: null }
return { key: preset.key, since: new Date(Date.now() - preset.ms), until: null }
}
@@ -172,7 +172,7 @@ export default function AddDeviceUserModal({ open, deviceId, existingUserIds = [
)}
{/* Results */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', minHeight: 80 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', minHeight: 80, maxHeight: 260, overflowY: 'auto' }}>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
<Spinner size="sm" />
@@ -3,7 +3,7 @@ import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
export default function DeleteDeviceModal({ device, onConfirm, onCancel, error }) {
const name = device?.device_name || 'this device'
const name = device?.console_name || device?.device_name || 'this device'
const serial = device?.serial_number || device?.device_id
return (
@@ -112,8 +112,23 @@ function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChang
)
}
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id }) {
const totalBells = attr?.totalBells || 8
// Console UI uses alertType 'disabled'|'single'|'multi'; firmware uses
// alert_type 'OFF'|'SINGLE'|'HOURS'. Bell fields: console uses 0 = disabled,
// firmware uses 255 = disabled.
const ALERT_TYPE_TO_FIRMWARE = { disabled: 'OFF', single: 'SINGLE', multi: 'HOURS' }
const toFirmwareBell = (n) => (n === 0 ? 255 : n)
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id, sendMqttCommand }) {
// Only bells that actually have a relay output assigned — same source of
// truth as BellsTab's bell cards. Ignores attr.totalBells, which can drift
// from the real configured count. 0 and 65535 both mean "no output assigned"
// on the wire (uint16 "unset" sentinel) — treat both as disconnected here too,
// in case older Firestore data still has 65535 values from before that was
// normalized on write.
const connectedBellIndexes = (attr?.bellOutputs || [])
.map((output, i) => ({ output, bellNumber: i + 1 }))
.filter(({ output }) => output > 0 && output !== 65535)
.map(({ bellNumber }) => bellNumber)
const [alertType, setAlertType] = useState(clock?.ringAlerts || 'disabled')
const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000)
@@ -141,12 +156,28 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
setSaving(true)
setError('')
try {
// Push to the device first — clock.set_alerts_config batches alert type/bells
// and both silence windows into one round trip. Firestore is only written
// once the device confirms.
await sendMqttCommand('clock.set_alerts_config', {
alert_type: ALERT_TYPE_TO_FIRMWARE[alertType],
alert_interval: alertType === 'multi' ? ringIntervals : undefined,
hour_bell: toFirmwareBell(Number(hourBell)),
half_bell: toFirmwareBell(Number(halfBell)),
quarter_bell: toFirmwareBell(Number(quarterBell)),
daytime_silence: isDayOn,
daytime_on: isDayOn ? daySilenceFrom : undefined,
daytime_off: isDayOn ? daySilenceTo : undefined,
night_silence: isNightOn,
night_on: isNightOn ? nightFrom : undefined,
night_off: isNightOn ? nightTo : undefined,
})
await api.put(`/devices/${id}`, {
device_attributes: {
clockSettings: {
...clock,
ringAlerts: alertType,
ringAlertsMasterOn: alertType !== 'disabled',
ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals,
hourAlertsBell: Number(hourBell),
halfhourAlertsBell: Number(halfBell),
@@ -163,6 +194,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
await onSaved()
onClose()
} catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
@@ -171,7 +203,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
const bellOptions = [
{ value: '0', label: 'Disabled' },
...Array.from({ length: totalBells }, (_, i) => ({ value: String(i + 1), label: `Bell ${i + 1}` })),
...connectedBellIndexes.map((n) => ({ value: String(n), label: `Bell ${n}` })),
]
return (
@@ -11,8 +11,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false))
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? false))
const [warningsOn, setWarningsOn] = useState(String(attr?.warningsOn ?? false))
const [hasBells, setHasBells] = useState(String(attr?.hasBells ?? false))
const [hasClock, setHasClock] = useState(String(attr?.hasClock ?? false))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@@ -20,13 +18,12 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
setSaving(true)
setError('')
try {
// App-only settings — no device-side equivalent, Firestore is the source of truth.
await api.put(`/devices/${id}`, {
device_attributes: {
bellGuardOn: toBool(bellGuardOn),
warningsOn: toBool(warningsOn),
bellGuardSafetyOn: toBool(bellGuardSafetyOn),
hasBells: toBool(hasBells),
hasClock: toBool(hasClock),
},
})
await onSaved()
@@ -42,7 +39,7 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
<Modal
open={open}
onClose={onClose}
title="Edit Basic Attributes"
title="Edit Safety Settings"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
@@ -84,31 +81,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
<option value="true">Active</option>
<option value="false">Disabled</option>
</FormField>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField
label="Bells Mechanism"
name="hasBells"
type="select"
value={hasBells}
onChange={(e) => setHasBells(e.target.value)}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</FormField>
<FormField
label="Clock Output"
name="hasClock"
type="select"
value={hasClock}
onChange={(e) => setHasClock(e.target.value)}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</FormField>
</div>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Connected bells: {Number.isFinite(attr?.totalBells) ? attr.totalBells : '—'} — managed via Bell Mechanisms tab.
</p>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
@@ -23,7 +23,7 @@ function timeToIso(hhmm) {
const toBool = (v) => v === 'true' || v === true
export default function EditBacklightModal({ open, onClose, onSaved, attr, clock, sub, id }) {
export default function EditBacklightModal({ open, onClose, onSaved, attr, clock, sub, id, sendMqttCommand }) {
const maxOutputs = sub?.maxOutputs || 8
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0)
const clockOutputsUsed = (clock?.clockOutputs || []).filter((o) => o > 0)
@@ -47,20 +47,30 @@ export default function EditBacklightModal({ open, onClose, onSaved, attr, clock
setSaving(true)
setError('')
try {
const enabled = toBool(isOn)
// Push to the device first — only write Firestore once it confirms.
await sendMqttCommand('clock.set_backlight', {
backlight: enabled,
backlight_output: Number(output),
backlight_on: enabled ? onTime : undefined,
backlight_off: enabled ? offTime : undefined,
})
await api.put(`/devices/${id}`, {
device_attributes: {
clockSettings: {
...clock,
isBacklightAutomationOn: toBool(isOn),
isBacklightAutomationOn: enabled,
backlightOutput: Number(output),
backlightTurnOnTime: toBool(isOn) && onTime ? timeToIso(onTime) : '',
backlightTurnOffTime: toBool(isOn) && offTime ? timeToIso(offTime) : '',
backlightTurnOnTime: enabled && onTime ? timeToIso(onTime) : '',
backlightTurnOffTime: enabled && offTime ? timeToIso(offTime) : '',
},
},
})
await onSaved()
onClose()
} catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
@@ -17,7 +17,7 @@ const TIMING_LABELS = {
140: 'Max — 140ms',
}
export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub, id }) {
export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub, id, sendMqttCommand }) {
const maxOutputs = sub?.maxOutputs || 8
const existingCount = attr?.totalBells || 0
const existingOutputs = attr?.bellOutputs || []
@@ -25,10 +25,15 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
const [activeBells, setActiveBells] = useState(existingCount)
const [rows, setRows] = useState(() =>
Array.from({ length: maxOutputs }, (_, i) => ({
output: existingOutputs[i] ?? 0,
Array.from({ length: maxOutputs }, (_, i) => {
// 65535 is the firmware's uint16 "unset" sentinel — treat it like 0 (Disabled)
// in case stale Firestore data still has it from before this was normalized.
const rawOutput = existingOutputs[i] ?? 0
return {
output: rawOutput === 65535 ? 0 : rawOutput,
timing: existingTimings[i] ?? 90,
}))
}
})
)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@@ -43,6 +48,25 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
setSaving(true)
setError('')
try {
// Push to the device first — relay.set_config takes both maps in one call.
// Only write to Firestore once the device confirms it actually applied
// and persisted the change (sendMqttCommand's toast reports the outcome).
//
// relay.set_config is a partial update — any bell index NOT present in
// these maps is left untouched by the firmware. So every slot beyond
// activeBells must be sent explicitly as output 0 (disabled), or a bell
// that was previously active stays wired to its old output forever,
// even though it's no longer shown as active here. The only way to
// deactivate a bell today is to select it and set it to Disabled — this
// makes "deselect it" do the same thing implicitly.
const durations = {}
const outputs = {}
rows.forEach((r, i) => {
durations[i] = r.timing
outputs[i] = i < activeBells ? r.output : 0
})
await sendMqttCommand('relay.set_config', { durations, outputs })
await api.put(`/devices/${id}`, {
device_attributes: {
totalBells: activeBells,
@@ -53,6 +77,7 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
await onSaved()
onClose()
} catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
@@ -55,7 +55,7 @@ function StepSlider({ steps, value, onChange, label }) {
)
}
export default function EditClockSettingsModal({ open, onClose, onSaved, attr, sub, id }) {
export default function EditClockSettingsModal({ open, onClose, onSaved, attr, sub, id, sendMqttCommand }) {
const clock = attr?.clockSettings || {}
const maxOutputs = sub?.maxOutputs || 8
const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0)
@@ -85,6 +85,16 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
setSaving(true)
setError('')
try {
// Push to the device first — clock.set_config batches enable/outputs/timings
// into one round trip. Firestore is only written once the device confirms.
await sendMqttCommand('clock.set_config', {
enabled: hasClock,
c1: out1,
c2: out2,
pulse_duration: runPulse,
pause_duration: pauseMs,
})
await api.put(`/devices/${id}`, {
device_attributes: {
hasClock,
@@ -98,6 +108,7 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
await onSaved()
onClose()
} catch (err) {
// sendMqttCommand already surfaced a failure toast if the device rejected it.
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
@@ -1,97 +0,0 @@
// frontend/src/modals/devices/EditLoggingModal.jsx
import { useState } from 'react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
import api from '@/lib/api'
const LOG_LEVELS = [
{ value: '0', label: '(0) Disabled' },
{ value: '1', label: '(1) Error' },
{ value: '2', label: '(2) Warning' },
{ value: '3', label: '(3) Info' },
{ value: '4', label: '(4) Debug' },
{ value: '5', label: '(5) Verbose' },
]
export default function EditLoggingModal({ open, onClose, onSaved, attr, id }) {
const [serialLevel, setSerialLevel] = useState(String(attr?.serialLogLevel ?? 0))
const [sdLevel, setSdLevel] = useState(String(attr?.sdLogLevel ?? 0))
const [mqttLevel, setMqttLevel] = useState(String(attr?.mqttLogLevel ?? 0))
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const handleSave = async () => {
setSaving(true)
setError('')
try {
await api.put(`/devices/${id}`, {
device_attributes: {
serialLogLevel: Number(serialLevel),
sdLogLevel: Number(sdLevel),
mqttLogLevel: Number(mqttLevel),
},
})
await onSaved()
onClose()
} catch (err) {
setError(err.message || 'Failed to save.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Edit Log Levels"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" loading={saving} onClick={handleSave}>Save</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<FormField
label="Serial Log Level"
name="serialLevel"
type="select"
value={serialLevel}
onChange={(e) => setSerialLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
<FormField
label="SD Card Log Level"
name="sdLevel"
type="select"
value={sdLevel}
onChange={(e) => setSdLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
<FormField
label="MQTT Log Level"
name="mqttLevel"
type="select"
value={mqttLevel}
onChange={(e) => setMqttLevel(e.target.value)}
>
{LOG_LEVELS.map((l) => (
<option key={l.value} value={l.value}>{l.label}</option>
))}
</FormField>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,281 @@
// frontend/src/modals/bellcloud/devices/ResetStatsModal.jsx
// QA-to-shipping reset — clears test data accumulated on the bench (bell
// fires, playback, reboots, debug logs) across both the console's own
// database and the physical device itself, category by category.
//
// Two categories (device_boot_data, device_logs_clear) fire real MQTT
// commands and need the device online; the rest are console-database-only
// and always run regardless of connectivity. See backend's
// POST /devices/{id}/reset-stats for the DB/Firestore side — the two
// device-side commands are sent directly here via sendMqttCommand, reusing
// the existing ack/toast infrastructure rather than duplicating a
// wait-for-device-reply mechanism in the backend.
import { useState } from 'react'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import PressHoldButton from '@/components/ui/PressHoldButton'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
// ─── Category definitions ──────────────────────────────────────────────────
// `key` matches ResetStatsRequest's field names on the backend for the
// Postgres/Firestore categories. `firmware` categories are sent as MQTT
// commands directly from this modal instead.
const CATEGORIES = [
{
group: 'Device (firmware)', items: [
{ key: 'device_boot_data', firmware: 'telemetry.reset_boot_data', label: 'Boot count & history', description: 'Lifetime boot counter and on-device SD boot log.', defaultChecked: true },
{ key: 'device_logs_clear', firmware: 'logs.clear', label: 'SD debug logs', description: 'Day-rotated debug log files stored on the device SD card.', defaultChecked: true },
],
},
{
group: 'Bell & Playback (console)', items: [
{ key: 'bell_stats', label: 'Bell strike stats', description: 'Lifetime hammer strikes and per-bell strike counts.', defaultChecked: true },
{ key: 'playback_stats', label: 'Playback stats', description: 'Lifetime melody playback count and warning count.', defaultChecked: true },
],
},
{
group: 'Console History', items: [
{ key: 'device_logs', label: 'Device logs', description: 'Console-stored debug/info log history for this device.', defaultChecked: true },
{ key: 'heartbeats', label: 'Heartbeats', description: 'RSSI/uptime/free-heap history — also clears RSSI & heap graphs.', defaultChecked: true },
{ key: 'commands', label: 'Command history', description: 'Sent-command log shown in the Control tab.', defaultChecked: true },
{ key: 'boot_events', label: 'Boot event history', description: 'Console-side boot/crash timeline (separate from the on-device log above).', defaultChecked: true },
{ key: 'alert_history', label: 'Alert history', description: 'Past alert transitions (warnings/critical/failed).', defaultChecked: true },
{ key: 'ping_samples', label: 'Ping samples', description: 'Latency history shown in the ping-latency graph.', defaultChecked: true },
{ key: 'diagnostics_reports', label: 'Diagnostics reports', description: 'CPU temp/WiFi/OTA/stack/bell-heat history from the 5-min metrics report.', defaultChecked: true },
{ key: 'reports', label: 'Device reports', description: 'History of critical board-initiated events (e.g. bell overload) from control/reports.', defaultChecked: true },
],
},
{
group: 'Advanced', items: [
{ key: 'current_alerts', label: 'Current active alerts', description: 'Clears LIVE alert state — if the device genuinely has an active fault right now, this hides it from the console until its next state change. Only enable if you are sure the device is idle/healthy.', defaultChecked: false, danger: true },
],
},
]
const ALL_KEYS = CATEGORIES.flatMap(g => g.items.map(i => i.key))
function defaultSelection() {
const sel = {}
for (const g of CATEGORIES) for (const i of g.items) sel[i.key] = i.defaultChecked
return sel
}
// ─── Checkbox row ───────────────────────────────────────────────────────────
function CategoryRow({ item, checked, onChange, disabled }) {
return (
<label
style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-3)',
padding: 'var(--space-2) 0', cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.5 : 1,
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => onChange(item.key)}
disabled={disabled}
style={{ accentColor: item.danger ? 'var(--color-danger)' : 'var(--color-primary)', flexShrink: 0, marginTop: 3 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: item.danger ? 'var(--color-danger)' : 'var(--color-text-primary)' }}>
{item.label}
</p>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 2 }}>
{item.description}
</p>
</div>
</label>
)
}
// ─── ResetStatsModal ────────────────────────────────────────────────────────
export default function ResetStatsModal({ open, onClose, deviceId, isOnline, sendMqttCommand }) {
const { toast } = useToast()
const [selection, setSelection] = useState(defaultSelection)
const [running, setRunning] = useState(false)
const [results, setResults] = useState(null) // { [key]: { ok, detail } } once run completes
const toggle = (key) => setSelection(prev => ({ ...prev, [key]: !prev[key] }))
const toggleAll = (value) => {
const sel = {}
for (const key of ALL_KEYS) sel[key] = value
setSelection(sel)
}
const anySelected = ALL_KEYS.some(k => selection[k])
const firmwareItems = CATEGORIES[0].items
const backendKeys = ALL_KEYS.filter(k => !firmwareItems.some(f => f.key === k))
const handleClose = () => {
if (running) return
setResults(null)
onClose()
}
const runReset = async () => {
setRunning(true)
const outcome = {}
// Firmware commands — only attempt if online; skip cleanly if not.
for (const item of firmwareItems) {
if (!selection[item.key]) continue
if (!isOnline) {
outcome[item.key] = { ok: false, detail: 'Device offline — skipped' }
continue
}
try {
await sendMqttCommand(item.firmware, {})
outcome[item.key] = { ok: true, detail: 'Reset on device' }
} catch (err) {
outcome[item.key] = { ok: false, detail: err.message || 'Command failed' }
}
}
// Backend (Postgres + Firestore) — always attempted regardless of connectivity.
const backendBody = {}
for (const key of backendKeys) backendBody[key] = !!selection[key]
const anyBackend = backendKeys.some(k => selection[k])
if (anyBackend) {
try {
const res = await api.post(`/devices/${deviceId}/reset-stats`, backendBody)
for (const key of backendKeys) {
if (!selection[key]) continue
const val = res[key]
outcome[key] = { ok: val !== null && val !== undefined, detail: typeof val === 'number' ? `${val} row(s) cleared` : (val ? 'Cleared' : 'No change') }
}
} catch (err) {
for (const key of backendKeys) {
if (selection[key]) outcome[key] = { ok: false, detail: err.message || 'Failed' }
}
}
}
setResults(outcome)
setRunning(false)
const failed = Object.values(outcome).filter(o => !o.ok).length
if (failed === 0) {
toast.success('Stats Reset', 'Selected categories were cleared successfully.')
} else {
toast.warning('Partially Completed', `${failed} categor${failed === 1 ? 'y' : 'ies'} could not be reset — see details below.`)
}
}
return (
<Modal
open={open}
onClose={handleClose}
title="Reset Device Stats"
size="lg"
persistent={running}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
{!results && (
<>
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)',
}}>
<Icon name="warning" size={16} color="var(--color-warning)" style={{ flexShrink: 0, marginTop: 1 }} />
<span>
This clears QA/bench test data before shipping to a customer. It does not affect network settings, bell/clock configuration, melodies, warranty records, or customer assignment. <strong>This action cannot be undone.</strong>
</span>
</div>
{!isOnline && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)',
fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)',
}}>
<Icon name="info" size={16} color="var(--color-text-muted)" style={{ flexShrink: 0, marginTop: 1 }} />
<span>Device is offline — console history will still be cleared, but on-device resets (boot count, SD logs) will be skipped.</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-2)' }}>
<Button variant="ghost" size="sm" onClick={() => toggleAll(true)}>Select All</Button>
<Button variant="ghost" size="sm" onClick={() => toggleAll(false)}>Select None</Button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', maxHeight: '50vh', overflowY: 'auto' }}>
{CATEGORIES.map(group => (
<div key={group.group}>
<span style={{
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)', letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{group.group}
</span>
<div style={{ borderTop: '1px solid var(--color-border)', marginTop: 'var(--space-2)' }}>
{group.items.map(item => (
<CategoryRow
key={item.key}
item={item}
checked={!!selection[item.key]}
onChange={toggle}
disabled={running}
/>
))}
</div>
</div>
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)', paddingTop: 'var(--space-2)', borderTop: '1px solid var(--color-border)' }}>
<Button variant="ghost" onClick={handleClose} disabled={running}>Cancel</Button>
<PressHoldButton
variant="danger"
holdMs={3000}
disabled={running || !anySelected}
holdingLabel="Keep holding to reset…"
onConfirm={runReset}
>
{running ? 'Resetting…' : 'Hold to Reset Selected'}
</PressHoldButton>
</div>
</>
)}
{results && (
<>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxHeight: '55vh', overflowY: 'auto' }}>
{CATEGORIES.flatMap(g => g.items).filter(item => selection[item.key]).map(item => {
const r = results[item.key]
return (
<div key={item.key} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)',
padding: 'var(--space-2) var(--space-3)', borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)',
}}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{item.label}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{r?.detail}</span>
<StatusBadge variant={r?.ok ? 'success' : 'warning'} size="sm">{r?.ok ? 'Done' : 'Skipped'}</StatusBadge>
</div>
</div>
)
})}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button variant="primary" onClick={handleClose}>Close</Button>
</div>
</>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,112 @@
// frontend/src/modals/bellcloud/users/CreateUserModal.jsx
// Create a new app user (Firebase Auth account + Firestore profile) from anywhere in the console.
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
export default function CreateUserModal({ open, onClose, onCreated }) {
const [email, setEmail] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
setEmail(''); setDisplayName(''); setPassword(''); setConfirm(''); setError('')
}, [open])
const handleCreate = async () => {
setError('')
if (!email || !displayName || !password) {
setError('Email, display name, and password are required.')
return
}
if (password.length < 6) {
setError('Password must be at least 6 characters.')
return
}
if (password !== confirm) {
setError('Passwords do not match.')
return
}
setSaving(true)
try {
const created = await api.post('/users', {
email,
display_name: displayName,
password,
status: 'active',
})
onCreated(created)
} catch (err) {
setError(err.message || 'Failed to create user.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Create App User"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleCreate} loading={saving}>Create User</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<FormField
label="Email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="user@example.com"
autoFocus
/>
<FormField
label="Display Name"
name="display_name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
placeholder="e.g. Jane Smith"
/>
<FormField
label="Password"
name="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="Min. 6 characters"
hint="Used to sign into the mobile app immediately."
/>
<FormField
label="Confirm Password"
name="confirm_password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
placeholder="Re-enter password"
error={confirm && password !== confirm ? 'Passwords do not match.' : undefined}
/>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,140 @@
// frontend/src/modals/crm/customers/CreateCustomerModal.jsx
// On-the-fly customer creation with minimal fields — mirrors CreateUserModal.
// Everything beyond name/folder_id/organization/email can be filled in later
// on the full customer detail page.
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import Modal from '@/components/ui/Modal'
import Button from '@/components/ui/Button'
import FormField from '@/components/ui/FormField'
function slugify(str) {
return str
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip accents
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export default function CreateCustomerModal({ open, onClose, onCreated }) {
const [name, setName] = useState('')
const [surname, setSurname] = useState('')
const [organization, setOrganization] = useState('')
const [email, setEmail] = useState('')
const [folderId, setFolderId] = useState('')
const [folderIdTouched, setFolderIdTouched] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!open) return
setName(''); setSurname(''); setOrganization(''); setEmail('')
setFolderId(''); setFolderIdTouched(false); setError('')
}, [open])
// Auto-suggest a folder slug from name+surname until the user edits it directly.
useEffect(() => {
if (folderIdTouched) return
setFolderId(slugify([name, surname].filter(Boolean).join('-')))
}, [name, surname, folderIdTouched])
const handleFolderIdChange = (e) => {
setFolderIdTouched(true)
setFolderId(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))
}
const handleCreate = async () => {
setError('')
if (!name.trim()) { setError('First name is required.'); return }
const folder = folderId.trim()
if (!folder) { setError('Internal Folder ID is required.'); return }
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(folder)) {
setError('Folder ID must contain only lowercase letters, numbers, and hyphens, and cannot start or end with a hyphen.')
return
}
setSaving(true)
try {
const created = await api.post('/crm/customers', {
name: name.trim(),
surname: surname.trim() || null,
organization: organization.trim() || null,
folder_id: folder,
relationship_status: 'lead',
contacts: email.trim() ? [{ type: 'email', label: '', value: email.trim(), primary: true }] : [],
})
onCreated(created)
} catch (err) {
setError(err.message || 'Failed to create customer.')
} finally {
setSaving(false)
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Create Customer"
size="sm"
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" onClick={handleCreate} loading={saving}>Create Customer</Button>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<FormField
label="First Name"
name="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
placeholder="First name"
autoFocus
/>
<FormField
label="Surname"
name="surname"
type="text"
value={surname}
onChange={(e) => setSurname(e.target.value)}
placeholder="Last name"
/>
</div>
<FormField
label="Organization"
name="organization"
type="text"
value={organization}
onChange={(e) => setOrganization(e.target.value)}
placeholder="e.g. Saint John's Church"
/>
<FormField
label="Contact Email"
name="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@example.com"
/>
<FormField
label="Internal Folder ID"
name="folder_id"
type="text"
value={folderId}
onChange={handleFolderIdChange}
required
placeholder="e.g. saint-john-corfu"
hint="Lowercase letters, numbers and hyphens only. Becomes the Nextcloud folder name and cannot be changed. Auto-filled from the name — edit if needed."
/>
{error && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
)}
</div>
</Modal>
)
}
@@ -0,0 +1,191 @@
// frontend/src/pages/bellcloud/devices/CrashReport.jsx
// Fleet-wide crash report (firmware F-070): every fault boot with crash
// detail, grouped so recurring crash types stand out across devices.
// Grouping (backend mqtt/crash_groups.py): abort_msg with hex addresses
// stripped when present, else faulting task + exception cause, else reset
// reason for fault resets with no coredump.
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '@/lib/api'
import PageHeader from '@/components/ui/PageHeader'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
import { resolveRange } from '@/lib/timeRange'
import CrashDetail from '@/components/shared/CrashDetail'
import { fmtDateTimeMedium, fmtRelative } from '@/lib/formatters'
import { isHeapExhaustion } from '@/lib/crashReport'
const KIND_LABEL = { abort: 'Abort', exception: 'Exception', reason: 'No coredump' }
const KIND_VARIANT = { abort: 'danger', exception: 'danger', reason: 'warning' }
function deviceLabel(device) {
return device?.console_name || device?.device_name || null
}
function CrashGroupCard({ group, devicesBySerial }) {
const [showDetail, setShowDetail] = useState(false)
const heapLow = isHeapExhaustion(group.latest?.pre_crash)
return (
<Card
title={group.title}
subtitle={`${group.count} ${group.count === 1 ? 'crash' : 'crashes'} on ${group.device_count} ${group.device_count === 1 ? 'device' : 'devices'} · last ${fmtRelative(group.last_at)}`}
action={<StatusBadge variant={KIND_VARIANT[group.kind] || 'neutral'} size="sm">{KIND_LABEL[group.kind] || group.kind}</StatusBadge>}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2) var(--space-4)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
<span>First seen {fmtDateTimeMedium(group.first_at)}</span>
<span>Last seen {fmtDateTimeMedium(group.last_at)}</span>
{heapLow && <StatusBadge variant="warning" size="sm">Latest: likely heap exhaustion</StatusBadge>}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{group.devices.map(d => {
const device = devicesBySerial[d.device_serial]
const name = deviceLabel(device)
return (
<div
key={d.device_serial}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap',
padding: 'var(--space-2) 0', borderBottom: '1px solid var(--color-border)',
}}
>
<span style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
{device ? (
<Link to={`/devices/${device.id}?tab=health`} style={{ color: 'var(--color-text-accent)', fontSize: 'var(--font-size-sm)' }}>
{name || d.device_serial}
</Link>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{d.device_serial}</span>
)}
{name && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{d.device_serial}
</span>
)}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{d.count}×
</span>
</div>
)
})}
</div>
<div>
<Button
variant="ghost"
size="sm"
iconRight={<Icon name="chevron_down" size={14} style={{ transform: showDetail ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />}
onClick={() => setShowDetail(v => !v)}
>
{showDetail ? 'Hide latest occurrence' : 'Show latest occurrence'}
</Button>
</div>
{showDetail && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{group.latest.device_serial} · boot #{group.latest.boot_count ?? '—'} · {fmtDateTimeMedium(group.latest.occurred_at)}
</span>
<CrashDetail event={group.latest} />
</div>
)}
</div>
</Card>
)
}
export default function CrashReport() {
const [range, setRange] = useState(() => resolveRange('30d'))
const [groups, setGroups] = useState([])
const [totalCrashes, setTotalCrashes] = useState(0)
const [devices, setDevices] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const p = new URLSearchParams()
if (range.since) p.set('since', range.since.toISOString())
if (range.until) p.set('until', range.until.toISOString())
const qs = p.toString()
const [crashRes, deviceRes] = await Promise.all([
api.get(`/mqtt/crash-groups${qs ? `?${qs}` : ''}`),
// Only for names/links — the report itself doesn't depend on it.
api.get('/devices').catch(() => ({ devices: [] })),
])
setGroups(crashRes.groups || [])
setTotalCrashes(crashRes.total_crashes || 0)
setDevices(deviceRes.devices || [])
} catch (err) {
setError(err.message || 'Failed to load crash report.')
} finally {
setLoading(false)
}
}, [range])
useEffect(() => { load() }, [load])
const devicesBySerial = useMemo(() => {
const map = {}
for (const d of devices) {
const sn = d.serial_number || d.device_id
if (sn) map[sn] = d
}
return map
}, [devices])
const affectedDevices = useMemo(
() => new Set(groups.flatMap(g => g.devices.map(d => d.device_serial))).size,
[groups],
)
return (
<div className="page-wrapper">
<PageHeader
title="Crash Report"
subtitle="Fault resets across the fleet, grouped by crash signature"
breadcrumbs={[{ label: 'Device Fleet', href: '/devices' }, { label: 'Crash Report' }]}
>
<div style={{ width: 'min(220px, 100%)' }}>
<TimeRangeSelect value={range} onChange={setRange} />
</div>
</PageHeader>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
<Spinner size="lg" />
</div>
) : error ? (
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)' }}>
{error}
</div>
) : groups.length === 0 ? (
<Card>
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
No crashes recorded in this range.
</div>
</Card>
) : (
<>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
{totalCrashes} {totalCrashes === 1 ? 'crash' : 'crashes'} in {groups.length} {groups.length === 1 ? 'group' : 'groups'} across {affectedDevices} {affectedDevices === 1 ? 'device' : 'devices'}
</span>
<div className="masonry-grid masonry-grid--2">
{groups.map(g => (
<CrashGroupCard key={g.key} group={g} devicesBySerial={devicesBySerial} />
))}
</div>
</>
)}
</div>
)
}
@@ -6,15 +6,19 @@ import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import { useToast } from '@/components/ui/Toast'
import { useDeviceCommand } from '@/hooks/useDeviceCommand'
import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button'
import SignalIndicator from '@/components/ui/SignalIndicator'
import EditableText from '@/components/ui/EditableText'
import Icon from '@/components/ui/Icon'
import CopyableSerial from '@/components/shared/CopyableSerial'
import Tabs from '@/components/ui/Tabs'
import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal'
@@ -31,11 +35,13 @@ import ClockTab from '@/pages/bellcloud/devices/tabs/ClockTab'
import WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab'
import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab'
// ─── Tab config ───────────────────────────────────────────────────────────────
const TABS = [
{ key: 'overview', label: 'Overview' },
{ key: 'health', label: 'Health' },
{ key: 'general', label: 'General' },
{ key: 'bells', label: 'Bell Mechanisms' },
{ key: 'clock', label: 'Clock & Alerts' },
@@ -44,10 +50,15 @@ const TABS = [
{ key: 'control', label: 'Control' },
]
// 'logs' used to be its own top-level tab — now a sub-tab of Health.
// Old bookmarks/links with ?tab=logs still land somewhere sensible.
const LEGACY_TAB_REDIRECTS = { logs: 'health' }
function resolveInitialTab(searchParams) {
const raw = searchParams.get('tab')
const raw = searchParams.get('tab')?.toLowerCase()
if (!raw) return 'overview'
const match = TABS.find(t => t.key === raw.toLowerCase())
if (LEGACY_TAB_REDIRECTS[raw]) return LEGACY_TAB_REDIRECTS[raw]
const match = TABS.find(t => t.key === raw)
return match ? match.key : 'overview'
}
@@ -81,12 +92,10 @@ export default function DeviceDetail() {
const [cmdHistory, setCmdHistory] = useState([])
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
const [sendingCmd, setSendingCmd] = useState('')
// Modal open flags
const [editingLocation, setEditingLocation] = useState(false)
const [editingAttributes, setEditingAttributes] = useState(false)
const [editingLogging, setEditingLogging] = useState(false)
const [editingMisc, setEditingMisc] = useState(false)
const [editingBellOutputs, setEditingBellOutputs] = useState(false)
const [editingClockSettings, setEditingClockSettings] = useState(false)
@@ -139,6 +148,19 @@ export default function DeviceDetail() {
useEffect(() => { loadDevice() }, [loadDevice])
// Re-fetches the device without toggling the page-level loading spinner —
// used by background reconciliation (e.g. ClockTab/BellsTab silently
// correcting drift from a GET-config refresh) so it doesn't unmount/remount
// the active tab mid-sync.
const reloadDeviceSilent = useCallback(async () => {
try {
const d = await api.get(`/devices/${id}`)
setDevice(d)
} catch {
// Silent by design — a failed background refresh isn't worth surfacing.
}
}, [id])
const loadCmdHistory = useCallback(async () => {
const sn = device?.serial_number || device?.device_id
if (!sn) return
@@ -157,6 +179,61 @@ export default function DeviceDetail() {
if (activeTab === 'control' && device) loadCmdHistory()
}, [activeTab, device, loadCmdHistory])
// ── Command ack (live reply over WebSocket) ───────────────────────────────
// Send/ack-queue machinery lives in useDeviceCommand (shared with the
// onboarding wizard). Heartbeats aren't command acks, so they're handled
// here via onNonAckMessage — the hook forwards anything that isn't a
// 'control/ack' reply for this device instead of opening a second WS connection.
const deviceSerial = device?.serial_number || device?.device_id
const handleNonAckMessage = useCallback((msg) => {
// Live heartbeat — updates the online dot/RSSI without polling. The device
// publishes one every ~30s; we just reflect whatever lands here instead of
// waiting for the next page load / GET /mqtt/status round trip.
// v2 heartbeat payload is FLAT (no nested .payload wrapper) — see
// vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human.
if (msg.type !== 'status/heartbeat') return
// Retained replay = the device's LAST heartbeat, re-sent by the broker when
// the backend reconnects — not proof the device is alive now.
if (msg.retained) return
const hb = msg.payload || {}
// LWT / graceful-disconnect marker: the device just went away.
if (hb.state === 'offline') {
setMqttStatus(prev => prev ? { ...prev, online: false } : prev)
return
}
setMqttStatus(prev => ({
device_serial: deviceSerial,
online: true,
seconds_since_heartbeat: 0,
last_alert_event: prev?.last_alert_event ?? null,
last_heartbeat: {
...(prev?.last_heartbeat || {}),
device_id: hb.device_id ?? prev?.last_heartbeat?.device_id,
firmware_version: hb.fw_version ?? prev?.last_heartbeat?.firmware_version,
ip_address: hb.ip_address ?? prev?.last_heartbeat?.ip_address,
gateway: hb.gateway ?? prev?.last_heartbeat?.gateway,
uptime_ms: hb.uptime_ms ?? prev?.last_heartbeat?.uptime_ms,
uptime_display: hb.uptime_human ?? prev?.last_heartbeat?.uptime_display,
rssi: hb.rssi ?? prev?.last_heartbeat?.rssi,
free_heap: hb.free_heap ?? prev?.last_heartbeat?.free_heap,
state: hb.state ?? prev?.last_heartbeat?.state,
ok: hb.ok ?? prev?.last_heartbeat?.ok,
received_at: new Date().toISOString(),
},
}))
}, [deviceSerial])
// DeviceDetail additionally refreshes the Control tab's command history as
// soon as a command is accepted for sending — the shared hook doesn't know
// about that concern, so it's wired in via onCommandSent.
const { sendMqttCommand, sendMqttCommandSilent, sendingCmd } = useDeviceCommand({
deviceSerial,
onNonAckMessage: handleNonAckMessage,
onCommandSent: loadCmdHistory,
})
// ── Tab sync ───────────────────────────────────────────────────────────────
const handleTabChange = key => {
@@ -213,20 +290,19 @@ export default function DeviceDetail() {
}
}
const sendMqttCommand = async (cmd, contents = {}) => {
const deviceId = device?.device_id || device?.serial_number
if (!deviceId) return
setSendingCmd(cmd)
// Console-only friendly name — separate from device_name (the name the end
// user gave the device in the app). Saves optimistically into local state
// so the hero title updates immediately; a failed save reverts and toasts.
const handleSaveConsoleName = useCallback(async (newName) => {
const previous = device?.console_name || ''
setDevice(d => d ? { ...d, console_name: newName } : d)
try {
await api.post(`/mqtt/command/${deviceId}`, { cmd, contents })
toast.success('Sent', `Command "${cmd}" sent.`)
loadCmdHistory()
await api.put(`/devices/${id}`, { console_name: newName })
} catch (err) {
toast.danger('Error', err.message || 'Failed to send command.')
} finally {
setSendingCmd('')
}
setDevice(d => d ? { ...d, console_name: previous } : d)
toast({ variant: 'danger', title: 'Failed to rename device', description: err.message || 'Please try again.' })
}
}, [id, device, toast])
// ── Derived ────────────────────────────────────────────────────────────────
@@ -272,6 +348,7 @@ export default function DeviceDetail() {
stats,
sn,
isOnline,
mqttStatus,
tags,
setTags,
staffNotes,
@@ -284,14 +361,15 @@ export default function DeviceDetail() {
cmdHistoryLoading,
sendingCmd,
loadDevice,
reloadDeviceSilent,
loadCmdHistory,
sendMqttCommand,
sendMqttCommandSilent,
onShowDelete: () => setShowDelete(true),
onAssignCustomer: () => setShowAssignCustomer(true),
onUnassignCustomer: handleUnassignCustomer,
onEditLocation: () => setEditingLocation(true),
onEditAttributes: () => setEditingAttributes(true),
onEditLogging: () => setEditingLogging(true),
onEditMisc: () => setEditingMisc(true),
onEditBellOutputs: () => setEditingBellOutputs(true),
onEditClockSettings: () => setEditingClockSettings(true),
@@ -305,12 +383,44 @@ export default function DeviceDetail() {
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="page-wrapper">
<div className="page-wrapper page-wrapper--capped">
<PageHeader
title={device.device_name || 'Unnamed Device'}
subtitle={sn}
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<span style={{
width: 48, height: 48,
borderRadius: 'var(--radius-xl)',
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<SignalIndicator rssi={mqttStatus?.last_heartbeat?.rssi} isOnline={isOnline} size="md" />
</span>
<span style={{ display: 'flex', flexDirection: 'column' }}>
<EditableText
value={device.console_name || ''}
placeholder={device.device_name || 'Unnamed Device'}
onSave={handleSaveConsoleName}
editable={canEdit}
inputAriaLabel="Edit console name"
/>
<CopyableSerial
serial={sn}
className="page-header-subtitle"
style={{ margin: 0, color: 'var(--color-text-muted)', fontSize: 'var(--font-size-base)', lineHeight: 'var(--line-height-base)' }}
/>
</span>
</span>
}
>
<Button variant="ghost" onClick={() => sendMqttCommand('ping')}>
Ping Now
</Button>
{canEdit && (
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
Edit Device
@@ -326,6 +436,7 @@ export default function DeviceDetail() {
<div style={{ flex: 1, minWidth: 0 }}>
{activeTab === 'overview' && <OverviewTab {...tabProps} />}
{activeTab === 'health' && <HealthTab {...tabProps} />}
{activeTab === 'general' && <GeneralTab {...tabProps} />}
{activeTab === 'bells' && <BellsTab {...tabProps} />}
{activeTab === 'clock' && <ClockTab {...tabProps} />}
@@ -350,13 +461,6 @@ export default function DeviceDetail() {
attr={attr}
id={id}
/>
<EditLoggingModal
open={editingLogging}
onClose={() => setEditingLogging(false)}
onSaved={loadDevice}
attr={attr}
id={id}
/>
<EditMiscModal
open={editingMisc}
onClose={() => setEditingMisc(false)}
@@ -372,6 +476,7 @@ export default function DeviceDetail() {
attr={attr}
sub={sub}
id={id}
sendMqttCommand={sendMqttCommand}
/>
<EditClockSettingsModal
open={editingClockSettings}
@@ -380,6 +485,7 @@ export default function DeviceDetail() {
attr={attr}
sub={sub}
id={id}
sendMqttCommand={sendMqttCommand}
/>
<EditAlertsModal
open={editingAlerts}
@@ -388,6 +494,7 @@ export default function DeviceDetail() {
clock={clock}
attr={attr}
id={id}
sendMqttCommand={sendMqttCommand}
/>
<EditBacklightModal
open={editingBacklight}
@@ -397,6 +504,7 @@ export default function DeviceDetail() {
clock={clock}
sub={sub}
id={id}
sendMqttCommand={sendMqttCommand}
/>
<EditSubscriptionModal
open={editingSubscription}
@@ -422,7 +530,7 @@ export default function DeviceDetail() {
open={showDelete}
variant="danger"
title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || sn}"? This action cannot be undone.`}
message={`Are you sure you want to delete "${device.console_name || device.device_name || sn}"? This action cannot be undone.`}
confirmLabel="Delete"
onConfirm={handleDelete}
onCancel={() => setShowDelete(false)}
@@ -4,10 +4,12 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button'
import DataTable from '@/components/ui/DataTable'
import StatusBadge from '@/components/ui/StatusBadge'
import SignalIndicator from '@/components/ui/SignalIndicator'
import Pagination from '@/components/ui/Pagination'
import SearchBar from '@/components/ui/SearchBar'
import RowActions from '@/components/ui/RowActions'
@@ -25,6 +27,7 @@ import DeviceListMapView from './DeviceListMapView'
const ALL_COLUMNS = [
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
{ key: 'appName', label: 'App Name', pickerLabel: 'App Name (user-given)', defaultOn: false, sortable: true },
{ key: 'serialNumber', label: 'Serial Number', defaultOn: true },
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
@@ -48,8 +51,97 @@ const ALL_COLUMNS = [
{ key: 'tags', label: 'Tags', defaultOn: false },
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
{ key: 'hw_revision', label: 'HW Revision', defaultOn: false },
{ key: 'uptime', label: 'Uptime', defaultOn: true, sortable: true },
{ key: 'firmwareVersion', label: 'Firmware', defaultOn: true },
{ key: 'signalStrength', label: 'Signal', defaultOn: false, sortable: true },
{ key: 'cpuTemp', label: 'CPU Temp', defaultOn: false, sortable: true },
{ key: 'pingLatency', label: 'Ping', defaultOn: false, sortable: true },
]
// Values older than this are shown greyed-out instead of full-strength text —
// "stale" meaning the last poll happened long enough ago that the reading may
// no longer reflect reality. Mirrors the offline/diagnostics cadences used on
// the Health tab (HealthTab.jsx heartbeatGapMs/diagGapMs) rather than inventing
// new numbers here.
const HEARTBEAT_STALE_MS = 90 * 1000 // uptime, firmware, signal — from 30s heartbeat
const DIAG_STALE_MS = 15 * 60 * 1000 // cpu temp — diagnostics report every 5 min
const PING_STALE_MS = 15 * 60 * 1000 // ping — sampled every 60s, but not all devices have RTC
// Approximates to the single largest non-zero unit only — "13d 1h 55m"
// becomes "13 days", "14h 27m" becomes "14 hours" — a precise breakdown is
// too dense for a table column; the Health tab's KPI tile still shows the
// full d/h/m form.
function formatUptimeApprox(ms) {
if (ms == null) return null
const seconds = Math.floor(ms / 1000)
const days = Math.floor(seconds / 86400)
if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}`
const hours = Math.floor(seconds / 3600)
if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}`
const minutes = Math.floor(seconds / 60)
return `${minutes} minute${minutes !== 1 ? 's' : ''}`
}
// Freshly-booted devices are more likely to be mid-recovery from a crash/OTA/
// power event — color signals "how settled is this device," not severity in
// the danger/warning sense, so it deliberately doesn't reuse rssiToColor's
// palette. --mail-filter-sales-text is the only "orange" token in the design
// system, reused here for the 10-30min middle band.
function uptimeToColor(ms) {
if (ms == null) return null
const minutes = ms / 60000
if (minutes < 10) return 'var(--color-warning)'
if (minutes < 30) return 'var(--mail-filter-sales-text)'
if (minutes < 60) return 'var(--color-success)'
return 'var(--color-info)'
}
function rssiToColor(rssi) {
if (rssi == null) return null
if (rssi > -50) return 'var(--color-success)'
if (rssi >= -60) return 'var(--color-info)'
if (rssi >= -70) return 'var(--color-warning)'
return 'var(--color-danger)'
}
// Mirrors HealthTab's CPU-temp gradient stops (HealthTab.jsx tempColorStops)
// — reads the same per-device device_health_settings thresholds (already
// present on the device doc from GET /devices, no extra request needed)
// rather than a hardcoded fleet-wide number, so a device with a custom
// ceiling shows the same color here as on its own Health tab.
function cpuTempToColor(temp, healthSettings) {
if (temp == null) return null
const ceiling = healthSettings?.warning?.cpu_temp_ceiling_c ?? 65
const critical = healthSettings?.critical?.cpu_temp_ceiling_c ?? 80
if (temp >= critical) return 'var(--color-danger)'
if (temp >= ceiling) return 'var(--color-warning)'
return 'var(--color-info)'
}
function pingToColor(rttMs) {
if (rttMs == null) return null
if (rttMs <= 150) return 'var(--color-success)'
if (rttMs <= 300) return 'var(--color-warning)'
return 'var(--color-danger)'
}
// Whether a timestamp is old enough to render its value greyed-out — "grey
// means stale" convention used for status dots elsewhere. Plain helper (not
// a component) so the Date.now() read happens at row-render time in the
// column's render() callback, not inside a component body.
function isStaleAt(timestamp, maxAgeMs) {
return !timestamp || (Date.now() - new Date(timestamp).getTime()) > maxAgeMs
}
function StaleableValue({ text, isStale, color }) {
if (text == null) return <Muted>—</Muted>
return (
<span style={{ color: isStale ? 'var(--color-text-muted)' : (color || 'var(--color-text-primary)'), opacity: isStale ? 0.5 : 1 }}>
{text}
</span>
)
}
const ALL_KEYS = ALL_COLUMNS.map((c) => c.key)
function loadColumnPrefs() {
@@ -75,6 +167,13 @@ function saveColumnPrefs(order, visible) {
// ─── Helpers ──────────────────────────────────────────────────────────────────
// The console-only friendly name always wins when set — the user-given
// device_name is unreliable (random/joke names some end users pick) and is
// still available separately via the "App Name" column.
function deviceDisplayName(device) {
return device.console_name || device.device_name || 'Unnamed Device'
}
function parseFirestoreDate(str) {
if (!str) return null
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
@@ -157,12 +256,17 @@ function Muted({ children }) {
// ─── Sorting helper ───────────────────────────────────────────────────────────
function getSortValue(device, key) {
function getSortValue(device, key, metrics) {
const attr = device.device_attributes || {}
const sub = device.device_subscription || {}
const stats = device.device_stats || {}
const sn = device.serial_number || device.device_id
const hb = metrics?.mqttStatusMap[sn]?.last_heartbeat
const diag = metrics?.latestDiagMap[sn]
const ping = metrics?.latestPingMap[sn]
switch (key) {
case 'name': return (device.device_name || '').toLowerCase()
case 'name': return deviceDisplayName(device).toLowerCase()
case 'appName': return (device.device_name || '').toLowerCase()
case 'location': return (device.device_location || '').toLowerCase()
case 'totalBells': return attr.totalBells ?? -1
case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0
@@ -171,15 +275,19 @@ function getSortValue(device, key) {
case 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
case 'totalMelodies': return device.device_melodies_all?.length ?? -1
case 'uptime': return hb?.uptime_ms ?? -1
case 'signalStrength': return hb?.rssi ?? -999
case 'cpuTemp': return diag?.cpu_temp_avg ?? -999
case 'pingLatency': return ping?.rtt_ms ?? -1
default: return 0
}
}
function sortDevices(devices, key, dir) {
function sortDevices(devices, key, dir, metrics) {
if (!key) return devices
return [...devices].sort((a, b) => {
const va = getSortValue(a, key)
const vb = getSortValue(b, key)
const va = getSortValue(a, key, metrics)
const vb = getSortValue(b, key, metrics)
if (va < vb) return dir === 'asc' ? -1 : 1
if (va > vb) return dir === 'asc' ? 1 : -1
return 0
@@ -199,6 +307,8 @@ export default function DeviceList() {
const [error, setError] = useState('')
const [mqttStatusMap, setMqttStatusMap] = useState({})
const [mqttLoaded, setMqttLoaded] = useState(false)
const [latestDiagMap, setLatestDiagMap] = useState({})
const [latestPingMap, setLatestPingMap] = useState({})
// Server-side filters
const [search, setSearch] = useState('')
@@ -291,6 +401,21 @@ export default function DeviceList() {
}
setMqttLoaded(true)
}).catch(() => { setMqttLoaded(true) })
// Last-known CPU temp + ping RTT — one extra request, fleet-wide,
// reading whatever the background MQTT ingestion already stored.
// Never triggers a live poll of any device.
api.get('/mqtt/latest-metrics').then((metrics) => {
if (metrics?.diagnostics) {
const dMap = {}
for (const d of metrics.diagnostics) dMap[d.device_serial] = d
setLatestDiagMap(dMap)
}
if (metrics?.pings) {
const pMap = {}
for (const p of metrics.pings) pMap[p.device_serial] = p
setLatestPingMap(pMap)
}
}).catch(() => { /* optional columns — non-fatal if unavailable */ })
} catch (err) {
setError(err.message || 'Failed to load devices.')
} finally {
@@ -300,6 +425,54 @@ export default function DeviceList() {
useEffect(() => { fetchDevices() }, [fetchDevices])
// ── Live status via WebSocket ────────────────────────────────────────────
// Devices publish a heartbeat every ~30s; reflect it in the row the moment
// it arrives instead of waiting for the next fetchDevices() round trip.
// First paint still comes from the one-shot /mqtt/status fetch above — this
// just keeps it fresh without polling.
useMqttWebSocket({
enabled: true,
onMessage: (msg) => {
if (msg?.type !== 'status/heartbeat') return
// v2 heartbeat payload is FLAT (no nested .payload wrapper) — see
// vesper_mqtt_topic_spec_v2.md. field names: fw_version, uptime_human.
// Retained replay = the device's LAST heartbeat, re-sent by the broker
// when the backend reconnects — not proof the device is alive now.
if (msg.retained) return
const hb = msg.payload || {}
const serial = msg.device_serial
if (!serial) return
// LWT / graceful-disconnect marker: the device just went away.
if (hb.state === 'offline') {
setMqttStatusMap(prev => prev[serial] ? { ...prev, [serial]: { ...prev[serial], online: false } } : prev)
return
}
setMqttStatusMap(prev => ({
...prev,
[serial]: {
...(prev[serial] || {}),
device_serial: serial,
online: true,
seconds_since_heartbeat: 0,
last_heartbeat: {
...(prev[serial]?.last_heartbeat || {}),
device_id: hb.device_id ?? prev[serial]?.last_heartbeat?.device_id,
firmware_version: hb.fw_version ?? prev[serial]?.last_heartbeat?.firmware_version,
ip_address: hb.ip_address ?? prev[serial]?.last_heartbeat?.ip_address,
gateway: hb.gateway ?? prev[serial]?.last_heartbeat?.gateway,
uptime_ms: hb.uptime_ms ?? prev[serial]?.last_heartbeat?.uptime_ms,
uptime_display: hb.uptime_human ?? prev[serial]?.last_heartbeat?.uptime_display,
rssi: hb.rssi ?? prev[serial]?.last_heartbeat?.rssi,
free_heap: hb.free_heap ?? prev[serial]?.last_heartbeat?.free_heap,
state: hb.state ?? prev[serial]?.last_heartbeat?.state,
ok: hb.ok ?? prev[serial]?.last_heartbeat?.ok,
received_at: new Date().toISOString(),
},
},
}))
},
})
useEffect(() => {
setPage(1)
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
@@ -340,7 +513,7 @@ export default function DeviceList() {
return true
})
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir)
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir, { mqttStatusMap, latestDiagMap, latestPingMap })
const total = sortedDevices.length
const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize)
@@ -376,16 +549,24 @@ export default function DeviceList() {
const sn = device.serial_number || device.device_id
const mqtt = mqttStatusMap[sn]
const isOnline = mqtt ? mqtt.online : device.is_Online
const hb = mqtt?.last_heartbeat
const diag = latestDiagMap[sn]
const ping = latestPingMap[sn]
switch (c.key) {
case 'status':
return <OnlineDot isOnline={isOnline} />
case 'name':
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<SignalIndicator rssi={mqtt?.last_heartbeat?.rssi} isOnline={isOnline} size="sm" />
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{device.device_name || 'Unnamed Device'}
{deviceDisplayName(device)}
</span>
</span>
)
case 'appName':
return device.device_name || <Muted>—</Muted>
case 'serialNumber':
return (
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
@@ -436,6 +617,45 @@ export default function DeviceList() {
return device.hw_family || <Muted>—</Muted>
case 'hw_revision':
return device.hw_revision || <Muted>—</Muted>
case 'uptime':
return (
<StaleableValue
text={formatUptimeApprox(hb?.uptime_ms)}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
color={uptimeToColor(hb?.uptime_ms)}
/>
)
case 'firmwareVersion':
return (
<StaleableValue
text={hb?.firmware_version || null}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
/>
)
case 'signalStrength':
return (
<StaleableValue
text={hb?.rssi != null ? `${hb.rssi} dBm` : null}
isStale={isStaleAt(hb?.received_at, HEARTBEAT_STALE_MS)}
color={rssiToColor(hb?.rssi)}
/>
)
case 'cpuTemp':
return (
<StaleableValue
text={diag?.cpu_temp_avg != null ? `${diag.cpu_temp_avg.toFixed(1)}°C` : null}
isStale={isStaleAt(diag?.received_at, DIAG_STALE_MS)}
color={cpuTempToColor(diag?.cpu_temp_avg, device.device_health_settings)}
/>
)
case 'pingLatency':
return (
<StaleableValue
text={ping?.rtt_ms != null ? `${ping.rtt_ms} ms` : null}
color={pingToColor(ping?.rtt_ms)}
isStale={isStaleAt(ping?.sampled_at, PING_STALE_MS)}
/>
)
default:
return <Muted>—</Muted>
}
@@ -493,6 +713,9 @@ export default function DeviceList() {
: undefined
}
>
<Button variant="secondary" icon={<Icon name="warning" size={14} />} onClick={() => navigate('/devices/crashes')}>
Crash Report
</Button>
{canEdit && (
<Button variant="primary" onClick={() => navigate('/devices/new')}>
Add Device
@@ -7,6 +7,7 @@
import { useMemo } from 'react'
import Icon from '@/components/ui/Icon'
import SignalIndicator from '@/components/ui/SignalIndicator'
// ─── Tier config ──────────────────────────────────────────────────────────────
@@ -194,13 +195,17 @@ function MapBackground({ coords }) {
// ─── Online status block (rectangular, spans both identity rows) ──────────────
function OnlineBlock({ online }) {
function OnlineBlock({ online, rssi }) {
return (
<div
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
aria-label={online ? 'Online' : 'Offline'}
>
{online ? (
<SignalIndicator rssi={rssi} isOnline size="sm" />
) : (
<span className="dcard__online-block-dot" aria-hidden="true" />
)}
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
</div>
)
@@ -241,7 +246,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
const mqtt = mqttStatusMap[sn]
const isOnline = mqtt ? mqtt.online : device.is_Online
const name = device.device_name || 'Unnamed Device'
const name = device.console_name || device.device_name || 'Unnamed Device'
const location = device.device_location
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
const tierLabel = getTierLabel(sub.subscrTier)
@@ -266,7 +271,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
{/* ── Header: status block + identity ── */}
<header className="dcard__header">
<OnlineBlock online={isOnline} />
<OnlineBlock online={isOnline} rssi={mqtt?.last_heartbeat?.rssi} />
<div className="dcard__identity">
<div className="dcard__name-row">
@@ -589,7 +589,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
style={{ cursor: 'pointer' }}
role="button"
aria-label={`${device.device_name || 'Device'} — ${color.label}`}
aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
>
{/* Breathing ring */}
<circle
@@ -767,7 +767,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
aria-hidden="true"
/>
<h2 className="dmv-drawer__name">
{selected.device_name || 'Unnamed Device'}
{selected.console_name || selected.device_name || 'Unnamed Device'}
</h2>
<button
className="dmv-drawer__close"
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,8 @@
// Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count,
// master switch, and per-bell glass cards with output, timing, size, and strike stats.
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
@@ -91,7 +90,7 @@ const SPEC_TILE = {
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
const strikerSize = getStrikerSize(hammingMs)
const isDisabled = Number.isFinite(output) && Number(output) === 0
const isDisabled = Number.isFinite(output) && (Number(output) === 0 || Number(output) === 65535)
const [hovered, setHovered] = useState(false)
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
@@ -272,23 +271,85 @@ export default function BellsTab({
attr,
canEdit,
loadDevice,
reloadDeviceSilent,
sendMqttCommand,
sendMqttCommandSilent,
onEditBellOutputs,
}) {
const { toast } = useToast()
const [togglingCommander, setTogglingCommander] = useState(false)
const [requestingCounters, setRequestingCounters] = useState(false)
const [liveCounters] = useState(null)
const [confirmDisable, setConfirmDisable] = useState(false)
// Derived data
// Derived data — shown instantly from Firestore on load, then silently
// reconciled against the device's true on-device config (see effect below).
const bellOutputs = attr?.bellOutputs || []
const hammerTimings = attr?.hammerTimings || []
const totalBells = attr?.totalBells || 0
const hasBells = attr?.hasBells ?? false
const bellGuardOn = attr?.bellGuardOn ?? false
// ── Background refresh: pull true state from the device right after load ──
// Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders
// against StrictMode's double-invoke or any parent re-render retriggering
// this effect). Reconciles bells_enabled/durations/outputs against Firestore
// and silently corrects any drift — no toast, this is a quiet sync.
const hasSyncedRef = useRef(false)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('bells.get_config').then((reply) => {
const data = reply?.data || {}
const durations = data.durations || {}
const outputs = data.outputs || {}
const channelCount = Math.max(
Object.keys(durations).length,
Object.keys(outputs).length,
totalBells,
)
if (channelCount === 0) return
// Firmware uses both 0 and 65535 (uint16 "unset") as "no output assigned" —
// treat both as disabled so unconfigured channels are never mistaken for
// connected bells.
const isDisabledOutput = (o) => !o || o === 65535
// The console's data model assumes bells are a contiguous block starting at
// index 0 (see EditBellOutputsModal / totalBells). The device, however, can
// report enabled channels at any index with gaps in between — so compact
// only the genuinely-connected channels down to a contiguous list, in
// channel order, rather than naively slicing the raw 16-slot arrays.
const connectedIndexes = Array.from({ length: channelCount }, (_, i) => i)
.filter((i) => !isDisabledOutput(Number(outputs[i] ?? 0)))
const trueOutputs = connectedIndexes.map((i) => Number(outputs[i]))
const trueTimings = connectedIndexes.map((i) => Number(durations[i] ?? 0))
const trueEnabled = !!data.bells_enabled
const trueActiveCount = trueOutputs.length
const outputsDiffer = JSON.stringify(trueOutputs) !== JSON.stringify(bellOutputs)
const timingsDiffer = JSON.stringify(trueTimings) !== JSON.stringify(hammerTimings)
const enabledDiffers = trueEnabled !== hasBells
if (!outputsDiffer && !timingsDiffer && !enabledDiffers) return
api.put(`/devices/${deviceId}`, {
device_attributes: {
hasBells: trueEnabled,
totalBells: trueActiveCount,
bellOutputs: trueOutputs,
hammerTimings: trueTimings,
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// Request strike counters via MQTT
const requestStrikeCounters = useCallback(async () => {
if (!sendMqttCommand) return
@@ -301,20 +362,19 @@ export default function BellsTab({
}
}, [sendMqttCommand])
// Toggle Bell Commander (master switch)
// Toggle Bell Commander (master switch) — sends bells.enable/bells.disable to
// the device first; Firestore (hasBells) is only updated once the device
// confirms SUCCESS. sendMqttCommand already shows the blue→green/red toast.
const handleToggleCommander = async (newVal) => {
setTogglingCommander(true)
try {
await sendMqttCommand(newVal ? 'bells.enable' : 'bells.disable')
await api.put(`/devices/${device.id || device.device_id}`, {
device_attributes: { hasBells: newVal },
})
await loadDevice()
toast.success(
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
newVal ? 'All bell mechanisms are now active.' : 'All bell mechanisms have been disabled.'
)
} catch (err) {
toast.danger('Error', err.message || 'Failed to update Bell Commander.')
} catch {
// sendMqttCommand's toast already reported the failure — nothing further to do.
} finally {
setTogglingCommander(false)
}
@@ -495,7 +555,7 @@ export default function BellsTab({
setConfirmDisable(false)
handleToggleCommander(false)
}}
onCancel={() => setConfirmDisable(false)}
onClose={() => setConfirmDisable(false)}
loading={togglingCommander}
/>
@@ -5,7 +5,6 @@ import api from '@/lib/api'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import { useToast } from '@/components/ui/Toast'
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -45,6 +44,16 @@ function silenceLabel(isOn, from, to) {
return 'On'
}
function formatUtcOffset(offsetSec) {
if (offsetSec == null) return null
const totalMinutes = offsetSec / 60
const sign = totalMinutes >= 0 ? '+' : '-'
const abs = Math.abs(totalMinutes)
const hours = Math.floor(abs / 60)
const minutes = abs % 60
return minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`
}
// ─── Analog Clock Face ────────────────────────────────────────────────────────
function AnalogClock({ time, label, variant = 'primary' }) {
@@ -261,19 +270,111 @@ export default function ClockTab({
onEditClockSettings,
onEditAlerts,
onEditBacklight,
onDeviceUpdated,
sendMqttCommand,
sendMqttCommandSilent,
loadDevice,
reloadDeviceSilent,
}) {
const [now, setNow] = useState(new Date())
const [showSync, setShowSync] = useState(false)
const [flashFields, setFlashFields] = useState(false)
const [togglingClock, setTogglingClock] = useState(false)
const { toast } = useToast()
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
// ── Background refresh: pull true state from the device right after load ──
// Fires at most once per mount (hasSyncedRef guard — belt-and-suspenders
// against StrictMode's double-invoke or any parent re-render retriggering
// this effect). Reconciles enabled/outputs/timings/alerts/backlight/silence
// against Firestore using semantic (not raw-string) comparison, and silently
// corrects any real drift — no toast.
const hasSyncedRef = useRef(false)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('clock.get_config').then((reply) => {
const data = reply?.data || {}
if (!data || Object.keys(data).length === 0) return
const trueAlertType = { OFF: 'disabled', SINGLE: 'single', HOURS: 'multi' }[data.alert_type] ?? 'disabled'
const trueHourBell = data.hour_bell === 255 ? 0 : (data.hour_bell || 0)
const trueHalfBell = data.half_bell === 255 ? 0 : (data.half_bell || 0)
const trueQuarterBell = data.quarter_bell === 255 ? 0 : (data.quarter_bell || 0)
const trueC1 = data.c1output || 0
const trueC2 = data.c2output || 0
const truePulse = data.pulse_duration || 0
const truePause = data.pause_duration || 0
const trueBacklightOutput = data.backlight_output === 255 ? 0 : (data.backlight_output || 0)
// Semantic comparison — normalize times to HH:MM via extractTime so format
// differences (timezone suffix, seconds, etc.) never register as false drift.
const hasDrift =
!!data.enabled !== (attr?.hasClock ?? false) ||
trueC1 !== (clock?.clockOutputs?.[0] || 0) ||
trueC2 !== (clock?.clockOutputs?.[1] || 0) ||
truePulse !== (clock?.clockTimings?.[0] || 0) ||
truePause !== (clock?.clockTimings?.[1] || 0) ||
trueAlertType !== (clock?.ringAlerts || 'disabled') ||
(trueAlertType === 'multi' && data.alert_interval !== clock?.ringIntervals) ||
trueHourBell !== (clock?.hourAlertsBell || 0) ||
trueHalfBell !== (clock?.halfhourAlertsBell || 0) ||
trueQuarterBell !== (clock?.quarterAlertsBell || 0) ||
!!data.daytime_silence !== (clock?.isDaySilenceOn ?? false) ||
data.daytime_on !== extractTime(clock?.daySilenceFrom) ||
data.daytime_off !== extractTime(clock?.daySilenceTo) ||
!!data.night_silence !== (clock?.isNightSilenceOn ?? false) ||
data.night_on !== extractTime(clock?.nightSilenceFrom) ||
data.night_off !== extractTime(clock?.nightSilenceTo) ||
!!data.backlight !== (clock?.isBacklightAutomationOn ?? false) ||
trueBacklightOutput !== (clock?.backlightOutput || 0) ||
(!!data.backlight && data.backlight_on !== extractTime(clock?.backlightTurnOnTime)) ||
(!!data.backlight && data.backlight_off !== extractTime(clock?.backlightTurnOffTime)) ||
(data.gmt_offset_sec != null && data.gmt_offset_sec !== clock?.gmtOffsetSec) ||
(data.dst_offset_sec != null && data.dst_offset_sec !== clock?.dstOffsetSec) ||
(data.ntp_server != null && data.ntp_server !== clock?.ntpServer)
if (!hasDrift) return
api.put(`/devices/${deviceId}`, {
device_attributes: {
hasClock: !!data.enabled,
clockSettings: {
...clock,
clockOutputs: [trueC1, trueC2],
clockTimings: [truePulse, truePause],
ringAlerts: trueAlertType,
ringIntervals: data.alert_interval,
hourAlertsBell: trueHourBell,
halfhourAlertsBell: trueHalfBell,
quarterAlertsBell: trueQuarterBell,
isDaySilenceOn: !!data.daytime_silence,
daySilenceFrom: data.daytime_on ? `2000-01-01T${data.daytime_on}:00Z` : (clock?.daySilenceFrom || ''),
daySilenceTo: data.daytime_off ? `2000-01-01T${data.daytime_off}:00Z` : (clock?.daySilenceTo || ''),
isNightSilenceOn: !!data.night_silence,
nightSilenceFrom: data.night_on ? `2000-01-01T${data.night_on}:00Z` : (clock?.nightSilenceFrom || ''),
nightSilenceTo: data.night_off ? `2000-01-01T${data.night_off}:00Z` : (clock?.nightSilenceTo || ''),
isBacklightAutomationOn: !!data.backlight,
backlightOutput: trueBacklightOutput,
backlightTurnOnTime: data.backlight_on ? `2000-01-01T${data.backlight_on}:00Z` : '',
backlightTurnOffTime: data.backlight_off ? `2000-01-01T${data.backlight_off}:00Z` : '',
gmtOffsetSec: data.gmt_offset_sec ?? clock?.gmtOffsetSec,
dstOffsetSec: data.dst_offset_sec ?? clock?.dstOffsetSec,
ntpServer: data.ntp_server ?? clock?.ntpServer,
},
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// ── Derived values ────────────────────────────────────────────────────────
const hasClock = attr?.hasClock ?? false
@@ -285,7 +386,7 @@ export default function ClockTab({
const pauseTime = clockTimings[1]
const alertType = clock?.ringAlerts || 'disabled'
const alertMasterOn = clock?.ringAlertsMasterOn ?? false
const alertMasterOn = alertType !== 'disabled' // "master on" = alert_type is not OFF
const ringIntervals = clock?.ringIntervals
const hourBell = clock?.hourAlertsBell || 0
const halfBell = clock?.halfhourAlertsBell || 0
@@ -298,9 +399,9 @@ export default function ClockTab({
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
const timezone = attr?.timezone || device?.timezone || null
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
const autoDst = attr?.autoDST ?? false
const timezone = formatUtcOffset(clock?.gmtOffsetSec)
const ntpServer = clock?.ntpServer || null
const dstActive = !!clock?.dstOffsetSec
const alertTypeLabel =
alertType === 'single' ? 'Single Fire' :
@@ -312,6 +413,9 @@ export default function ClockTab({
// All 4 hero fields must be set before ENABLING the master switch
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
// Sends clock.enable/clock.disable to the device first; Firestore (hasClock)
// is only updated once the device confirms SUCCESS. sendMqttCommand already
// shows the blue→green/red toast.
const handleMasterToggleClick = async () => {
if (!canEdit || togglingClock) return
@@ -326,23 +430,13 @@ export default function ClockTab({
const newValue = !hasClock
setTogglingClock(true)
try {
const updated = await api.put(`/devices/${deviceId}`, {
device_attributes: { ...(attr || {}), hasClock: newValue },
await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable')
await api.put(`/devices/${deviceId}`, {
device_attributes: { hasClock: newValue },
})
if (onDeviceUpdated) {
// If API returns the full device object, use it; otherwise patch manually
if (updated?.device_attributes !== undefined) {
onDeviceUpdated(updated)
} else {
onDeviceUpdated(prev => ({
...prev,
device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
}))
}
}
toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
} catch (err) {
toast.danger('Error', err.message || 'Failed to update clock status.')
await loadDevice?.()
} catch {
// sendMqttCommand's toast already reported the failure — nothing further to do.
} finally {
setTogglingClock(false)
}
@@ -607,28 +701,14 @@ export default function ClockTab({
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
<SettingRow
label="Auto-DST"
badge={
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<StatusBadge variant="neutral">{autoDst ? 'Enabled' : 'Disabled'}</StatusBadge>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(28, 32, 38, 0.40)',
padding: '2px var(--space-2)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
}}>
Coming Soon
</span>
</div>
}
label="DST"
badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
/>
</div>
{canEdit && (
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation.
Read-only — reported directly by the device. Editing timezone/NTP settings from the console is not yet supported.
</p>
</div>
)}
@@ -1,5 +1,691 @@
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
// Control tab — direct MQTT command execution: relay tests, clock control, system actions.
export default function ControlTab({ device, canEdit, onDeviceUpdated }) {
return null
import { useState, useEffect, useRef } from 'react'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import FormField from '@/components/ui/FormField'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import { useToast } from '@/components/ui/Toast'
import ResetStatsModal from '@/modals/bellcloud/devices/ResetStatsModal'
const OTA_CHANNELS = [
{ value: 'stable', label: 'Stable' },
{ value: 'beta', label: 'Beta' },
{ value: 'development', label: 'Development' },
]
// ─── Glass surface constants (match OverviewTab / BellsTab aesthetic) ─────────
const GLASS = 'rgba(28, 32, 38, 0.30)'
const BLUR = 'blur(12px)'
function GlassCard({ children, style = {} }) {
return (
<div style={{
background: GLASS,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
...style,
}}>
{children}
</div>
)
}
function SectionLabel({ children, icon }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
{icon && <Icon name={icon} size={15} color="var(--color-text-muted)" />}
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{children}
</span>
</div>
)
}
// ─── Action row — label/description on the left, control on the right ────────
function ActionRow({ title, description, children, last = false }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 'var(--space-4)',
padding: 'var(--space-4) 0',
borderBottom: last ? 'none' : '1px solid var(--color-border)',
flexWrap: 'wrap',
}}>
<div style={{ minWidth: 200, flex: 1 }}>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{title}
</p>
{description && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
{description}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
{children}
</div>
</div>
)
}
// ─── Stacked action row — title/description on top, control row beneath ──────
function StackedActionRow({ title, description, children, last = false }) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-3)',
padding: 'var(--space-4) 0',
borderBottom: last ? 'none' : '1px solid var(--color-border)',
}}>
<div>
<p style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
{title}
</p>
{description && (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
{description}
</p>
)}
</div>
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
{children}
</div>
</div>
)
}
// ─── ControlTab ────────────────────────────────────────────────────────────────
export default function ControlTab({ device, attr, canEdit, isOnline, sendMqttCommand, sendMqttCommandSilent, sendingCmd }) {
const { toast } = useToast()
const totalBells = attr?.totalBells || 0
const bellOptions = Array.from({ length: totalBells }, (_, i) => i)
// relay.test_output
const [testOutput, setTestOutput] = useState('')
const [testDuration, setTestDuration] = useState('100')
// relay.test_bell
const [firingBell, setFiringBell] = useState(null)
// relay.test_batch
const [batchBells, setBatchBells] = useState([])
// system destructive confirms
const [confirmRestart, setConfirmRestart] = useState(false)
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false)
const [showResetStats, setShowResetStats] = useState(false)
// firmware
const [firmwareStatus, setFirmwareStatus] = useState(null)
const [otaChannel, setOtaChannel] = useState('stable')
const [customUrl, setCustomUrl] = useState('')
const [confirmOtaUpdate, setConfirmOtaUpdate] = useState(false)
const [confirmOtaCustom, setConfirmOtaCustom] = useState(false)
const [confirmRollback, setConfirmRollback] = useState(false)
const hasSyncedFwRef = useRef(false)
const disabled = !canEdit || !isOnline
const loadFirmwareStatus = () => {
if (!sendMqttCommandSilent) return
sendMqttCommandSilent('firmware.status').then((reply) => {
const data = reply?.data || null
setFirmwareStatus(data)
if (data?.ota_channel) setOtaChannel(data.ota_channel)
}).catch(() => {
// Device offline or didn't reply — card stays empty/dash.
})
}
useEffect(() => {
if (hasSyncedFwRef.current || !sendMqttCommandSilent || !device) return
hasSyncedFwRef.current = true
loadFirmwareStatus()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
const run = async (cmd, contents) => {
await sendMqttCommand(cmd, contents)
}
const handleTestOutput = () => {
const output = parseInt(testOutput, 10)
const duration_ms = parseInt(testDuration, 10)
if (!Number.isFinite(output) || output < 0 || output > 31) {
toast.danger('Invalid Output', 'Output must be a number between 0 and 31.')
return
}
if (!Number.isFinite(duration_ms) || duration_ms < 1 || duration_ms > 5000) {
toast.danger('Invalid Duration', 'Duration must be between 1 and 5000 ms.')
return
}
run('relay.test_output', { output, duration_ms })
}
const handleTestBell = async (bell) => {
setFiringBell(bell)
try {
await run('relay.test_bell', { bell })
} finally {
setFiringBell(null)
}
}
const toggleBatchBell = (i) => {
setBatchBells(prev => prev.includes(i) ? prev.filter(b => b !== i) : [...prev, i].sort((a, b) => a - b))
}
const handleTestBatch = () => {
if (batchBells.length === 0) {
toast.danger('No Bells Selected', 'Select at least one bell channel to fire.')
return
}
run('relay.test_batch', { bells: batchBells })
}
const handleFactoryReset = () => {
setConfirmFactoryReset(false)
run('system.factory_reset', {})
}
const handleRestart = () => {
setConfirmRestart(false)
run('system.restart', {})
}
// ── Firmware handlers ─────────────────────────────────────────────────────
const handleChangeChannel = async (channel) => {
const previous = otaChannel
setOtaChannel(channel)
try {
await run('ota.set_channel', { channel })
} catch {
setOtaChannel(previous)
}
}
const handleOtaUpdate = async () => {
setConfirmOtaUpdate(false)
await run('ota.update', { channel: otaChannel })
loadFirmwareStatus()
}
const handleOtaCustom = async () => {
setConfirmOtaCustom(false)
const firmware_url = customUrl.trim()
if (!firmware_url) {
toast.danger('Missing URL', 'Enter a firmware .bin URL first.')
return
}
await run('ota.custom', { firmware_url })
loadFirmwareStatus()
}
const handleCommit = async () => {
await run('firmware.commit', {})
loadFirmwareStatus()
}
const handleRollback = async () => {
setConfirmRollback(false)
await run('firmware.rollback', {})
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── Status bar ────────────────────────────────────────────────────── */}
<GlassCard style={{ padding: 'var(--space-4) var(--space-6)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<StatusBadge variant={isOnline ? 'success' : 'danger'}>{isOnline ? 'Online' : 'Offline'}</StatusBadge>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Commands are sent live over MQTT and require the device to be connected.
</span>
</div>
{!canEdit && (
<StatusBadge variant="neutral">Read-only</StatusBadge>
)}
</GlassCard>
{!isOnline && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)',
}}>
<Icon name="warning" size={16} color="var(--color-warning)" />
<span>This device is currently offline. Commands sent below will not be delivered until it reconnects.</span>
</div>
)}
<div className="masonry-grid masonry-grid--2">
{/* ── Relay Testing ──────────────────────────────────────────────── */}
<Card title="Relay Testing" subtitle="Fire physical outputs and bell channels directly — bypasses melody playback.">
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* relay.test_output */}
<StackedActionRow
title="Test Raw Output"
description="Fire a specific relay output for a set duration (1–5000 ms)."
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)' }}>
<div style={{ width: 96 }}>
<FormField
label="Output"
name="test-output"
type="number"
value={testOutput}
onChange={e => setTestOutput(e.target.value)}
placeholder="0–31"
disabled={disabled}
inputProps={{ min: 0, max: 31 }}
/>
</div>
<div style={{ width: 110 }}>
<FormField
label="Duration (ms)"
name="test-duration"
type="number"
value={testDuration}
onChange={e => setTestDuration(e.target.value)}
placeholder="1–5000"
disabled={disabled}
inputProps={{ min: 1, max: 5000 }}
/>
</div>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || testOutput === ''}
loading={sendingCmd === 'relay.test_output'}
onClick={handleTestOutput}
>
Fire
</Button>
</StackedActionRow>
{/* relay.test_bell */}
<StackedActionRow
title="Test Bell Channel"
description="Fire a configured bell channel using its assigned output and duration."
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
{(() => {
const bells = bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i)
const widestLabel = `Ring Bell ${bells.length}`
return bells.map(i => (
<Button
key={i}
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'relay.test_bell' && firingBell === i}
onClick={() => handleTestBell(i)}
style={{ minWidth: `${widestLabel.length}ch`, justifyContent: 'center' }}
>
Ring Bell {i + 1}
</Button>
))
})()}
</div>
</StackedActionRow>
{/* relay.test_batch */}
<StackedActionRow
title="Test Batch"
description="Fire multiple bell channels simultaneously, like a single melody step."
last
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
{(bellOptions.length > 0 ? bellOptions : Array.from({ length: 16 }, (_, i) => i)).map(i => {
const active = batchBells.includes(i)
return (
<button
key={i}
type="button"
disabled={disabled}
onClick={() => toggleBatchBell(i)}
style={{
width: 32, height: 32,
borderRadius: 'var(--radius-md)',
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
backgroundColor: active ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
color: active ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: disabled ? 'default' : 'pointer',
opacity: disabled ? 0.6 : 1,
transition: 'all 0.15s ease',
}}
aria-pressed={active}
aria-label={`Bell ${i + 1}`}
>
{i + 1}
</button>
)
})}
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || batchBells.length === 0}
loading={sendingCmd === 'relay.test_batch'}
onClick={handleTestBatch}
>
Fire Selected
</Button>
</StackedActionRow>
</div>
</Card>
{/* ── Clock Control ──────────────────────────────────────────────── */}
<Card title="Clock Control" subtitle="Pause or resume clock face and strike updates without changing configuration.">
<div style={{ display: 'flex', flexDirection: 'column' }}>
<ActionRow
title="Pause Clock Updates"
description="Freezes the clock face and strike scheduling until resumed."
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'clock.pause'}
onClick={() => run('clock.pause', {})}
>
Pause
</Button>
</ActionRow>
<ActionRow
title="Resume Clock Updates"
description="Resumes normal clock face and strike scheduling."
last
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'clock.resume'}
onClick={() => run('clock.resume', {})}
>
Resume
</Button>
</ActionRow>
</div>
</Card>
{/* ── System ──────────────────────────────────────────────────────── */}
<Card
title="System"
subtitle="Device-wide controls. Factory reset and restart affect the whole unit."
icon={<Icon name="warning" size={15} />}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<ActionRow
title="Restart Device"
description="Reboots the device. Response is sent 2 seconds before reboot."
>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'system.restart'}
onClick={() => setConfirmRestart(true)}
>
Restart
</Button>
</ActionRow>
<ActionRow
title="Factory Reset"
description="Wipes all saved configuration back to factory defaults. Non-reversible."
>
<Button
variant="danger"
size="md"
disabled={disabled}
loading={sendingCmd === 'system.factory_reset'}
onClick={() => setConfirmFactoryReset(true)}
>
Factory Reset
</Button>
</ActionRow>
<ActionRow
title="Reset Device Stats"
description="Clears QA/bench test data (bell fires, playback, reboots, logs) before shipping to a customer. Does not affect configuration."
last
>
<Button
variant="danger"
size="md"
disabled={!canEdit}
onClick={() => setShowResetStats(true)}
>
Reset Stats…
</Button>
</ActionRow>
</div>
</Card>
{/* ── Firmware ────────────────────────────────────────────────────── */}
<Card
title="Firmware"
subtitle="OTA channel, update, and rollback-safety controls."
icon={<Icon name="firmware" size={15} />}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{/* Current status summary */}
<StackedActionRow
title="Current Firmware"
description={
firmwareStatus
? `v${firmwareStatus.current_version || '—'} · ${firmwareStatus.validation_state || 'UNKNOWN'}${firmwareStatus.is_testing ? ' · testing window open' : ''}`
: 'Not yet loaded — requires the device to be online.'
}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
{firmwareStatus && (
<>
<StatusBadge variant={firmwareStatus.validation_state === 'VALIDATED' ? 'success' : firmwareStatus.is_testing ? 'warning' : 'neutral'}>
{firmwareStatus.validation_state || '—'}
</StatusBadge>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Boot count: {Number.isFinite(firmwareStatus.boot_count) ? firmwareStatus.boot_count : '—'}
</span>
</>
)}
<Button variant="ghost" size="sm" disabled={!isOnline} onClick={loadFirmwareStatus}>
Refresh
</Button>
</div>
</StackedActionRow>
{/* Commit / rollback */}
<ActionRow
title="Commit Firmware"
description="Marks the current OTA slot as permanently valid. Only available during a testing window."
>
<Button
variant="secondary"
size="md"
disabled={disabled || !firmwareStatus?.can_commit}
loading={sendingCmd === 'firmware.commit'}
onClick={handleCommit}
>
Commit
</Button>
</ActionRow>
<ActionRow
title="Roll Back Firmware"
description="Reverts to the previous OTA slot and reboots immediately."
>
<Button
variant="danger"
size="md"
disabled={disabled || !firmwareStatus?.can_rollback}
loading={sendingCmd === 'firmware.rollback'}
onClick={() => setConfirmRollback(true)}
>
Rollback
</Button>
</ActionRow>
{/* OTA channel + update */}
<StackedActionRow
title="Update Now"
description="Flash the latest build from the VPS update server for the selected channel. Device reboots after flashing."
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<div style={{ width: 160 }}>
<FormField
label="Channel"
name="ota-channel"
type="select"
value={otaChannel}
onChange={e => handleChangeChannel(e.target.value)}
disabled={disabled}
>
{OTA_CHANNELS.map(c => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</FormField>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled}
loading={sendingCmd === 'ota.update'}
onClick={() => setConfirmOtaUpdate(true)}
>
Update Now
</Button>
</div>
</StackedActionRow>
{/* Custom OTA URL */}
<StackedActionRow
title="Flash From Custom URL"
description="Flash a staging or manual build from a direct HTTP .bin URL. HTTPS not supported."
last
>
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)', flexWrap: 'wrap', width: '100%' }}>
<div style={{ flex: 1, minWidth: 220 }}>
<FormField
label="Firmware URL"
name="ota-custom-url"
type="text"
value={customUrl}
onChange={e => setCustomUrl(e.target.value)}
placeholder="http://example.com/firmware.bin"
disabled={disabled}
/>
</div>
<Button
variant="secondary"
size="md"
disabled={disabled || !customUrl.trim()}
loading={sendingCmd === 'ota.custom'}
onClick={() => setConfirmOtaCustom(true)}
>
Flash
</Button>
</div>
</StackedActionRow>
</div>
</Card>
</div>
{/* ── Confirm dialogs ───────────────────────────────────────────────── */}
<ConfirmDialog
open={confirmRestart}
variant="primary"
title="Restart Device"
message="This will reboot the device immediately. Any active playback will be interrupted. Continue?"
confirmLabel="Restart"
onConfirm={handleRestart}
onCancel={() => setConfirmRestart(false)}
loading={sendingCmd === 'system.restart'}
/>
<ConfirmDialog
open={confirmFactoryReset}
variant="danger"
title="Factory Reset Device"
message="This will permanently wipe ALL saved configuration on this device — bell outputs, clock settings, network config, everything. This action cannot be undone. The device must be restarted afterwards to apply."
confirmLabel="Factory Reset"
onConfirm={handleFactoryReset}
onCancel={() => setConfirmFactoryReset(false)}
loading={sendingCmd === 'system.factory_reset'}
/>
<ResetStatsModal
open={showResetStats}
onClose={() => setShowResetStats(false)}
deviceId={device?.id}
isOnline={isOnline}
sendMqttCommand={sendMqttCommand}
/>
<ConfirmDialog
open={confirmOtaUpdate}
variant="primary"
title="Update Firmware Now"
message={`This will flash the latest "${otaChannel}" build from the update server and reboot the device once complete. Make sure playback is stopped first. Continue?`}
confirmLabel="Update Now"
onConfirm={handleOtaUpdate}
onCancel={() => setConfirmOtaUpdate(false)}
loading={sendingCmd === 'ota.update'}
/>
<ConfirmDialog
open={confirmOtaCustom}
variant="primary"
title="Flash Custom Firmware"
message={`This will flash the device from:\n${customUrl.trim()}\n\nThe device will reboot after flashing. Continue?`}
confirmLabel="Flash"
onConfirm={handleOtaCustom}
onCancel={() => setConfirmOtaCustom(false)}
loading={sendingCmd === 'ota.custom'}
/>
<ConfirmDialog
open={confirmRollback}
variant="danger"
title="Roll Back Firmware"
message="This will revert to the previous OTA slot and reboot the device immediately. Continue?"
confirmLabel="Rollback"
onConfirm={handleRollback}
onCancel={() => setConfirmRollback(false)}
loading={sendingCmd === 'firmware.rollback'}
/>
</div>
)
}
@@ -28,6 +28,9 @@ const LOG_LEVEL_META = {
const GLASS_BG = 'rgba(28, 32, 38, 0.30)'
const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
const LOG_CMD = { serial: 'log.set_serial', sd: 'log.set_sd', mqtt: 'log.set_mqtt' }
const LOG_ATTR = { serial: 'serialLogLevel', sd: 'sdLogLevel', mqtt: 'mqttLogLevel' }
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseCoordinates(coordStr) {
@@ -851,7 +854,9 @@ export default function GeneralTab({
setTags,
onEditLocation,
onEditAttributes,
onEditLogging,
sendMqttCommand,
sendMqttCommandSilent,
reloadDeviceSilent,
}) {
const { toast } = useToast()
const [locationName, setLocationName] = useState(null)
@@ -874,6 +879,63 @@ export default function GeneralTab({
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
}, [attr?.serialLogLevel, attr?.sdLogLevel, attr?.mqttLogLevel])
// ── Background refresh: pull true log levels + network info from the device ──
// Fires at most once per mount (hasSyncedRef guard). Log levels are reconciled
// against Firestore and silently corrected on drift. Network info is read-only
// and always reflects whatever the device just reported — no drift comparison
// needed since nothing else in the console ever writes it.
const hasSyncedRef = useRef(false)
const [networkInfo, setNetworkInfo] = useState(null)
const [firmwareStatus, setFirmwareStatus] = useState(null)
useEffect(() => {
if (!sendMqttCommandSilent || !device) return
if (hasSyncedRef.current) return
hasSyncedRef.current = true
const deviceId = device.id || device.device_id
sendMqttCommandSilent('log.get_config').then((reply) => {
const data = reply?.data || {}
const trueSerial = data.serial_level
const trueSd = data.sd_level
const trueMqtt = data.mqtt_level
if (trueSerial == null && trueSd == null && trueMqtt == null) return
const hasDrift =
(trueSerial != null && trueSerial !== (attr?.serialLogLevel ?? 0)) ||
(trueSd != null && trueSd !== (attr?.sdLogLevel ?? 0)) ||
(trueMqtt != null && trueMqtt !== (attr?.mqttLogLevel ?? 0))
if (!hasDrift) return
if (trueSerial != null) setSerialLevel(trueSerial)
if (trueSd != null) setSdLevel(trueSd)
if (trueMqtt != null) setMqttLevel(trueMqtt)
api.put(`/devices/${deviceId}`, {
device_attributes: {
serialLogLevel: trueSerial ?? attr?.serialLogLevel ?? 0,
sdLogLevel: trueSd ?? attr?.sdLogLevel ?? 0,
mqttLogLevel: trueMqtt ?? attr?.mqttLogLevel ?? 0,
},
}).then(() => reloadDeviceSilent?.())
}).catch(() => {
// Device offline or didn't reply — keep showing the last-known Firestore values.
})
sendMqttCommandSilent('network.info').then((reply) => {
setNetworkInfo(reply?.data || null)
}).catch(() => {
// Device offline or didn't reply — Network Info card stays empty/dash.
})
sendMqttCommandSilent('firmware.status').then((reply) => {
setFirmwareStatus(reply?.data || null)
}).catch(() => {
// Device offline or didn't reply — Firmware card stays empty/dash.
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [device?.id, device?.device_id])
// Nearest place
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
@@ -886,11 +948,6 @@ export default function GeneralTab({
.catch(() => setLocationName(null))
}, [coords])
const net = attr?.networkSettings || {}
const staticIpAddress = Array.isArray(net.ipAddress)
? net.ipAddress.filter(Boolean).join('.')
: (net.ipAddress || '')
// ── Tag handlers ────────────────────────────────────────────────────────────
const handleAddTag = useCallback(async (tag) => {
@@ -921,34 +978,43 @@ export default function GeneralTab({
}
}, [id, tags, setTags, toast])
// ── Log level save (on blur / edit) ─────────────────────────────────────────
// ── Log level save ────────────────────────────────────────────────────────
// Each slider sends its own log.set_* command to the device first; Firestore
// is only updated once the device confirms SUCCESS. sendMqttCommand already
// shows the blue→green/red toast, so a slider that fails to apply snaps back
// to its last confirmed value instead of drifting from reality.
const logSetters = useMemo(
() => ({ serial: setSerialLevel, sd: setSdLevel, mqtt: setMqttLevel }),
[]
)
const handleSaveLogging = useCallback(async (field, value) => {
if (!canEdit) return
if (!canEdit || !sendMqttCommand) return
const previous = { serial: serialLevel, sd: sdLevel, mqtt: mqttLevel }[field]
logSetters[field](value)
try {
await sendMqttCommand(LOG_CMD[field], { level: value })
await api.put(`/devices/${id}`, {
device_attributes: {
...(attr || {}),
serialLogLevel: field === 'serial' ? value : serialLevel,
sdLogLevel: field === 'sd' ? value : sdLevel,
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
}
device_attributes: { [LOG_ATTR[field]]: value },
})
toast.success('Saved', 'Log levels updated.')
} catch (err) {
toast.danger('Error', err.message || 'Failed to save log levels.')
} catch {
// sendMqttCommand's toast already reported the failure — revert the slider.
logSetters[field](previous)
}
}, [id, attr, serialLevel, sdLevel, mqttLevel, canEdit, toast])
}, [id, serialLevel, sdLevel, mqttLevel, canEdit, sendMqttCommand, logSetters])
// ── Network fields ───────────────────────────────────────────────────────────
// ── Network fields ─────────────────────────────────────────────────────────
// Populated live from network.info — read-only, reported directly by the device.
const netFields = [
{ label: 'Hostname', value: net.hostname || '—' },
{ label: 'IP Address', value: staticIpAddress || '—' },
{ label: 'Gateway', value: net.gateway || '—' },
{ label: 'DNS', value: net.dns || '—' },
{ label: 'Subnet', value: net.subnet || '—' },
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' },
{ label: 'Hostname', value: networkInfo?.hostname || '—' },
{ label: 'IP Address', value: networkInfo?.ip || '—' },
{ label: 'Gateway', value: networkInfo?.gateway || '—' },
{ label: 'DNS', value: networkInfo?.dns || '—' },
{ label: 'Subnet', value: networkInfo?.subnet || '—' },
{ label: 'MAC Address', value: networkInfo?.mac || '—' },
{ label: 'SSID', value: networkInfo?.ssid || '—' },
{ label: 'Connection', value: networkInfo?.connection_type || '—' },
]
// ── Render ──────────────────────────────────────────────────────────────────
@@ -956,6 +1022,116 @@ export default function GeneralTab({
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ════════════════════════════════════════════════════════════════════
ROW 0 — Firmware (full width, short)
════════════════════════════════════════════════════════════════════ */}
<GlassCard>
{(() => {
// The firmware reports the literal string "Unknown" (not null/absent) for
// backup_version when no second OTA slot has ever been flashed — guard
// against that sentinel so it doesn't render as "vUnknown".
const backupKnown = firmwareStatus?.backup_version && firmwareStatus.backup_version !== 'Unknown'
const items = [
{
label: 'Version',
content: (
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-2)' }}>
<span style={{ fontSize: 'var(--font-size-lg)', fontWeight: 'var(--font-weight-semibold)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>
{firmwareStatus?.current_version ? `v${firmwareStatus.current_version}` : '—'}
</span>
{firmwareStatus?.build_date && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
built {firmwareStatus.build_date}
</span>
)}
</div>
),
},
{
label: 'Channel',
content: (
<StatusBadge variant={firmwareStatus?.ota_channel === 'stable' ? 'success' : firmwareStatus?.ota_channel === 'beta' ? 'warning' : 'neutral'}>
{firmwareStatus?.ota_channel || '—'}
</StatusBadge>
),
},
{
label: 'Validation',
content: (
<StatusBadge variant={
firmwareStatus?.validation_state === 'VALIDATED' ? 'success' :
firmwareStatus?.is_testing ? 'warning' : 'neutral'
}>
{firmwareStatus?.validation_state || '—'}
</StatusBadge>
),
},
{
label: 'Boot Count',
content: (
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>
{Number.isFinite(firmwareStatus?.boot_count) ? firmwareStatus.boot_count : '—'}
</span>
),
},
{
label: 'Backup Slot',
content: (
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-primary)' }}>
{backupKnown ? `v${firmwareStatus.backup_version}` : '—'}
{backupKnown && (
<span style={{ marginLeft: 6, fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-base)', color: firmwareStatus?.backup_is_valid ? 'var(--color-success)' : 'var(--color-danger)' }}>
{firmwareStatus?.backup_is_valid ? 'valid' : 'invalid'}
</span>
)}
</span>
),
},
{
label: 'Testing Window',
content: (
<StatusBadge variant={firmwareStatus?.is_testing ? 'warning' : 'neutral'}>
{firmwareStatus?.is_testing ? 'Open' : 'Closed'}
</StatusBadge>
),
},
]
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-4)',
}}>
<div style={{
width: 36, height: 36, flexShrink: 0,
borderRadius: 'var(--radius-md)',
background: 'var(--color-primary-subtle)',
border: '1px solid rgba(192,193,255,0.20)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Icon name="firmware" size={17} color="var(--color-primary)" />
</div>
<div style={{
flex: 1,
display: 'grid',
gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))`,
gap: 'var(--space-4)',
minWidth: 0,
}}>
{items.map(({ label, content }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<SectionLabel>{label}</SectionLabel>
{content}
</div>
))}
</div>
</div>
)
})()}
</GlassCard>
{/* ════════════════════════════════════════════════════════════════════
ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
════════════════════════════════════════════════════════════════════ */}
@@ -1013,9 +1189,6 @@ export default function GeneralTab({
<GlassCard
title="Mechanisms"
fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditAttributes} />
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
@@ -1058,9 +1231,6 @@ export default function GeneralTab({
<GlassCard
title="Log Settings"
fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditLogging} />
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
<LogSlider
@@ -1091,9 +1261,6 @@ export default function GeneralTab({
<GlassCard
title="Network Info"
fullHeight
titleAction={canEdit && (
<EditActionButton onClick={onEditAttributes} />
)}
>
<div style={{
display: 'grid',
@@ -0,0 +1,960 @@
// frontend/src/pages/bellcloud/devices/tabs/HealthTab.jsx
// Health tab — stability/diagnostics data derived from firmware boot tracking
// (F-052/F-053/F-056) and MQTT heartbeat/ping telemetry: lifetime boot count,
// reset reasons, coredump crash detail, RSSI/ping/heap/temp trends, restart
// timeline. Sub-tabs: Overview, Logs (full MQTT log explorer), Settings
// (per-device alert thresholds). A single time-range control in the toolbar
// scopes every data-fetching section on the page (Overview fully; Logs'
// Unified/Device Logs/Info channels — Alerts/Heartbeats/Commands panels don't
// have range support on their endpoints yet).
import { useState, useEffect, useCallback, useMemo } from 'react'
import api from '@/lib/api'
import Card from '@/components/ui/Card'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import FormField from '@/components/ui/FormField'
import Button from '@/components/ui/Button'
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
import LineChart from '@/components/ui/LineChart'
import LogsTab from '@/pages/bellcloud/devices/tabs/LogsTab'
import { fmtDateTimeMedium, fmtRelative } from '@/lib/formatters'
import { resolveRange } from '@/lib/timeRange'
import { useToast } from '@/components/ui/Toast'
import CrashDetail from '@/components/shared/CrashDetail'
import { hasCrashDetail, crashHeadline, isHeapExhaustion } from '@/lib/crashReport'
// ─── Reset reason → badge variant ──────────────────────────────────────────
const FAULT_REASONS = new Set([
'PANIC', 'TASK_WATCHDOG', 'INTERRUPT_WATCHDOG', 'OTHER_WATCHDOG', 'BROWNOUT',
])
function reasonVariant(reason, isFault) {
if (isFault ?? FAULT_REASONS.has(reason)) return 'danger'
if (reason === 'UNKNOWN') return 'warning'
return 'success'
}
function reasonLabel(reason) {
if (!reason) return 'Unknown'
return reason.replace(/_/g, ' ')
}
const DEFAULT_OFFLINE_TIMEOUT_SEC = 90 // matches backend's online = seconds_since_heartbeat < 90
// Standard section gap used everywhere on this tab — matches the gap other
// device-detail tabs (e.g. General) and DESIGN.md's masonry-grid use, so the
// Health tab doesn't visually stand apart with its own tighter/looser rhythm.
const SECTION_GAP = 'var(--space-6)'
// RSSI color stops — fixed semantic thresholds (not user-configurable, unlike
// CPU temp): weak/fair/good signal quality. Matches SignalIndicator's own
// dBm bands so the line chart and the header signal icon never disagree.
const RSSI_COLOR_STOPS = [
{ value: -90, color: 'var(--color-danger)' },
{ value: -70, color: 'var(--color-warning)' },
{ value: -50, color: 'var(--color-success)' },
]
// ─── Equal-height card grid ─────────────────────────────────────────────────
// DESIGN.md §11: masonry-grid is for variable-height sections that should
// auto-flow into balanced columns — it does NOT equalize row heights. Stat
// tiles and same-shape chart cards must align horizontally, so this uses a
// real CSS grid instead (masonry would render them at uneven heights).
function EqualGrid({ columns, children }) {
return (
<div style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: SECTION_GAP,
alignItems: 'stretch',
}}>
{children}
</div>
)
}
// ─── Hero KPI tile — uses Card's real title/subtitle so padding matches
// every other card on the page exactly, per point #1/#8. Content below the
// title is vertically centered in the remaining body height (#2).
function KpiTile({ label, value, unit, sublabel, badge, variant }) {
const color =
variant === 'danger' ? 'var(--color-danger)' :
variant === 'warning' ? 'var(--color-warning)' :
'var(--color-text-primary)'
return (
<Card title={label}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 'var(--space-1)', minHeight: 64 }}>
{badge ?? (
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1.75rem', fontWeight: 'var(--font-weight-bold)', color, lineHeight: 1.15, letterSpacing: '-0.01em' }}>
{value}
{unit && <span style={{ fontSize: '1rem', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)', marginLeft: 4 }}>{unit}</span>}
</span>
)}
{sublabel && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{sublabel}
</span>
)}
</div>
</Card>
)
}
// ─── Secondary metric tile (more compact, same Card-title convention) ──────
function MetricTile({ label, value, unit, sublabel, children }) {
return (
<Card title={label}>
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 2, minHeight: 44 }}>
{children ?? (
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1.15rem', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{value}
{unit && <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', marginLeft: 3 }}>{unit}</span>}
</span>
)}
{sublabel && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{sublabel}
</span>
)}
</div>
</Card>
)
}
// ─── Restart timeline — full-width horizontal track, one dot per boot ─────
// Range-aware per #7: the track always spans [rangeStart, rangeEnd], with
// dots placed at their real position in that span — a device with only 1
// day of history inside a 30-day view shows its dots bunched near one edge,
// not stretched to fill the whole track.
function RestartTimeline({ events, rangeStart, rangeEnd }) {
const tMin = rangeStart
const tMax = rangeEnd
const span = Math.max(1, tMax - tMin)
return (
<div style={{ position: 'relative', padding: 'var(--space-4) var(--space-2) var(--space-2)' }}>
<div style={{
position: 'relative', height: 8, margin: '32px 4px 8px',
background: 'var(--color-bg-abyss)', border: '1px solid var(--color-border)',
borderRadius: 4,
}}>
{events.length === 0 && (
<span style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontStyle: 'italic',
whiteSpace: 'nowrap', background: 'var(--color-bg-surface)', padding: '0 var(--space-2)',
}}>
No boot events in this range
</span>
)}
{events.map((e, i) => {
const t = new Date(e.occurred_at).getTime()
const pct = Math.max(0, Math.min(100, ((t - tMin) / span) * 100))
const color = e.is_fault ? 'var(--color-danger)' : 'var(--color-success)'
return (
<div
key={e.id ?? i}
title={`Boot #${e.boot_count ?? '?'} · ${reasonLabel(e.reset_reason)} · ${fmtDateTimeMedium(e.occurred_at)}`}
style={{
position: 'absolute', top: '50%', left: `${pct}%`,
transform: 'translate(-50%, -50%)',
width: e.is_fault ? 12 : 10, height: e.is_fault ? 12 : 10,
borderRadius: '50%',
background: color,
boxShadow: `0 0 8px ${color}`,
border: '2px solid var(--color-bg-surface)',
cursor: 'default',
}}
/>
)
})}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{fmtDateTimeMedium(new Date(tMin).toISOString())}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{fmtDateTimeMedium(new Date(tMax).toISOString())}
</span>
</div>
</div>
)
}
// ─── Boot event row (expandable for crash detail) ──────────────────────────
function BootEventRow({ event, expanded, onToggle }) {
const hasCrash = hasCrashDetail(event)
const headline = hasCrash ? crashHeadline(event) : null
const heapLow = isHeapExhaustion(event.pre_crash)
return (
<div style={{ borderBottom: '1px solid var(--color-border)' }}>
<div
onClick={hasCrash ? onToggle : undefined}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-4)', flexWrap: 'wrap',
padding: 'var(--space-3) var(--space-4)',
cursor: hasCrash ? 'pointer' : 'default',
}}
>
<span style={{ width: 160, flexShrink: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
{fmtDateTimeMedium(event.occurred_at)}
</span>
<StatusBadge variant={reasonVariant(event.reset_reason, event.is_fault)} size="sm">
{reasonLabel(event.reset_reason)}
</StatusBadge>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
boot #{event.boot_count ?? '—'}
</span>
{headline && (
<span style={{
flex: 1, minWidth: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-danger)',
fontFamily: 'var(--font-family-mono)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{headline}
</span>
)}
{!headline && <span style={{ flex: 1 }} />}
{heapLow && <StatusBadge variant="warning" size="sm">Low heap</StatusBadge>}
{event.free_heap != null && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{Math.round(event.free_heap / 1024)} KB free at boot
</span>
)}
{hasCrash && (
<Icon
name="chevron_down"
size={14}
color="var(--color-text-muted)"
style={{ transform: expanded ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}
/>
)}
</div>
{expanded && hasCrash && (
<div style={{ padding: 'var(--space-2) var(--space-4) var(--space-5)' }}>
<CrashDetail event={event} />
</div>
)}
</div>
)
}
// ─── Health status icon — computed client-side from Settings thresholds ───
function statusFromThresholds({ mqttStatus, latestDiag, restartsInRange, settings }) {
const w = settings.warning || {}
const c = settings.critical || {}
const rssi = mqttStatus?.last_heartbeat?.rssi
const heapKb = mqttStatus?.last_heartbeat?.free_heap != null ? mqttStatus.last_heartbeat.free_heap / 1024 : null
const temp = latestDiag?.cpu_temp_avg
const checks = [
[c.restarts_per_week_threshold, restartsInRange],
[c.rssi_floor_dbm, rssi, true],
[c.free_heap_floor_kb, heapKb, true],
[c.cpu_temp_ceiling_c, temp],
]
const warnChecks = [
[w.restarts_per_week_threshold, restartsInRange],
[w.rssi_floor_dbm, rssi, true],
[w.free_heap_floor_kb, heapKb, true],
[w.cpu_temp_ceiling_c, temp],
]
const crossed = (threshold, value, isFloor = false) => {
if (threshold == null || value == null) return false
return isFloor ? value < threshold : value > threshold
}
if (checks.some(([t, v, floor]) => crossed(t, v, floor))) return 'critical'
if (warnChecks.some(([t, v, floor]) => crossed(t, v, floor))) return 'warning'
return 'ok'
}
function StatusIcon({ status }) {
const meta = {
ok: { name: 'check', color: 'var(--color-success)', label: 'All thresholds within range' },
warning: { name: 'warning', color: 'var(--color-warning)', label: 'A warning threshold has been crossed' },
critical: { name: 'warning', color: 'var(--color-danger)', label: 'A critical threshold has been crossed' },
}[status] || { name: 'info', color: 'var(--color-text-muted)', label: 'Status unknown' }
return (
<span title={meta.label} style={{ display: 'inline-flex', alignItems: 'center' }}>
<Icon name={meta.name} size={16} color={meta.color} />
</span>
)
}
// ─── Health overview sub-tab (stats + charts + boot history) ───────────────
function HealthOverview({ sn, mqttStatus, range, offlineTimeoutSec, healthSettings, isOnline, sendMqttCommand }) {
const [bootEvents, setBootEvents] = useState([])
const [heartbeats, setHeartbeats] = useState([])
const [pingSamples, setPingSamples] = useState([])
const [diagReports, setDiagReports] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState(null)
const [syncing, setSyncing] = useState(false)
const rangeParams = useMemo(() => {
const p = new URLSearchParams()
if (range.since) p.set('since', range.since.toISOString())
if (range.until) p.set('until', range.until.toISOString())
return p.toString()
}, [range])
const load = useCallback(async () => {
if (!sn) return
setLoading(true)
setError('')
try {
const suffix = rangeParams ? `&${rangeParams}` : ''
const [bootRes, hbRes, pingRes, diagRes] = await Promise.all([
api.get(`/mqtt/boot-events/${sn}?limit=2000${suffix}`),
api.get(`/mqtt/heartbeats/${sn}?limit=5000${suffix}`),
api.get(`/mqtt/ping-samples/${sn}?limit=5000${suffix}`),
api.get(`/mqtt/diagnostics-reports/${sn}?limit=5000${suffix}`),
])
setBootEvents(bootRes.events || [])
setHeartbeats(hbRes.heartbeats || [])
setPingSamples(pingRes.samples || [])
setDiagReports(diagRes.reports || [])
} catch (err) {
setError(err.message || 'Failed to load device health data.')
} finally {
setLoading(false)
}
}, [sn, rangeParams])
useEffect(() => { load() }, [load])
// Asks the device for its own SD boot log. The backend merges the reply
// into boot history as it arrives (fills in crash detail for boots we
// already have, adds boots the console never saw live), before the ack
// reaches us — so a reload right after the reply shows the merged result.
const syncFromDevice = async () => {
if (!sendMqttCommand) return
setSyncing(true)
try {
await sendMqttCommand('telemetry.get_boot_history')
await load()
} catch {
// the command toast already reports the failure
} finally {
setSyncing(false)
}
}
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
<Spinner size="lg" />
</div>
)
}
if (error) {
return (
<div role="alert" style={{ backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', padding: 'var(--space-4)', color: 'var(--color-danger)' }}>
{error}
</div>
)
}
const latestBoot = bootEvents[0] || null
const latestDiag = diagReports[0] || null
const restartsInRange = bootEvents.length
const rangeLabelShort = range.key === 'all' ? 'all-time' : (
range.key === 'custom' ? 'custom range' :
{ '10m': '10 min', '1h': '1 hour', '3h': '3 hours', '24h': '24 hours', '3d': '3 days', '7d': '7 days', '30d': '30 days', '3mo': '3 months', '6mo': '6 months' }[range.key] || range.key
)
// Resolved axis bounds for every chart + the restart timeline — the FULL
// requested range, not just whatever data happens to exist (#7). "all"
// range has no lower bound, so fall back to the earliest sample actually
// present (or now-24h if there's nothing at all) purely so the axis has
// something sensible to show.
const allTimestamps = [
...heartbeats.map(h => new Date(h.received_at).getTime()),
...pingSamples.map(p => new Date(p.sampled_at).getTime()),
...diagReports.map(d => new Date(d.received_at).getTime()),
...bootEvents.map(e => new Date(e.occurred_at).getTime()),
]
const rangeXMax = range.until ? range.until.getTime() : Date.now()
const rangeXMin = range.since ? range.since.getTime() : (allTimestamps.length ? Math.min(...allTimestamps) : rangeXMax - 86400000)
// ── Offline windows — shared across all four trend charts ────────────────
// Computed once from heartbeats (the highest-frequency, always-expected
// signal — every 30s from any online device) and applied identically to
// RSSI, Heap, Ping, and CPU Temp, rather than each chart inferring gaps
// from its own series. This matters because ping (every 60s) and
// diagnostics (every 5min) are naturally sparser than heartbeats, so a gap
// big enough to mean "offline" on the heartbeat timeline doesn't reliably
// show up as an anomalous gap in those slower series' own timestamps — and
// conversely, an outage that also took down the console itself (e.g. a
// power cut) still shows up as a heartbeat gap even though, from any single
// series' own perspective, it just looks like "no data for a while," which
// is indistinguishable from that series' normal sample spacing.
//
// Threshold is relative to the visible range (2% of its width) rather than
// a fixed duration — a 3-minute silence is obviously "offline" when viewing
// the last hour, but invisible noise when viewing the last 3 months. Floored
// at the device's own offline-timeout setting so a busy short-range view
// never flags normal heartbeat jitter as an outage.
const visibleSpanMs = rangeXMax - rangeXMin
const offlineWindowThresholdMs = Math.max(offlineTimeoutSec * 1000, visibleSpanMs * 0.02)
const offlineWindows = []
{
const hbTimes = heartbeats.map(h => new Date(h.received_at).getTime()).sort((a, b) => a - b)
let cursor = rangeXMin
for (const t of hbTimes) {
if (t - cursor > offlineWindowThresholdMs) offlineWindows.push({ start: cursor, end: t })
cursor = Math.max(cursor, t)
}
// Trailing edge: an outage still in progress (no "next" heartbeat yet)
// has no closing point in the array at all, so it must be checked
// against the end of the visible range explicitly.
if (rangeXMax - cursor > offlineWindowThresholdMs) offlineWindows.push({ start: cursor, end: rangeXMax })
}
const rssiSeries = [...heartbeats].reverse().map(h => ({ x: new Date(h.received_at).getTime(), y: h.rssi ?? null }))
const heapSeries = [...heartbeats].reverse().map(h => ({ x: new Date(h.received_at).getTime(), y: h.free_heap ?? null }))
const pingSeries = [...pingSamples].reverse().map(p => ({ x: new Date(p.sampled_at).getTime(), y: p.rtt_ms }))
// cpu_temp_avg is null on reports where no samples were taken yet that
// window (e.g. the very first report shortly after boot) — rendered as a
// gap by LineChart, not a false zero.
const tempSeries = [...diagReports].reverse().map(d => ({ x: new Date(d.received_at).getTime(), y: d.cpu_temp_avg ?? null }))
let stackHighWater = {}
try {
stackHighWater = latestDiag?.stack_high_water ? JSON.parse(latestDiag.stack_high_water) : {}
} catch {
stackHighWater = {}
}
const lowestStackEntry = Object.entries(stackHighWater).sort((a, b) => a[1] - b[1])[0]
const stackWarning = lowestStackEntry && lowestStackEntry[1] < 1024
// formatX must be date-aware once the range spans more than a day, or two
// axis endpoints 24h+ apart can render identical "HH:MM" labels.
const spanMs = rangeXMax - rangeXMin
const formatTime = x => spanMs > 36 * 3600000
? new Date(x).toLocaleString([], { month: 'short', day: 'numeric' })
: new Date(x).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
// CPU temperature color stops — 3 user-configurable bands from Settings
// (cool/warn/ceiling), interpolated linearly by LineChart's gradient, not
// hard-cut.
const tempCeiling = healthSettings?.warning?.cpu_temp_ceiling_c ?? 65
const tempCritical = healthSettings?.critical?.cpu_temp_ceiling_c ?? 80
const tempColorStops = [
{ value: tempCeiling - 20, color: 'var(--color-info)' },
{ value: tempCeiling, color: 'var(--color-warning)' },
{ value: tempCritical, color: 'var(--color-danger)' },
]
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: SECTION_GAP }}>
{/* ── Hero KPI row (compact) ───────────────────────────────────────── */}
<EqualGrid columns={4}>
<KpiTile
label="Lifetime Boot Count"
value={latestBoot?.boot_count ?? '—'}
/>
<KpiTile
label={`Restarts (${rangeLabelShort})`}
value={restartsInRange}
variant={restartsInRange > 5 ? 'danger' : restartsInRange > 0 ? 'warning' : undefined}
sublabel={healthSettings?.warning?.restarts_per_week_threshold != null ? `alert threshold: ${healthSettings.warning.restarts_per_week_threshold} / week` : null}
/>
<KpiTile
label="Device Uptime"
value={mqttStatus?.last_heartbeat?.uptime_ms != null
? formatUptimeShort(mqttStatus.last_heartbeat.uptime_ms)
: '—'}
sublabel={latestBoot?.boot_count != null ? `since boot #${latestBoot.boot_count}` : null}
/>
<KpiTile
label="Last Boot Reason"
sublabel={latestBoot ? fmtRelative(latestBoot.occurred_at) : null}
badge={latestBoot ? (
<StatusBadge variant={reasonVariant(latestBoot.reset_reason, latestBoot.is_fault)}>
{reasonLabel(latestBoot.reset_reason)}
</StatusBadge>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No boot data yet
</span>
)}
/>
</EqualGrid>
{/* ── Secondary metrics row (compact) ──────────────────────────────── */}
<EqualGrid columns={5}>
<MetricTile
label="CPU Temperature"
value={latestDiag?.cpu_temp_avg != null ? latestDiag.cpu_temp_avg.toFixed(1) : '—'}
unit="°C"
sublabel={latestDiag?.cpu_temp_max != null ? `peak ${latestDiag.cpu_temp_max.toFixed(1)}°C` : null}
/>
<MetricTile
label="WiFi Reconnects"
value={latestDiag?.wifi_reconnect_count ?? '—'}
sublabel={latestDiag?.wifi_last_disconnect_reason ? `last: ${latestDiag.wifi_last_disconnect_reason}` : null}
/>
<MetricTile label="Firmware / OTA">
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: '1rem', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{latestDiag?.ota_current_version ? `v${latestDiag.ota_current_version}` : '—'}
</span>
{latestDiag && (
<div style={{ marginTop: 2 }}>
<StatusBadge variant={latestDiag.ota_update_available ? 'warning' : 'success'} size="sm">
{latestDiag.ota_update_available ? 'update available' : 'up to date'}
</StatusBadge>
</div>
)}
</MetricTile>
<MetricTile
label="Signal Strength"
value={mqttStatus?.last_heartbeat?.rssi ?? '—'}
unit={mqttStatus?.last_heartbeat?.rssi != null ? 'dBm' : ''}
sublabel="most recent heartbeat"
/>
<MetricTile label="Stack High-Water">
{lowestStackEntry ? (
<>
<span style={{
fontFamily: 'var(--font-family-mono)', fontSize: '1.15rem', fontWeight: 'var(--font-weight-semibold)',
color: stackWarning ? 'var(--color-danger)' : 'var(--color-text-primary)',
}}>
{lowestStackEntry[1]}<span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)', marginLeft: 3 }}>B free</span>
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{lowestStackEntry[0]} (lowest)
</span>
</>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>—</span>
)}
</MetricTile>
</EqualGrid>
{/* ── Trend graphs ─────────────────────────────────────────────────── */}
<EqualGrid columns={2}>
<Card title="Signal Strength (RSSI)" subtitle="From 30s heartbeat — colored by signal quality">
<LineChart
data={rssiSeries}
colorStops={RSSI_COLOR_STOPS}
unit=" dBm"
formatValue={v => Math.round(v)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No heartbeat data in this range."
/>
</Card>
<Card
title="Ping Latency"
subtitle={pingSeries.length === 0 ? 'RTC-equipped devices only' : 'Round-trip time, sampled every 60s'}
>
<LineChart
data={pingSeries}
color="var(--color-primary)"
unit=" ms"
formatValue={v => Math.round(v)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No ping samples in this range — not supported on non-RTC hardware variants."
/>
</Card>
<Card title="Free Heap" subtitle="From 30s heartbeat">
<LineChart
data={heapSeries}
color="var(--color-success)"
unit=" KB"
formatValue={v => Math.round(v / 1024)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No heartbeat data in this range."
/>
</Card>
<Card title="CPU Temperature" subtitle="Min/max/avg sampled every 15s, reported every 5 min">
<LineChart
data={tempSeries}
colorStops={tempColorStops}
unit="°C"
formatValue={v => v.toFixed(1)}
formatX={formatTime}
xMin={rangeXMin}
xMax={rangeXMax}
offlineWindows={offlineWindows}
gapStyle="baseline"
emptyLabel="No diagnostics reports in this range."
/>
</Card>
</EqualGrid>
{/* ── Restart timeline — full page width ───────────────────────────── */}
<Card title="Restart Timeline" subtitle="Each dot is one boot — red = fault reset" padding={false}>
<RestartTimeline events={[...bootEvents].reverse()} rangeStart={rangeXMin} rangeEnd={rangeXMax} />
</Card>
{/* ── Full boot event history ──────────────────────────────────────── */}
<Card
title="Boot History"
subtitle="Reverse-chronological — click a fault row with crash detail to expand"
padding={false}
action={sendMqttCommand && (
<Button variant="secondary" size="sm" icon={<Icon name="refresh" size={14} />} onClick={syncFromDevice} loading={syncing} disabled={!isOnline} title={isOnline ? "Fetch the device's own boot log and merge it into this history" : 'Device is offline'}>
Sync from device
</Button>
)}
>
{bootEvents.length === 0 ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontStyle: 'italic' }}>
No boot events recorded in this range.
</div>
) : (
<div>
{bootEvents.map(event => (
<BootEventRow
key={event.id}
event={event}
expanded={expandedId === event.id}
onToggle={() => setExpandedId(prev => prev === event.id ? null : event.id)}
/>
))}
</div>
)}
</Card>
</div>
)
}
function formatUptimeShort(ms) {
const seconds = Math.floor(ms / 1000)
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
return [d && `${d}d`, h && `${h}h`, m && `${m}m`].filter(Boolean).join(' ') || '< 1m'
}
// ─── Toggle switch (matches PublicFeaturesSettings' pattern) ──────────────
function ToggleSwitch({ enabled, onChange, disabled, id }) {
return (
<button
type="button"
id={id}
role="switch"
aria-checked={enabled}
disabled={disabled}
onClick={() => onChange(!enabled)}
style={{
flexShrink: 0,
position: 'relative',
display: 'inline-flex',
width: '44px',
height: '24px',
borderRadius: 'var(--radius-full)',
border: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
padding: 0,
transition: 'background 0.2s',
background: enabled ? 'var(--color-success)' : 'var(--color-bg-island)',
boxShadow: enabled
? 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-success)'
: 'inset 0 1px 3px rgba(0,0,0,0.2), 0 0 0 1px var(--color-border-strong)',
opacity: disabled ? 0.5 : 1,
}}
>
<span
style={{
position: 'absolute',
top: '3px',
left: enabled ? '23px' : '3px',
width: '18px',
height: '18px',
borderRadius: 'var(--radius-full)',
background: 'white',
transition: 'left 0.2s',
boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
}}
aria-hidden="true"
/>
</button>
)
}
// ─── Settings sub-tab — per-device alert thresholds ────────────────────────
// Each metric gets ONE row with two number inputs — Warning and Critical —
// rather than the old single-threshold-per-metric layout, so both severity
// levels driving the header status icon are visible and editable together.
const DEFAULT_THRESHOLDS = {
warning: { restarts_per_week_threshold: 6, rssi_floor_dbm: -80, free_heap_floor_kb: 30, cpu_temp_ceiling_c: 65 },
critical: { restarts_per_week_threshold: 12, rssi_floor_dbm: -90, free_heap_floor_kb: 15, cpu_temp_ceiling_c: 80 },
}
const THRESHOLD_ROWS = [
{ key: 'restarts_per_week_threshold', label: 'Restarts per week', unit: '', hint: 'alert if restarts in the selected range exceed this' },
{ key: 'rssi_floor_dbm', label: 'Signal strength floor', unit: 'dBm', hint: 'alert if RSSI drops below' },
{ key: 'free_heap_floor_kb', label: 'Free heap floor', unit: 'KB', hint: 'alert if free heap drops below' },
{ key: 'cpu_temp_ceiling_c', label: 'CPU temperature ceiling', unit: '°C', hint: 'alert if temperature exceeds' },
]
function ThresholdRow({ row, warning, critical, onChange }) {
return (
<div style={{
display: 'grid', gridTemplateColumns: '1fr 140px 140px', gap: 'var(--space-4)', alignItems: 'center',
padding: 'var(--space-3) 0', borderBottom: '1px solid var(--color-border)',
}}>
<div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>{row.label}</p>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{row.hint}{row.unit ? ` (${row.unit})` : ''}</p>
</div>
<FormField
label="Warning"
name={`warning_${row.key}`}
type="number"
value={warning}
onChange={e => onChange('warning', row.key, e.target.value)}
/>
<FormField
label="Critical"
name={`critical_${row.key}`}
type="number"
value={critical}
onChange={e => onChange('critical', row.key, e.target.value)}
/>
</div>
)
}
function HealthSettings({ deviceId, settings, onSaved }) {
const { toast } = useToast()
const [form, setForm] = useState({
warning: { ...DEFAULT_THRESHOLDS.warning, ...settings.warning },
critical: { ...DEFAULT_THRESHOLDS.critical, ...settings.critical },
offline_timeout_sec: settings.offline_timeout_sec ?? DEFAULT_OFFLINE_TIMEOUT_SEC,
email_on_threshold: settings.email_on_threshold ?? false,
push_on_crash_boot: settings.push_on_crash_boot ?? false,
})
const [saving, setSaving] = useState(false)
useEffect(() => {
setForm({
warning: { ...DEFAULT_THRESHOLDS.warning, ...settings.warning },
critical: { ...DEFAULT_THRESHOLDS.critical, ...settings.critical },
offline_timeout_sec: settings.offline_timeout_sec ?? DEFAULT_OFFLINE_TIMEOUT_SEC,
email_on_threshold: settings.email_on_threshold ?? false,
push_on_crash_boot: settings.push_on_crash_boot ?? false,
})
}, [settings])
const setThreshold = (severity, key, value) => setForm(prev => ({
...prev, [severity]: { ...prev[severity], [key]: value },
}))
const setField = (key, value) => setForm(prev => ({ ...prev, [key]: value }))
const save = async () => {
setSaving(true)
try {
const payload = {
warning: Object.fromEntries(THRESHOLD_ROWS.map(r => [r.key, Number(form.warning[r.key])])),
critical: Object.fromEntries(THRESHOLD_ROWS.map(r => [r.key, Number(form.critical[r.key])])),
offline_timeout_sec: Number(form.offline_timeout_sec),
email_on_threshold: !!form.email_on_threshold,
push_on_crash_boot: !!form.push_on_crash_boot,
}
const res = await api.put(`/devices/${deviceId}`, { device_health_settings: payload })
onSaved(res.device_health_settings || payload)
toast.success('Saved', 'Alert thresholds updated.')
} catch (err) {
toast.danger('Error', err.message || 'Failed to save thresholds.')
} finally {
setSaving(false)
}
}
return (
<div style={{ maxWidth: 'var(--content-max-width-lg)' }}>
<Card title="Alert Thresholds" subtitle="Warning shows an orange status icon; Critical shows red. Both drive the icon next to the time-range control above.">
<div style={{
display: 'grid', gridTemplateColumns: '1fr 140px 140px', gap: 'var(--space-4)',
paddingBottom: 'var(--space-2)', borderBottom: '1px solid var(--color-border-strong)',
}}>
<span />
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-warning)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Warning</span>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-danger)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>Critical</span>
</div>
{THRESHOLD_ROWS.map(row => (
<ThresholdRow
key={row.key}
row={row}
warning={form.warning[row.key]}
critical={form.critical[row.key]}
onChange={setThreshold}
/>
))}
<div style={{ marginTop: 'var(--space-6)' }}>
<FormField
label="Offline timeout"
name="offline_timeout_sec"
type="number"
value={form.offline_timeout_sec}
onChange={e => setField('offline_timeout_sec', e.target.value)}
hint="seconds — no message of any kind for this long ⇒ device considered offline (also controls chart gap detection)"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-4)', marginTop: 'var(--space-6)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>Email me when a threshold is crossed</span>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Not yet active — no email infrastructure wired up.</p>
</div>
<ToggleSwitch id="email-on-threshold" enabled={!!form.email_on_threshold} onChange={v => setField('email_on_threshold', v)} />
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', padding: 'var(--space-3) var(--space-4)', borderRadius: 'var(--radius-md)', background: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>Push notification on crash boot</span>
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Not yet active — no push infrastructure wired up.</p>
</div>
<ToggleSwitch id="push-on-crash" enabled={!!form.push_on_crash_boot} onChange={v => setField('push_on_crash_boot', v)} />
</div>
</div>
<div style={{ marginTop: 'var(--space-6)' }}>
<Button variant="primary" loading={saving} onClick={save}>Save Thresholds</Button>
</div>
</Card>
</div>
)
}
// ─── HealthTab ──────────────────────────────────────────────────────────────
function SubTabPill({ active, onClick, children }) {
return (
<button
type="button"
onClick={onClick}
style={{
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)',
padding: 'var(--space-2) var(--space-5)',
borderRadius: 'var(--radius-full)',
border: `1px solid ${active ? 'var(--color-border-focus)' : 'var(--color-border)'}`,
background: active ? 'var(--color-primary-subtle)' : 'var(--color-bg-surface)',
color: active ? 'var(--color-text-accent)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
fontFamily: 'var(--font-family-base)',
cursor: 'pointer', whiteSpace: 'nowrap',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
>
{children}
</button>
)
}
// Reads the header status icon's inputs even while a sub-tab other than
// Overview is active, so the icon doesn't disappear/reset when you switch
// to Logs or Settings — it needs its own small, independent fetch of just
// the latest heartbeat/diagnostics/boot-count, not the full ranged dataset
// HealthOverview loads.
function useHeaderStatus(sn, healthSettings, mqttStatus) {
const [latestDiag, setLatestDiag] = useState(null)
const [restarts7d, setRestarts7d] = useState(0)
useEffect(() => {
if (!sn) return
let cancelled = false
const sevenDaysAgo = new Date(Date.now() - 7 * 86400000).toISOString()
Promise.all([
api.get(`/mqtt/diagnostics-reports/${sn}?limit=1`).catch(() => null),
api.get(`/mqtt/boot-events/${sn}?limit=200&since=${encodeURIComponent(sevenDaysAgo)}`).catch(() => null),
]).then(([diagRes, bootRes]) => {
if (cancelled) return
setLatestDiag(diagRes?.reports?.[0] || null)
setRestarts7d(bootRes?.total ?? bootRes?.events?.length ?? 0)
})
return () => { cancelled = true }
}, [sn])
return statusFromThresholds({
mqttStatus,
latestDiag,
restartsInRange: restarts7d,
settings: healthSettings,
})
}
export default function HealthTab({ device, sn, mqttStatus, isOnline, sendMqttCommand }) {
const [subTab, setSubTab] = useState('overview')
const [range, setRange] = useState(() => resolveRange('7d'))
// Local override after a successful save, so the Settings form and offline-
// timeout derivation reflect the new values immediately without waiting on
// the parent's device prop to re-fetch. Falls back to whatever's on the
// device doc otherwise — no effect-based sync needed since device updates
// flow down as a fresh prop value on every render already.
const [savedOverride, setSavedOverride] = useState(null)
const healthSettings = savedOverride || device?.device_health_settings || {}
const offlineTimeoutSec = healthSettings.offline_timeout_sec || DEFAULT_OFFLINE_TIMEOUT_SEC
const headerStatus = useHeaderStatus(sn, healthSettings, mqttStatus)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: SECTION_GAP }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap', minHeight: 38 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<SubTabPill active={subTab === 'overview'} onClick={() => setSubTab('overview')}>
<StatusIcon status={headerStatus} />
Overview
</SubTabPill>
<SubTabPill active={subTab === 'logs'} onClick={() => setSubTab('logs')}>Logs</SubTabPill>
<SubTabPill active={subTab === 'settings'} onClick={() => setSubTab('settings')}>
<Icon name="settings" size={13} />
Settings
</SubTabPill>
</div>
{/* Always rendered (Overview + Logs both use it) so the toolbar never
changes height when switching sub-tabs — only Settings hides it,
in which case an invisible spacer of the same size holds the row
height steady. */}
<div style={{ width: 220, visibility: subTab === 'settings' ? 'hidden' : 'visible' }}>
<TimeRangeSelect value={range} onChange={setRange} />
</div>
</div>
{subTab === 'overview' && (
<HealthOverview sn={sn} mqttStatus={mqttStatus} range={range} offlineTimeoutSec={offlineTimeoutSec} healthSettings={healthSettings} isOnline={isOnline} sendMqttCommand={sendMqttCommand} />
)}
{subTab === 'logs' && <LogsTab sn={sn} range={range} />}
{subTab === 'settings' && (
<HealthSettings deviceId={device?.id} settings={healthSettings} onSaved={setSavedOverride} />
)}
</div>
)
}
@@ -0,0 +1,971 @@
// frontend/src/pages/bellcloud/devices/tabs/LogsTab.jsx
// Logs tab — full-page device log explorer across every MQTT channel:
// Unified (Logs+Info+Alerts merged), Device Logs, Info, Alerts, Heartbeats, Commands.
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import api from '@/lib/api'
import Spinner from '@/components/ui/Spinner'
import StatusBadge from '@/components/ui/StatusBadge'
import Pagination from '@/components/ui/Pagination'
import { fmtDateTimeMedium, fmtRelative, fmtLogTimestamp } from '@/lib/formatters'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
// ─── Glass surface tokens (match Overview / Control tab aesthetic) ────────────
const GLASS = 'rgba(28, 32, 38, 0.30)'
const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
const BLUR = 'blur(12px)'
function GlassCard({ children, style = {} }) {
return (
<div style={{
background: GLASS,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
...style,
}}>
{children}
</div>
)
}
function SectionLabel({ children }) {
return (
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{children}
</span>
)
}
// ─── Level / source colour maps ───────────────────────────────────────────────
// INFO stays neutral/white — only WARN and ERROR get colour, so they read as
// "something's wrong" at a glance instead of every line looking alarming.
const LOG_COLORS = {
ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
}
const MESSAGE_TEXT_COLOR = {
ERROR: 'var(--color-danger)',
WARN: 'var(--color-warning)',
INFO: 'var(--color-text-primary)',
}
const ALERT_STATE_COLORS = {
CRITICAL: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
FAILED: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
WARNING: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
}
// Stepped severity: picking WARN also shows WARN + ERROR, picking INFO shows all.
const LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
{ value: 'INFO', label: 'INFO', color: 'var(--color-info)' },
{ value: 'WARN', label: 'WARN', color: 'var(--color-warning)' },
{ value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' },
]
const LEVEL_RANK = { INFO: 0, WARN: 1, ERROR: 2 }
// Channel chips — color-coded per channel, chip style matches LEVEL_STEPS.
const CHANNELS = [
{ key: 'unified', label: 'Unified', color: 'var(--color-primary)' },
{ key: 'log', label: 'Device Logs', color: 'var(--color-info)' },
{ key: 'info', label: 'Info', color: 'var(--color-text-secondary)' },
{ key: 'alerts', label: 'Alerts', color: 'var(--color-warning)' },
{ key: 'heartbeats', label: 'Heartbeats', color: 'var(--color-success)' },
{ key: 'commands', label: 'Commands', color: 'var(--color-danger)' },
]
const SOURCE_LABELS = { log: 'Device Log', info: 'Info', alert: 'Alert' }
const SOURCE_COLORS = {
log: 'var(--color-info)',
info: 'var(--color-text-secondary)',
alert: 'var(--color-warning)',
}
const PAGE_SIZE = 50
// ─── Channel chip switcher ─────────────────────────────────────────────────────
function ChannelChips({ active, onChange }) {
return (
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center', flexWrap: 'wrap' }}>
{CHANNELS.map(ch => (
<button
key={ch.key}
onClick={() => onChange(ch.key)}
style={{
height: 24,
paddingInline: 'var(--space-2)',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${active === ch.key ? ch.color : 'var(--color-border)'}`,
background: active === ch.key ? `${ch.color}22` : GLASS_INNER,
color: active === ch.key ? ch.color : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
cursor: 'pointer',
transition: 'all 0.15s',
fontFamily: 'var(--font-family-base)',
whiteSpace: 'nowrap',
}}
>
{ch.label}
</button>
))}
</div>
)
}
// ─── Toolbar ──────────────────────────────────────────────────────────────────
function LogToolbar({
channel, onChannelChange,
showLevelFilter, levelStep, setLevelStep,
searchText, setSearchText,
autoScroll, setAutoScroll,
autoRefresh, setAutoRefresh,
onRefresh,
liveCount,
}) {
return (
<>
<style>{`
.logstab-toolbar .btn {
padding-top: var(--space-3) !important;
padding-bottom: var(--space-3) !important;
line-height: var(--line-height-base) !important;
}
`}</style>
<div className="logstab-toolbar" style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-4)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: '1px solid var(--color-border)',
flexWrap: 'wrap',
}}>
{/* Channel chips — left side, same row as the rest of the toolbar */}
<ChannelChips active={channel} onChange={onChannelChange} />
{/* Live pulse indicator */}
{liveCount > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)', flexShrink: 0 }}>
<span style={{
width: 6, height: 6, borderRadius: '50%',
background: 'var(--color-success)',
boxShadow: '0 0 6px var(--color-success)',
animation: 'logstab-pulse 1.5s ease-in-out infinite',
display: 'block',
}} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)' }}>
LIVE
</span>
</div>
)}
<div style={{ flex: 1 }} />
{/* Level colour slider */}
{showLevelFilter && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexShrink: 0 }}>
<SectionLabel>Level</SectionLabel>
<div style={{ display: 'flex', gap: 'var(--space-1)', alignItems: 'center' }}>
{LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => setLevelStep(idx)}
title={step.label}
style={{
height: 24,
paddingInline: 'var(--space-2)',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
background: levelStep === idx ? `${step.color}22` : GLASS_INNER,
color: levelStep === idx ? step.color : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
cursor: 'pointer',
transition: 'all 0.15s',
fontFamily: 'var(--font-family-base)',
}}
>
{step.label}
</button>
))}
</div>
</div>
)}
{/* Search */}
<input
type="text"
value={searchText}
onChange={e => setSearchText(e.target.value)}
placeholder="Filter messages…"
style={{
height: 28,
padding: '0 var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)',
background: GLASS_INNER,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
color: 'var(--color-text-primary)',
fontSize: 'var(--font-size-xs)',
fontFamily: 'var(--font-family-base)',
width: 180,
outline: 'none',
flexShrink: 0,
}}
/>
{/* Toggle: AUTO-SCROLL */}
<button
onClick={() => setAutoScroll(p => !p)}
style={{
height: 28,
paddingInline: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
background: autoScroll ? 'var(--color-primary-subtle)' : GLASS_INNER,
color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)',
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
}}
>
AUTO-SCROLL
</button>
{/* Toggle: AUTO-SYNC */}
<button
onClick={() => setAutoRefresh(p => !p)}
style={{
height: 28,
paddingInline: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${autoRefresh ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
background: autoRefresh ? 'rgba(74,222,128,0.10)' : GLASS_INNER,
color: autoRefresh ? 'var(--color-success)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)',
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
}}
>
AUTO-SYNC
</button>
{/* Refresh — no spinner/dim-out here: a background sync must stay
seamless (see fetchRows' `background` flag). Only a genuinely
stuck request would ever need a visual cue, which isn't worth
the flicker cost for a 5s poll. */}
<button
onClick={onRefresh}
style={{
height: 28,
paddingInline: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)',
background: GLASS_INNER,
color: 'var(--color-text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)',
fontFamily: 'var(--font-family-base)',
flexShrink: 0,
}}
>
REFRESH
</button>
</div>
</>
)
}
// ─── Empty / loading table states ────────────────────────────────────────────
function TableState({ loading, empty, colSpan, emptyText }) {
if (loading) {
return (
<tr><td colSpan={colSpan} style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
<Spinner size="sm" />
</td></tr>
)
}
if (empty) {
return (
<tr><td colSpan={colSpan} style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-base)', fontStyle: 'italic' }}>
{emptyText}
</td></tr>
)
}
return null
}
// ─── Device Logs / Info panel (shared shape — text lines with level) ─────────
function TextLogPanel({ sn, source, liveTypes, channel, onChannelChange, range }) {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [initialLoading, setInitialLoading] = useState(true)
const [page, setPage] = useState(1)
const [levelStep, setLevelStep] = useState(0)
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [autoRefresh, setAutoRefresh] = useState(true)
const [liveRows, setLiveRows] = useState([])
const containerRef = useRef(null)
const hasLoadedOnce = useRef(false)
const levelFilter = LEVEL_STEPS[levelStep].value
// `background` fetches (polling / silent revalidation) never toggle a loading
// flag — only the very first load for this panel shows a spinner. This is
// what keeps auto-sync and manual refresh from flickering the toolbar/table.
const fetchRows = useCallback(async (background = false) => {
if (!sn) return
if (!background && !hasLoadedOnce.current) setInitialLoading(true)
try {
const params = new URLSearchParams({
limit: PAGE_SIZE, offset: String((page - 1) * PAGE_SIZE),
source,
})
if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
if (searchText) params.set('search', searchText)
if (range?.since) params.set('since', range.since.toISOString())
if (range?.until) params.set('until', range.until.toISOString())
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
setRows(data.logs || [])
setTotal(data.total || 0)
} catch {
// silent — table shows previous data until next successful fetch
} finally {
hasLoadedOnce.current = true
setInitialLoading(false)
}
}, [sn, source, levelFilter, searchText, page, range])
useEffect(() => { fetchRows(false) }, [fetchRows])
useEffect(() => {
if (!autoRefresh || !sn) return
const t = setInterval(() => fetchRows(true), 5000)
return () => clearInterval(t)
}, [autoRefresh, sn, fetchRows])
const handleWsMessage = useCallback((data) => {
if (liveTypes.includes(data.type) && data.device_serial === sn) {
const entry = {
id: `live-${Date.now()}-${Math.random()}`,
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
data.payload?.level?.includes('WARN') ? 'WARN' : 'INFO',
message: data.payload?.message || data.payload?.type || '',
received_at: new Date().toISOString(),
_live: true,
}
setLiveRows(prev => [entry, ...prev].slice(0, 50))
}
}, [sn, liveTypes])
useMqttWebSocket({ enabled: page === 1, onMessage: handleWsMessage })
const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
const liveFiltered = page === 1 ? liveRows.filter(l => {
if (!meetsLevelFloor(l.level)) return false
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}) : []
const allRows = [...liveFiltered, ...rows]
const liveCount = liveFiltered.length
const prevLiveCount = useRef(liveCount)
useEffect(() => {
if (liveCount > prevLiveCount.current && autoScroll && containerRef.current) {
containerRef.current.scrollTop = 0
}
prevLiveCount.current = liveCount
}, [liveCount, autoScroll])
return (
<>
<LogToolbar
channel={channel} onChannelChange={onChannelChange}
showLevelFilter
levelStep={levelStep} setLevelStep={idx => { setLevelStep(idx); setPage(1) }}
searchText={searchText} setSearchText={v => { setSearchText(v); setPage(1) }}
autoScroll={autoScroll} setAutoScroll={setAutoScroll}
autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}
onRefresh={() => fetchRows(true)}
liveCount={liveCount}
/>
<div ref={containerRef} style={{ maxHeight: 520, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Level', 'Message'].map((h, i) => (
<th key={h} style={{
padding: 'var(--space-2) var(--space-4)', textAlign: 'left',
width: i === 0 ? 160 : i === 1 ? 72 : undefined,
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase', fontFamily: 'var(--font-family-base)',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && allRows.length === 0} empty={!initialLoading && allRows.length === 0} colSpan={3} emptyText="No logs found." />
{allRows.map((log, idx) => {
const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
return (
<tr key={log._live ? log.id : (log.id || idx)} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: log._live ? 'rgba(74,222,128,0.04)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(log.received_at)}
{log._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{log.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)' }}>
{log.message}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── Unified feed (Device Logs + Info + Alerts merged, chronological) ────────
function UnifiedPanel({ sn, channel, onChannelChange, range }) {
const [logRows, setLogRows] = useState([])
const [alertRows, setAlertRows] = useState([])
const [initialLoading, setInitialLoading] = useState(true)
const [levelStep, setLevelStep] = useState(0)
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [autoRefresh, setAutoRefresh] = useState(true)
const [liveRows, setLiveRows] = useState([])
const containerRef = useRef(null)
const hasLoadedOnce = useRef(false)
const levelFilter = LEVEL_STEPS[levelStep].value
const fetchAll = useCallback(async (background = false) => {
if (!sn) return
if (!background && !hasLoadedOnce.current) setInitialLoading(true)
try {
const params = new URLSearchParams({ limit: '100', offset: '0' })
params.append('source', 'log')
params.append('source', 'info')
if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
if (searchText) params.set('search', searchText)
if (range?.since) params.set('since', range.since.toISOString())
if (range?.until) params.set('until', range.until.toISOString())
const [logsData, alertsData] = await Promise.all([
api.get(`/mqtt/logs/${sn}?${params}`),
api.get(`/mqtt/alert-events/${sn}?limit=50&offset=0`),
])
setLogRows(logsData.logs || [])
setAlertRows(alertsData.events || [])
} catch {
// silent
} finally {
hasLoadedOnce.current = true
setInitialLoading(false)
}
}, [sn, levelFilter, searchText, range])
useEffect(() => { fetchAll(false) }, [fetchAll])
useEffect(() => {
if (!autoRefresh || !sn) return
const t = setInterval(() => fetchAll(true), 5000)
return () => clearInterval(t)
}, [autoRefresh, sn, fetchAll])
const handleWsMessage = useCallback((data) => {
if (data.device_serial !== sn) return
// Retained replays (broker re-sending the last message after the backend
// reconnects) are old events, not live ones.
if (data.retained) return
if (data.type === 'system/logs' || data.type === 'system/info') {
const entry = {
_kind: 'log',
source: data.type === 'system/logs' ? 'log' : 'info',
id: `live-${Date.now()}-${Math.random()}`,
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
data.payload?.level?.includes('WARN') ? 'WARN' : 'INFO',
message: data.payload?.message || data.payload?.type || '',
received_at: new Date().toISOString(),
_live: true,
}
setLiveRows(prev => [entry, ...prev].slice(0, 50))
} else if (data.type === 'system/alerts' && data.payload?.state !== 'CLEARED') {
const entry = {
_kind: 'alert',
source: 'alert',
id: `live-${Date.now()}-${Math.random()}`,
state: data.payload?.state,
subsystem: data.payload?.subsystem,
message: data.payload?.msg || '',
occurred_at: new Date().toISOString(),
_live: true,
}
setLiveRows(prev => [entry, ...prev].slice(0, 50))
}
}, [sn])
useMqttWebSocket({ enabled: true, onMessage: handleWsMessage })
const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
const merged = useMemo(() => {
const logEntries = logRows.map(l => ({ _kind: 'log', source: l.source || 'log', ...l }))
const alertEntries = alertRows.map(a => ({ _kind: 'alert', source: 'alert', ...a, received_at: a.occurred_at }))
const combined = [...logEntries, ...alertEntries]
combined.sort((a, b) => new Date(b.received_at) - new Date(a.received_at))
return combined
}, [logRows, alertRows])
const liveFiltered = liveRows.filter(l => {
if (l._kind === 'alert') return true
if (!meetsLevelFloor(l.level)) return false
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
return true
})
const allRows = [...liveFiltered, ...merged]
const liveCount = liveFiltered.length
const prevLiveCount = useRef(liveCount)
useEffect(() => {
if (liveCount > prevLiveCount.current && autoScroll && containerRef.current) {
containerRef.current.scrollTop = 0
}
prevLiveCount.current = liveCount
}, [liveCount, autoScroll])
return (
<>
<LogToolbar
channel={channel} onChannelChange={onChannelChange}
showLevelFilter
levelStep={levelStep} setLevelStep={setLevelStep}
searchText={searchText} setSearchText={setSearchText}
autoScroll={autoScroll} setAutoScroll={setAutoScroll}
autoRefresh={autoRefresh} setAutoRefresh={setAutoRefresh}
onRefresh={() => fetchAll(true)}
liveCount={liveCount}
/>
<div ref={containerRef} style={{ maxHeight: 520, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Channel', 'Level', 'Message'].map((h, i) => (
<th key={h} style={{
padding: 'var(--space-2) var(--space-4)', textAlign: 'left',
width: i === 0 ? 160 : i === 1 ? 100 : i === 2 ? 100 : undefined,
fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase', fontFamily: 'var(--font-family-base)',
}}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && allRows.length === 0} empty={!initialLoading && allRows.length === 0} colSpan={4} emptyText="No events found." />
{allRows.map((row, idx) => {
const channelColor = SOURCE_COLORS[row.source] || SOURCE_COLORS.log
const channelCell = (
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: channelColor, letterSpacing: 'var(--tracking-wide)' }}>
{SOURCE_LABELS[row.source] || row.source}
</span>
</td>
)
if (row._kind === 'alert') {
const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
return (
<tr key={row.id || idx} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: row._live ? 'rgba(255,92,92,0.05)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(row.received_at)}
{row._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
{channelCell}
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.state}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: s.text }}>
<span style={{ color: 'var(--color-text-muted)', marginRight: 'var(--space-2)' }}>[{row.subsystem}]</span>
{row.message || '—'}
</td>
</tr>
)
}
const s = LOG_COLORS[row.level] || LOG_COLORS.INFO
return (
<tr key={row.id || idx} style={{
borderBottom: idx < allRows.length - 1 ? '1px solid var(--color-border)' : 'none',
background: row._live ? 'rgba(74,222,128,0.04)' : 'transparent',
}}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{fmtLogTimestamp(row.received_at)}
{row._live && <span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>}
</td>
{channelCell}
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[row.level] || 'var(--color-text-primary)' }}>
{row.message}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</>
)
}
// ─── Alerts history panel ─────────────────────────────────────────────────────
function AlertsPanel({ sn, channel, onChannelChange }) {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [initialLoading, setInitialLoading] = useState(true)
const [page, setPage] = useState(1)
const hasLoadedOnce = useRef(false)
const fetchRows = useCallback(async (background = false) => {
if (!sn) return
if (!background && !hasLoadedOnce.current) setInitialLoading(true)
try {
const data = await api.get(`/mqtt/alert-events/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
setRows(data.events || [])
setTotal(data.total || 0)
} catch {
// silent
} finally {
hasLoadedOnce.current = true
setInitialLoading(false)
}
}, [sn, page])
useEffect(() => { fetchRows(false) }, [fetchRows])
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => fetchRows(true)} style={{
height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
}}>
REFRESH
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'State', 'Subsystem', 'Message'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={4} emptyText="No alert history for this device." />
{rows.map((row, idx) => {
const s = ALERT_STATE_COLORS[row.state] || ALERT_STATE_COLORS.WARNING
return (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.occurred_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<span style={{ display: 'inline-block', padding: '1px var(--space-2)', borderRadius: 'var(--radius-sm)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text, letterSpacing: 'var(--tracking-wide)' }}>
{row.state}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)' }}>{row.subsystem}</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: s.text }}>{row.message || '—'}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── Heartbeats panel ─────────────────────────────────────────────────────────
function HeartbeatsPanel({ sn, channel, onChannelChange }) {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [initialLoading, setInitialLoading] = useState(true)
const [page, setPage] = useState(1)
const hasLoadedOnce = useRef(false)
const fetchRows = useCallback(async (background = false) => {
if (!sn) return
if (!background && !hasLoadedOnce.current) setInitialLoading(true)
try {
const data = await api.get(`/mqtt/heartbeats/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
setRows(data.heartbeats || [])
setTotal(data.total || 0)
} catch {
// silent
} finally {
hasLoadedOnce.current = true
setInitialLoading(false)
}
}, [sn, page])
useEffect(() => { fetchRows(false) }, [fetchRows])
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => fetchRows(true)} style={{
height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
}}>
REFRESH
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Time', 'Uptime', 'Firmware', 'IP Address', 'RSSI'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={5} emptyText="No heartbeats recorded for this device." />
{rows.map((row, idx) => (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.received_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{row.uptime_display || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.firmware_version || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.ip_address || '—'}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{row.rssi != null ? `${row.rssi} dBm` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── Commands panel ───────────────────────────────────────────────────────────
const COMMAND_STATUS_VARIANT = {
pending: 'warning',
success: 'success',
error: 'danger',
timeout: 'danger',
}
function CommandsPanel({ sn, channel, onChannelChange }) {
const [rows, setRows] = useState([])
const [total, setTotal] = useState(0)
const [initialLoading, setInitialLoading] = useState(true)
const [page, setPage] = useState(1)
const hasLoadedOnce = useRef(false)
const fetchRows = useCallback(async (background = false) => {
if (!sn) return
if (!background && !hasLoadedOnce.current) setInitialLoading(true)
try {
const data = await api.get(`/mqtt/commands/${sn}?limit=${PAGE_SIZE}&offset=${(page - 1) * PAGE_SIZE}`)
setRows(data.commands || [])
setTotal(data.total || 0)
} catch {
// silent
} finally {
hasLoadedOnce.current = true
setInitialLoading(false)
}
}, [sn, page])
useEffect(() => { fetchRows(false) }, [fetchRows])
return (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', padding: 'var(--space-3) var(--space-5)', borderBottom: '1px solid var(--color-border)', flexWrap: 'wrap' }}>
<ChannelChips active={channel} onChange={onChannelChange} />
<div style={{ flex: 1 }} />
<button onClick={() => fetchRows(true)} style={{
height: 28, paddingInline: 'var(--space-3)', borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)', background: GLASS_INNER,
color: 'var(--color-text-secondary)', fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)', fontFamily: 'var(--font-family-base)',
}}>
REFRESH
</button>
</div>
<div style={{ maxHeight: 520, overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 'var(--font-size-sm)' }}>
<thead>
<tr style={{ position: 'sticky', top: 0, background: GLASS_INNER, backdropFilter: BLUR, WebkitBackdropFilter: BLUR, borderBottom: '1px solid var(--color-border)', zIndex: 1 }}>
{['Sent', 'Command', 'Status', 'Responded'].map(h => (
<th key={h} style={{ padding: 'var(--space-2) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
<TableState loading={initialLoading && rows.length === 0} empty={!initialLoading && rows.length === 0} colSpan={4} emptyText="No commands sent to this device yet." />
{rows.map((row, idx) => (
<tr key={row.id || idx} style={{ borderBottom: idx < rows.length - 1 ? '1px solid var(--color-border)' : 'none' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{fmtDateTimeMedium(row.sent_at)}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{row.command_name}
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)' }}>
<StatusBadge variant={COMMAND_STATUS_VARIANT[row.status] || 'neutral'}>{row.status}</StatusBadge>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)', whiteSpace: 'nowrap' }}>
{row.responded_at ? fmtDateTimeMedium(row.responded_at) : fmtRelative(row.sent_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{total > PAGE_SIZE && (
<div style={{ padding: 'var(--space-4) var(--space-5)', borderTop: '1px solid var(--color-border)' }}>
<Pagination page={page} pageSize={PAGE_SIZE} total={total} onPageChange={setPage} />
</div>
)}
</>
)
}
// ─── LogsTab ──────────────────────────────────────────────────────────────────
export default function LogsTab({ sn, range }) {
const [channel, setChannel] = useState('unified')
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
<GlassCard style={{ overflow: 'hidden' }}>
{!sn ? (
<div style={{ padding: 'var(--space-8)', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)', fontStyle: 'italic' }}>
No serial number available for this device.
</div>
) : channel === 'unified' ? (
<UnifiedPanel sn={sn} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'log' ? (
<TextLogPanel sn={sn} source="log" liveTypes={['system/logs']} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'info' ? (
<TextLogPanel sn={sn} source="info" liveTypes={['system/info']} channel={channel} onChannelChange={setChannel} range={range} />
) : channel === 'alerts' ? (
<AlertsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
) : channel === 'heartbeats' ? (
<HeartbeatsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
) : (
<CommandsPanel sn={sn} channel={channel} onChannelChange={setChannel} />
)}
</GlassCard>
<style>{`
@keyframes logstab-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
`}</style>
</div>
)
}
@@ -632,9 +632,9 @@ export default function ManageTab({ device, canEdit, deviceUsers: propUsers, use
defaultType="issue"
prefilledLinks={issueModal.entry
? undefined
: [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]
: [{ entity_type: 'device', entity_id: id, display_name: device?.console_name || device?.device_name || sn, locked: true }]
}
knownEntities={id ? { [id]: device?.device_name || sn } : undefined}
knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined}
onClose={() => setIssueModal({ open: false, entry: null })}
onSaved={handleIssueSaved}
onDelete={canEdit ? (entryId) => {
@@ -10,7 +10,8 @@ import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
import Modal from '@/components/ui/Modal'
import { fmtDateMedium, fmtDateTimeMedium, fmtRelative, fmtLogTimestamp } from '@/lib/formatters'
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
import {
parseFirestoreDate,
@@ -19,6 +20,7 @@ import {
} from './shared'
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
import CopyableSerial from '@/components/shared/CopyableSerial'
// ─── Glass surface tokens ─────────────────────────────────────────────────────
@@ -52,6 +54,23 @@ function subscrDurationLabel(days) {
return `${days}d`
}
// Latest Device Issue color: severity sets the base hue, age controls intensity.
// today = full opacity, fading out toward ~1 week, capped at a dim floor beyond that.
const ALERT_SEVERITY_HUE = {
CRITICAL: 'var(--color-danger)',
FAILED: 'var(--color-danger)',
WARNING: 'var(--color-warning)',
}
function alertEventStyle(occurredAtIso) {
const hue = ALERT_SEVERITY_HUE[occurredAtIso.state] || 'var(--color-warning)'
const ageMs = Date.now() - new Date(occurredAtIso.occurred_at).getTime()
const ageDays = ageMs / 86400000
// 1.0 opacity at "today", fading linearly to a 0.35 floor by 7 days out.
const opacity = Math.max(0.35, 1 - (ageDays / 7) * 0.65)
return { color: hue, opacity }
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function GlassCard({ children, style = {}, className = '' }) {
@@ -146,6 +165,14 @@ const LOG_COLORS = {
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
}
// Message text colour — INFO stays neutral/white, only WARN/ERROR get coloured
// so problem lines are the ones that visually stand out.
const MESSAGE_TEXT_COLOR = {
ERROR: 'var(--color-danger)',
WARN: 'var(--color-warning)',
INFO: 'var(--color-text-primary)',
}
// Level slider steps: 0=All, 1=INFO, 2=WARN, 3=ERROR
const LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
@@ -265,6 +292,24 @@ export default function OverviewTab({
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
const [viewNote, setViewNote] = useState(null)
// ── Latest device issue detail modal ─────────────────────────────────────
const [alertModalOpen, setAlertModalOpen] = useState(false)
const [alertLogs, setAlertLogs] = useState([])
const [alertLogsLoading, setAlertLogsLoading] = useState(false)
const openAlertModal = useCallback(() => {
setAlertModalOpen(true)
if (!sn) return
setAlertLogsLoading(true)
api.get(`/mqtt/logs/${sn}?limit=20&offset=0`)
.then(data => {
const rows = (data.logs || []).filter(l => l.level === 'ERROR' || l.level === 'WARN')
setAlertLogs(rows)
})
.catch(() => setAlertLogs([]))
.finally(() => setAlertLogsLoading(false))
}, [sn])
const handleNoteSaved = () => {
setNoteModal({ open: false })
loadNotes()
@@ -277,43 +322,52 @@ export default function OverviewTab({
// ── Live logs ─────────────────────────────────────────────────────────────
const [logs, setLogs] = useState([])
const [logsLoading, setLogsLoading] = useState(false)
const [logsInitialLoading, setLogsInitialLoading] = useState(true)
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [autoRefresh, setAutoRefresh] = useState(false)
const [autoRefresh, setAutoRefresh] = useState(true)
const [liveLogs, setLiveLogs] = useState([])
const logsContainerRef = useRef(null)
const logsHasLoadedOnce = useRef(false)
const LIMIT = 50
const levelFilter = LEVEL_STEPS[levelStep].value
// Stepped severity order: a level also matches everything more severe than it
// (ALL < INFO < WARN < ERROR), so picking WARN also shows WARN and ERROR.
const LEVEL_RANK = { INFO: 0, WARN: 1, ERROR: 2 }
const meetsLevelFloor = lvl => !levelFilter || (LEVEL_RANK[lvl] ?? 0) >= LEVEL_RANK[levelFilter]
const fetchLogs = useCallback(async () => {
// `background` fetches (polling / manual refresh) never toggle a loading
// flag — only the very first load shows a spinner, so auto-sync and the
// REFRESH button stay seamless instead of flickering every few seconds.
const fetchLogs = useCallback(async (background = false) => {
if (!sn) return
setLogsLoading(true)
if (!background && !logsHasLoadedOnce.current) setLogsInitialLoading(true)
try {
const params = new URLSearchParams({ limit: LIMIT, offset: 0 })
if (levelFilter) params.set('level', levelFilter)
if (levelFilter) { params.set('level', levelFilter); params.set('min_level', 'true') }
if (searchText) params.set('search', searchText)
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
setLogs(data.logs || [])
} catch {
// silent
} finally {
setLogsLoading(false)
logsHasLoadedOnce.current = true
setLogsInitialLoading(false)
}
}, [sn, levelFilter, searchText])
useEffect(() => { if (sn) fetchLogs() }, [sn, fetchLogs])
useEffect(() => { if (sn) fetchLogs(false) }, [sn, fetchLogs])
useEffect(() => {
if (!autoRefresh || !sn) return
const t = setInterval(fetchLogs, 5000)
const t = setInterval(() => fetchLogs(true), 5000)
return () => clearInterval(t)
}, [autoRefresh, sn, fetchLogs])
const handleWsMessage = useCallback((data) => {
if (data.type === 'logs' && data.device_serial === sn) {
if (data.type === 'system/logs' && data.device_serial === sn) {
const entry = {
id: Date.now(),
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
@@ -330,18 +384,24 @@ export default function OverviewTab({
const allLogs = [
...liveLogs.filter(l => {
if (levelFilter && l.level !== levelFilter) return false
if (!meetsLevelFloor(l.level)) return false
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}),
...logs,
]
// Only auto-scroll to top when a genuinely NEW live log arrives — not when
// the level filter / search changes the visible set (that should update
// the list in place, without yanking the scroll position).
const liveLogCount = liveLogs.length
const prevLiveLogCount = useRef(liveLogCount)
useEffect(() => {
if (autoScroll && logsContainerRef.current) {
if (liveLogCount > prevLiveLogCount.current && autoScroll && logsContainerRef.current) {
logsContainerRef.current.scrollTop = 0
}
}, [allLogs.length, autoScroll])
prevLiveLogCount.current = liveLogCount
}, [liveLogCount, autoScroll])
// ── Service lifecycle ─────────────────────────────────────────────────────
const subscrStart = parseFirestoreDate(sub?.subscrStart)
@@ -429,61 +489,90 @@ export default function OverviewTab({
</div>
{/* ── Section B: Hardware info ── */}
{/* ── Section B+D: Device info grid (Serial/HW/Uptime/Location/Rev/Issue) ── */}
<div style={{
flex: 1,
flex: 2,
padding: 'var(--space-6)',
marginLeft: 'var(--space-4)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 'var(--space-4)',
alignItems: 'center',
minWidth: 0,
}} className="overview-hero-grid-wrap">
<div className="overview-hero-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gridAutoRows: 'auto',
columnGap: 'var(--space-8)',
rowGap: 'var(--space-5)',
width: '100%',
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
<InfoField label="Serial Number">
<CopyableSerial
serial={sn}
textStyle={{ fontFamily: 'var(--font-family-mono)' }}
/>
</InfoField>
<InfoField label="Hardware Family">{hwVariant}</InfoField>
<InfoField label="Location">{device.device_location || '—'}</InfoField>
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
</div>
</div>
{/* ── Section D: Uptime + Latest Issue ── */}
<div style={{
width: 220,
flexShrink: 0,
padding: 'var(--space-6)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 'var(--space-5)',
minWidth: 0,
}}>
<InfoField label="Device Uptime">
{mqttStatus?.uptime_seconds != null
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(mqttStatus.uptime_seconds)}</span>
{mqttStatus?.last_heartbeat?.uptime_ms != null
? <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)' }}>{formatUptime(Math.floor(mqttStatus.last_heartbeat.uptime_ms / 1000))}</span>
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
</InfoField>
<InfoField label="Firmware Version" mono>{mqttStatus?.last_heartbeat?.firmware_version || '—'}</InfoField>
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-more-muted)', letterSpacing: 'var(--tracking-wide)', textTransform: 'uppercase' }}>Latest Device Issue</span>
{mqttStatus?.last_warn_message ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-warning)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-more-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
MQTT · WARN · Health Monitor
Latest Device Issue
</span>
{mqttStatus?.last_alert_event ? (
<div
onClick={openAlertModal}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openAlertModal() } }}
style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
cursor: 'pointer',
borderRadius: 'var(--radius-sm)',
marginInline: -4,
paddingInline: 4,
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = GLASS_INNER}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
...alertEventStyle(mqttStatus.last_alert_event),
}}>
{mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem} · {fmtRelative(mqttStatus.last_alert_event.occurred_at)}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-secondary)',
lineHeight: 1.5,
lineHeight: 1.4,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 1,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
}}>
{mqttStatus.last_warn_message}
{mqttStatus.last_alert_event.message || 'No details provided.'}
</span>
</div>
) : (
@@ -493,6 +582,7 @@ export default function OverviewTab({
)}
</div>
</div>
</div>
{/* ── Section C: Subscription + Warranty ── */}
@@ -930,7 +1020,7 @@ export default function OverviewTab({
{LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => { setLevelStep(idx); setLogs([]) }}
onClick={() => setLevelStep(idx)}
title={step.label}
style={{
height: 24,
@@ -1020,10 +1110,10 @@ export default function OverviewTab({
AUTO-SYNC
</button>
{/* Refresh — same visual style as the toggle buttons */}
{/* Refresh — same visual style as the toggle buttons. No spinner/dim-out:
a background sync must stay seamless, not flicker every few seconds. */}
<button
onClick={fetchLogs}
disabled={logsLoading}
onClick={() => fetchLogs(true)}
style={{
height: 28,
paddingInline: 'var(--space-3)',
@@ -1033,24 +1123,19 @@ export default function OverviewTab({
color: 'var(--color-text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: logsLoading ? 'not-allowed' : 'pointer',
cursor: 'pointer',
letterSpacing: 'var(--tracking-wide)',
fontFamily: 'var(--font-family-base)',
opacity: logsLoading ? 0.5 : 1,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 'var(--space-1)',
}}
>
{logsLoading ? <Spinner size="sm" /> : null}
REFRESH
</button>
</div>
{/* Log rows */}
<div ref={logsContainerRef} style={{ maxHeight: 320, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
{logsLoading && allLogs.length === 0 ? (
{logsInitialLoading && allLogs.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
@@ -1095,7 +1180,7 @@ export default function OverviewTab({
}}
>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
{log.received_at?.replace('T', ' ').substring(0, 19)}
{fmtLogTimestamp(log.received_at)}
{log._live && (
<span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
)}
@@ -1114,7 +1199,7 @@ export default function OverviewTab({
{log.level}
</span>
</td>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: 'var(--color-text-primary)' }}>
<td style={{ padding: 'var(--space-2) var(--space-4)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)' }}>
{log.message}
</td>
</tr>
@@ -1134,6 +1219,12 @@ export default function OverviewTab({
@media (max-width: 900px) {
.overview-row2 { grid-template-columns: 1fr !important; }
}
@media (max-width: 1100px) {
.overview-hero-grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
}
@media (max-width: 640px) {
.overview-hero-grid { grid-template-columns: 1fr !important; }
}
@keyframes ov-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
@@ -1160,8 +1251,8 @@ export default function OverviewTab({
open={issueModal.open}
entry={issueModal.entry}
defaultType="issue"
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]}
knownEntities={id ? { [id]: device?.device_name || sn } : undefined}
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.console_name || device?.device_name || sn, locked: true }]}
knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined}
onClose={() => setIssueModal({ open: false, entry: null })}
onSaved={handleIssueSaved}
/>
@@ -1224,6 +1315,92 @@ export default function OverviewTab({
</div>
</div>
)}
{/* ── Latest Device Issue detail modal ──────────────────────────────── */}
<Modal
open={alertModalOpen}
onClose={() => setAlertModalOpen(false)}
title="Latest Device Issue"
size="md"
>
{mqttStatus?.last_alert_event && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
...alertEventStyle(mqttStatus.last_alert_event),
opacity: 1,
}}>
{mqttStatus.last_alert_event.state} · {mqttStatus.last_alert_event.subsystem}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{fmtDateTimeMedium(mqttStatus.last_alert_event.occurred_at)}
</span>
</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.7, whiteSpace: 'pre-wrap' }}>
{mqttStatus.last_alert_event.message || 'No details provided.'}
</p>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', borderTop: '1px solid var(--color-border)', paddingTop: 'var(--space-4)' }}>
<SectionLabel>Recent Error &amp; Warning Logs</SectionLabel>
{alertLogsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
<Spinner size="sm" />
</div>
) : alertLogs.length === 0 ? (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No recent error or warning logs.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)', maxHeight: 280, overflowY: 'auto' }}>
{alertLogs.map((log, i) => {
const s = LOG_COLORS[log.level] || LOG_COLORS.INFO
return (
<div
key={log.id || i}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 'var(--space-3)',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
}}
>
<span style={{
display: 'inline-block',
padding: '1px var(--space-2)',
borderRadius: 'var(--radius-sm)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
background: s.bg,
color: s.text,
letterSpacing: 'var(--tracking-wide)',
flexShrink: 0,
}}>
{log.level}
</span>
<span style={{ flex: 1, fontSize: 'var(--font-size-sm)', color: MESSAGE_TEXT_COLOR[log.level] || 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{log.message}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
{fmtLogTimestamp(log.received_at)}
</span>
</div>
)
})}
</div>
)}
</div>
</div>
)}
</Modal>
</div>
)
}
@@ -820,7 +820,7 @@ export default function UserDetail() {
<option value="">Choose a device…</option>
{availableDevices.map((d) => (
<option key={d.id} value={d.id}>
{d.device_name || 'Unnamed'} ({d.device_id || d.id})
{d.console_name || d.device_name || 'Unnamed'} ({d.device_id || d.id})
</option>
))}
</Select>
@@ -848,7 +848,7 @@ export default function UserDetail() {
onClick={() => navigate(`/devices/${device.id}`)}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-accent)', textAlign: 'left', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '160px' }}
>
{device.device_name || 'Unnamed Device'}
{device.console_name || device.device_name || 'Unnamed Device'}
</button>
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
{device.device_id || device.id}
@@ -71,7 +71,7 @@ function StatusToggle({ value, onChange }) {
function PhotoField({ value, onChange, userId }) {
const fileInputRef = useRef(null)
const toast = useToast()
const { toast } = useToast()
const [uploading, setUploading] = useState(false)
const [hovered, setHovered] = useState(false)
@@ -192,7 +192,7 @@ function EyeToggle({ show, onToggle }) {
}
function PasswordCard({ userId, hasUid }) {
const toast = useToast()
const { toast } = useToast()
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [showPass, setShowPass] = useState(false)
@@ -304,7 +304,7 @@ function PasswordCard({ userId, hasUid }) {
export default function UserForm() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { toast } = useToast()
const isEdit = Boolean(id)
const [form, setForm] = useState({
@@ -317,7 +317,9 @@ export default function UserForm() {
userTitle: '',
settingsPIN: '',
quickSettingsPIN: '',
password: '',
})
const [confirmPassword, setConfirmPassword] = useState('')
const [uid, setUid] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
@@ -355,6 +357,10 @@ export default function UserForm() {
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!isEdit && form.password !== confirmPassword) {
setError('Passwords do not match.')
return
}
setSaving(true)
try {
if (isEdit) {
@@ -435,6 +441,30 @@ export default function UserForm() {
placeholder="+1 234 567 8900"
/>
</div>
{!isEdit && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
<FormField
label="Password"
name="password"
type="password"
value={form.password}
onChange={set('password')}
required
placeholder="Min. 6 characters"
hint="Used to sign into the mobile app immediately — this creates the Firebase Auth account."
/>
<FormField
label="Confirm Password"
name="confirm_password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
placeholder="Re-enter password"
error={confirmPassword && form.password !== confirmPassword ? 'Passwords do not match.' : undefined}
/>
</div>
)}
<StatusToggle value={form.status} onChange={setVal('status')} />
</div>
</Card>
@@ -405,7 +405,7 @@ function LinkedDevicesModal({ userId, userName, open, onClose }) {
<Icon name="device" size={14} color="var(--color-primary)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{d.device_name || d.device_id || d.id}
{d.console_name || d.device_name || d.device_id || d.id}
</div>
{d.device_id && (
<div style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)' }}>
+281
View File
@@ -6,6 +6,12 @@ import { useState } from 'react'
import Button from '@/components/ui/Button'
import RowActions from '@/components/ui/RowActions'
import StatusBadge from '@/components/ui/StatusBadge'
import SignalIndicator from '@/components/ui/SignalIndicator'
import EditableText from '@/components/ui/EditableText'
import LineChart from '@/components/ui/LineChart'
import PressHoldButton from '@/components/ui/PressHoldButton'
import TimeRangeSelect from '@/components/ui/TimeRangeSelect'
import { resolveRange } from '@/lib/timeRange'
import FormField from '@/components/ui/FormField'
import Modal from '@/components/ui/Modal'
import DataTable from '@/components/ui/DataTable'
@@ -18,6 +24,7 @@ import Tabs from '@/components/ui/Tabs'
import { ToastProvider, useToast } from '@/components/ui/Toast'
import SearchBar from '@/components/ui/SearchBar'
import Select from '@/components/ui/Select'
import MultiSelect from '@/components/ui/MultiSelect'
import HeaderSearch from '@/components/ui/HeaderSearch'
import Breadcrumbs from '@/components/ui/Breadcrumbs'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
@@ -42,6 +49,35 @@ function urlMapToList(map) {
}).sort((a, b) => a.name.localeCompare(b.name))
}
// LineChart demo series — computed once at module init (not per-render) so
// the chart doesn't violate the "no impure calls during render" rule.
const _rssiBase = Date.now()
const RSSI_DEMO_DATA = Array.from({ length: 40 }, (_, i) => ({
x: _rssiBase - (40 - i) * 30000,
y: -55 + Math.sin(i / 4) * 12 + (Math.sin(i * 7.3) * 2),
}))
const _pingBase = Date.now()
const PING_DEMO_DATA = Array.from({ length: 30 }, (_, i) => ({
x: _pingBase - (30 - i) * 60000,
y: (i > 10 && i < 16) ? null : 40 + Math.sin(i * 3.1) * 30 + 30,
}))
// Irregular spacing: a tight cluster of points, then a big time jump, then
// another cluster — regression case for the Catmull-Rom overshoot bug (a
// naive uniform-spacing spline loops the curve backward at the jump).
const _irregularBase = Date.now()
const IRREGULAR_DEMO_DATA = [
...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - 3600000 - i * 15000, y: 45 + Math.sin(i) * 8 })),
...Array.from({ length: 8 }, (_, i) => ({ x: _irregularBase - i * 15000, y: 50 + Math.cos(i) * 10 })),
].sort((a, b) => a.x - b.x)
// Sparse data in a wide requested range: only 6 points span 90 minutes, but
// xMin/xMax below requests a full 24h axis — the chart should show the data
// bunched near one edge with a mostly-empty axis, not auto-zoom to fit it.
const _sparseNow = Date.now()
const SPARSE_DEMO_DATA = Array.from({ length: 6 }, (_, i) => ({
x: _sparseNow - (6 - i) * 900000,
y: 20 + i * 3,
}))
const ASSET_ICON_GROUPS = [
{ title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
{ title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
@@ -404,7 +440,11 @@ export default function StyleGuide() {
const [modalSize, setModalSize] = useState('md')
const [paginationPage, setPaginationPage] = useState(3)
const [paginationSize, setPaginationSize] = useState(20)
const [demoRange, setDemoRange] = useState(() => resolveRange('7d'))
const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' })
const [multiSelectDemo, setMultiSelectDemo] = useState(['vesper_plus', 'chronos'])
const [editableDemoValue, setEditableDemoValue] = useState('Bell Tower — North Wing')
const [editableDemoEmpty, setEditableDemoEmpty] = useState('')
// DataTable interactive demo state
const [tableSortKey, setTableSortKey] = useState('')
@@ -499,7 +539,13 @@ export default function StyleGuide() {
['#spacing', 'Spacing'],
['#buttons', 'Button'],
['#badges', 'StatusBadge'],
['#signal-indicator', 'SignalIndicator'],
['#editable-text', 'EditableText'],
['#line-chart', 'LineChart'],
['#press-hold-button', 'PressHoldButton'],
['#time-range-select', 'TimeRangeSelect'],
['#formfields', 'FormField'],
['#multiselect', 'MultiSelect'],
['#spinner', 'Spinner'],
['#datatable', 'DataTable'],
['#rowactions', 'RowActions'],
@@ -1008,6 +1054,202 @@ export default function StyleGuide() {
</Section>
{/* ================================================================
5a. SIGNAL INDICATOR
================================================================ */}
<Section title="SignalIndicator" id="signal-indicator">
<Subsection title="RSSI thresholds — size md (default)">
<DemoRow wrap>
{[-55, -65, -75, -85, -95, null].map((rssi) => (
<div key={String(rssi)} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 'var(--space-1)' }}>
<SignalIndicator rssi={rssi} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{rssi == null ? 'unknown' : `${rssi} dBm`}
</span>
</div>
))}
</DemoRow>
</Subsection>
<Subsection title='size="sm" — inline with device name'>
<DemoRow>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}>
<SignalIndicator rssi={-58} size="sm" />
Bell Tower — North Wing
</span>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-1b. EDITABLE TEXT
================================================================ */}
<Section title="EditableText" id="editable-text">
<Subsection title="Hover to reveal pencil, click to edit — Enter/blur saves, Escape cancels">
<DemoRow>
<EditableText
value={editableDemoValue}
onSave={setEditableDemoValue}
inputAriaLabel="Edit demo name"
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}
/>
</DemoRow>
</Subsection>
<Subsection title="Empty value — falls back to placeholder">
<DemoRow>
<EditableText
value={editableDemoEmpty}
onSave={setEditableDemoEmpty}
placeholder="Unnamed Device"
inputAriaLabel="Edit demo name (empty)"
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600 }}
/>
</DemoRow>
</Subsection>
<Subsection title="editable={false} — pencil hidden, read-only">
<DemoRow>
<EditableText
value={editableDemoValue}
editable={false}
textStyle={{ fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--color-text-primary)' }}
/>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-2. LINE CHART
================================================================ */}
<Section title="LineChart" id="line-chart">
<Subsection title="Gradient color stops — keyed to Y value, not position (RSSI signal quality)">
<Card padding>
<LineChart
data={RSSI_DEMO_DATA}
unit=" dBm"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleTimeString()}
colorStops={[
{ value: -90, color: 'var(--color-danger)' },
{ value: -70, color: 'var(--color-warning)' },
{ value: -50, color: 'var(--color-success)' },
]}
/>
</Card>
</Subsection>
<Subsection title="Series with gaps — null y renders as a break, not zero">
<Card padding>
<LineChart
data={PING_DEMO_DATA}
color="var(--color-primary)"
unit=" ms"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleTimeString()}
/>
</Card>
</Subsection>
<Subsection title="Irregular spacing — smooth curve must not loop backward at a time jump">
<Card padding>
<LineChart
data={IRREGULAR_DEMO_DATA}
color="var(--color-success)"
unit="°C"
formatValue={v => v.toFixed(1)}
formatX={x => new Date(x).toLocaleTimeString()}
/>
</Card>
</Subsection>
<Subsection title="Sparse data in a wide range — axis reflects the full requested range, not just the data">
<Card padding>
<LineChart
data={SPARSE_DEMO_DATA}
color="var(--color-warning)"
unit=" pts"
formatValue={v => Math.round(v)}
formatX={x => new Date(x).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
xMin={_sparseNow - 24 * 3600000}
xMax={_sparseNow}
/>
</Card>
</Subsection>
<Subsection title="Empty state">
<Card padding>
<LineChart data={[]} emptyLabel="No ping samples recorded yet." />
</Card>
</Subsection>
</Section>
{/* ================================================================
5a-3. PRESS HOLD BUTTON
================================================================ */}
<Section title="PressHoldButton" id="press-hold-button">
<Subsection title="Danger variant — 3s hold (default)">
<DemoRow>
<PressHoldButton variant="danger" onConfirm={() => alert('Confirmed!')}>
Hold to Reset Stats
</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Primary variant — custom hold duration + holding label">
<DemoRow>
<PressHoldButton
variant="primary"
holdMs={1500}
holdingLabel="Keep holding…"
onConfirm={() => alert('Confirmed!')}
>
Hold to Confirm (1.5s)
</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Sizes">
<DemoRow>
<PressHoldButton size="sm" onConfirm={() => {}}>Small</PressHoldButton>
<PressHoldButton size="md" onConfirm={() => {}}>Medium</PressHoldButton>
<PressHoldButton size="lg" onConfirm={() => {}}>Large</PressHoldButton>
</DemoRow>
</Subsection>
<Subsection title="Disabled">
<DemoRow>
<PressHoldButton disabled onConfirm={() => {}}>Cannot hold</PressHoldButton>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5a-4. TIME RANGE SELECT
================================================================ */}
<Section title="TimeRangeSelect" id="time-range-select">
<Subsection title="Presets + custom range modal">
<DemoRow>
<div style={{ width: 220 }}>
<TimeRangeSelect value={demoRange} onChange={setDemoRange} />
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
key: {demoRange.key} · since: {demoRange.since ? demoRange.since.toLocaleString() : 'null (all-time)'}
</span>
</DemoRow>
</Subsection>
</Section>
{/* ================================================================
5b. PILL BUTTON
================================================================ */}
@@ -1281,6 +1523,45 @@ export default function StyleGuide() {
</Section>
{/* ================================================================
6b. MULTI-SELECT
================================================================ */}
<Section title="MultiSelect" id="multiselect">
<Subsection title="Checkbox dropdown — trigger shows placeholder / single label / 'N selected'">
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-3)' }}>
Same floating-menu language as <code>Select</code>, with checkbox rows so multiple values can be picked. Used for filters like "Board Type".
</p>
<div style={{ maxWidth: 280 }}>
<MultiSelect
value={multiSelectDemo}
onChange={setMultiSelectDemo}
placeholder="All Board Types"
options={[
{ value: 'vesper', label: 'Vesper' },
{ value: 'vesper_plus', label: 'Vesper Plus' },
{ value: 'vesper_pro', label: 'Vesper Pro' },
{ value: 'chronos', label: 'Chronos' },
{ value: 'chronos_pro', label: 'Chronos Pro' },
{ value: 'agnus_mini', label: 'Agnus Mini' },
{ value: 'agnus', label: 'Agnus' },
]}
/>
</div>
</Subsection>
<Subsection title="Disabled">
<div style={{ maxWidth: 280 }}>
<MultiSelect
value={[]}
onChange={() => {}}
disabled
placeholder="All Board Types"
options={[{ value: 'vesper', label: 'Vesper' }]}
/>
</div>
</Subsection>
</Section>
{/* ================================================================
7. SPINNER
================================================================ */}
File diff suppressed because it is too large Load Diff
@@ -112,9 +112,11 @@ function FirmwareFormModal({ initial, onClose, onSaved }) {
const [changelog, setChangelog] = useState(initial?.changelog ?? '')
const [releaseNote, setReleaseNote] = useState(initial?.release_note ?? '')
const [file, setFile] = useState(null)
const [elfFile, setElfFile] = useState(null)
const [uploading, setUploading] = useState(false)
const [error, setError] = useState('')
const fileInputRef = useRef(null)
const elfInputRef = useRef(null)
const handleSubmit = async (e) => {
e.preventDefault()
@@ -130,12 +132,14 @@ function FirmwareFormModal({ initial, onClose, onSaved }) {
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
fd.append('file', file)
if (elfFile) fd.append('elf_file', elfFile)
} else {
fd.append('channel', channel); fd.append('version', version)
fd.append('update_type', updateType); fd.append('min_fw_version', minFw)
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
if (file) fd.append('file', file)
if (elfFile) fd.append('elf_file', elfFile)
}
const token = localStorage.getItem('access_token')
const url = isEdit ? `/api/firmware/${initial.id}` : '/api/firmware/upload'
@@ -274,6 +278,30 @@ function FirmwareFormModal({ initial, onClose, onSaved }) {
)}
</div>
</div>
{/* Optional symbols for crash decoding — matched to crash reports by
the ELF's SHA-256 prefix (firmware F-070), not by version. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<input ref={elfInputRef} type="file" accept=".elf"
onChange={(e) => {
const f = e.target.files[0]
if (f && !f.name.endsWith('.elf')) { setError('Only .elf files are accepted for symbols.'); return }
setElfFile(f || null)
}}
style={{ display: 'none' }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={() => elfInputRef.current?.click()}>
{elfFile ? 'Change firmware.elf' : 'Attach firmware.elf'}
</Button>
{elfFile && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', wordBreak: 'break-all' }}>
{elfFile.name} · {formatBytes(elfFile.size)}
</span>
)}
</div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Optional. Lets the console decode crash backtraces from devices running this build.
</span>
</div>
</div>
</div>
</form>
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { useAuth } from '@/hooks/useAuth'
import { useToast } from '@/components/ui/Toast'
import PageHeader from '@/components/ui/PageHeader'
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
@@ -15,6 +16,7 @@ import FormField from '@/components/ui/FormField'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -455,6 +457,7 @@ export default function DeviceInventoryDetail() {
const { sn } = useParams()
const navigate = useNavigate()
const { hasPermission } = useAuth()
const { toast } = useToast()
const canEdit = hasPermission('manufacturing', 'edit')
const canDelete = hasPermission('manufacturing', 'delete')
@@ -462,13 +465,19 @@ export default function DeviceInventoryDetail() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [assignedCustomer, setAssignedCustomer] = useState(null)
const [resolvedUsers, setResolvedUsers] = useState([])
const [deviceUsers, setDeviceUsers] = useState([])
const [usersLoading, setUsersLoading] = useState(false)
// Assignment
const [showCustomerModal, setShowCustomerModal] = useState(false)
const [assignSaving, setAssignSaving] = useState(false)
const [assignError, setAssignError] = useState('')
// User assignment
const [showAddUser, setShowAddUser] = useState(false)
const [removingUser, setRemovingUser] = useState(null)
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
// Status
const [statusSaving, setStatusSaving] = useState(false)
const [statusError, setStatusError] = useState('')
@@ -493,17 +502,6 @@ export default function DeviceInventoryDetail() {
} else {
setAssignedCustomer(null)
}
if (data.user_list?.length) {
Promise.all(
data.user_list.map((uid) =>
api.get(`/users/${uid}`)
.then((u) => ({ uid, display_name: u.display_name || '', email: u.email || '' }))
.catch(() => ({ uid, display_name: '', email: '' }))
)
).then(setResolvedUsers)
} else {
setResolvedUsers([])
}
} catch (err) {
setError(err.message)
} finally {
@@ -513,6 +511,42 @@ export default function DeviceInventoryDetail() {
useEffect(() => { loadDevice() }, [loadDevice])
const loadUsers = useCallback(async (deviceId) => {
if (!deviceId) { setDeviceUsers([]); return }
setUsersLoading(true)
try {
const data = await api.get(`/devices/${deviceId}/users`)
setDeviceUsers(data.users || [])
} catch {
setDeviceUsers([])
} finally {
setUsersLoading(false)
}
}, [])
useEffect(() => { if (device?.id) loadUsers(device.id) }, [device?.id, loadUsers])
const handleUserAdded = (user) => {
setDeviceUsers((prev) => [...prev, { user_id: user.id, display_name: user.display_name, email: user.email, photo_url: user.photo_url, role: '' }])
toast.success('User added', `${user.display_name || user.email} now has access.`)
}
const handleRemoveUser = async () => {
if (!confirmRemoveUser || !device?.id) return
const targetId = confirmRemoveUser.user_id
setRemovingUser(targetId)
try {
await api.delete(`/devices/${device.id}/user-list/${targetId}`)
setDeviceUsers((prev) => prev.filter((u) => u.user_id !== targetId))
toast.success('Removed', `${confirmRemoveUser.display_name || confirmRemoveUser.email} removed.`)
} catch (err) {
toast.danger('Error', err.message || 'Failed to remove user.')
} finally {
setRemovingUser(null)
setConfirmRemoveUser(null)
}
}
// ─── Status change ───────────────────────────────────────────────────────
const handleStatusChange = async (newStatus) => {
@@ -612,7 +646,7 @@ export default function DeviceInventoryDetail() {
}
const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
const userList = device.user_list || []
const existingUserIds = deviceUsers.map((u) => u.user_id).filter(Boolean)
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
@@ -742,19 +776,30 @@ export default function DeviceInventoryDetail() {
{/* User Assignment sub-section */}
<div>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-2)' }}>
Assigned Users {userList.length > 0 && `(${userList.length})`}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', margin: 0 }}>
Assigned Users {deviceUsers.length > 0 && `(${deviceUsers.length})`}
</p>
{userList.length === 0 ? (
{canEdit && (
<Button variant="ghost" size="sm" onClick={() => setShowAddUser(true)}>
+ Add User
</Button>
)}
</div>
{usersLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-4)' }}>
<Spinner size="sm" />
</div>
) : deviceUsers.length === 0 ? (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
No users assigned to this device.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{userList.map((uid) => {
const resolved = resolvedUsers.find((u) => u.uid === uid)
const displayName = resolved?.display_name || ''
const email = resolved?.email || ''
{deviceUsers.map((user) => {
const uid = user.user_id
const displayName = user.display_name || ''
const email = user.email || ''
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
return (
<div
@@ -792,6 +837,24 @@ export default function DeviceInventoryDetail() {
}
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
</div>
{canEdit && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmRemoveUser(user) }}
disabled={removingUser === uid}
title="Remove access" aria-label="Remove user"
style={{
width: 26, height: 26, borderRadius: 'var(--radius-sm)',
border: '1px solid transparent', background: 'none',
color: 'var(--color-text-muted)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s',
}}
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
)}
<Icon name="chevron-right" size={14} color="var(--color-text-muted)" />
</div>
)
@@ -829,6 +892,25 @@ export default function DeviceInventoryDetail() {
/>
)}
<AddDeviceUserModal
open={showAddUser}
deviceId={device.id}
existingUserIds={existingUserIds}
onClose={() => setShowAddUser(false)}
onAdded={handleUserAdded}
/>
<ConfirmDialog
open={!!confirmRemoveUser}
variant="danger"
title="Remove Access"
message={`Remove ${confirmRemoveUser?.display_name || confirmRemoveUser?.email || 'this user'} from the device?`}
confirmLabel="Remove"
onConfirm={handleRemoveUser}
onClose={() => setConfirmRemoveUser(null)}
loading={!!removingUser}
/>
{editModalData && editStepMeta && (
<LifecycleEditModal
open={editModalData !== null}
@@ -2,7 +2,7 @@
// Provisions an ESP32 board via WebSerial + esptool-js.
// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
import { useState, useRef, useCallback, useEffect } from 'react'
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { ESPLoader, Transport } from 'esptool-js'
import api from '@/lib/api'
@@ -13,6 +13,7 @@ import StatusBadge from '@/components/ui/StatusBadge'
import SearchBar from '@/components/ui/SearchBar'
import Spinner from '@/components/ui/Spinner'
import Icon from '@/components/ui/Icon'
import { useSerialConnection } from '@/hooks/useSerialConnection'
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -82,6 +83,21 @@ function formatHwVersion(v) {
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
}
// Parses one line of serial output from Logging.cpp's fixed format:
// 🔴 [ERROR][TagName] message | 🟡 [WARN][TagName] message | etc.
// The leading icon may or may not survive the terminal's encoding, so the
// match is anchored on the bracketed level/tag text instead. Anything that
// doesn't match (ROM/bootloader output, other libraries) is untagged system
// output — still shown, just without a level or tag chip.
const SERIAL_LEVEL_MAP = { ERROR: 'ERROR', WARN: 'WARN', INFO: 'INFO', DEBG: 'DEBUG', VERB: 'VERBOSE' }
const SERIAL_LINE_RE = /^(?:\S+\s+)?\[(ERROR|WARN|INFO|DEBG|VERB)\]\[([^\]]+)\]\s*(.*)$/
function parseSerialLine(raw) {
const m = SERIAL_LINE_RE.exec(raw)
if (!m) return { level: null, tag: null, message: raw }
return { level: SERIAL_LEVEL_MAP[m[1]] || null, tag: m[2], message: m[3] }
}
// ─── Step Indicator ───────────────────────────────────────────────────────────
const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
@@ -660,74 +676,48 @@ function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
}
// ─── Serial Log Modal ──────────────────────────────────────────────────────────
// Reuses SerialLogPanel (level filters, search, live indicator — same as the
// Verify step's serial card) inside a near-fullscreen modal, since this is
// the "full history so far" view rather than a small at-a-glance card.
function SerialLogModal({ open, onClose, logs }) {
const [autoScroll, setAutoScroll] = useState(true)
const endRef = useRef(null)
useEffect(() => {
if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [logs, autoScroll, open])
function SerialLogModal({ open, onClose, logs, monitoring }) {
if (!open) return null
return (
<>
{/* .modal-body is a plain padded/scrolling block by default (the shape
every other modal's content wants). This instance needs it to be a
non-scrolling flex column instead, so SerialLogPanel's own internal
scroll area is the only thing that scrolls and can stretch to fill
the near-fullscreen modal. */}
<style>{`
.serial-log-modal .modal-body {
display: flex; flex-direction: column;
overflow: hidden; min-height: 0;
}
`}</style>
<Modal
open={open}
onClose={onClose}
title="Serial Output Logs"
size="lg"
size="xxl"
className="serial-log-modal"
footer={
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', cursor: 'pointer', userSelect: 'none' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Auto-scroll</span>
<span
onClick={() => setAutoScroll((v) => !v)}
style={{
position: 'relative', display: 'inline-flex', alignItems: 'center',
width: 32, height: 18,
backgroundColor: autoScroll ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
borderRadius: 9, border: '1px solid var(--color-border)',
cursor: 'pointer', transition: 'background-color 0.2s', flexShrink: 0,
}}
>
<span style={{
position: 'absolute', left: autoScroll ? 14 : 2,
width: 14, height: 14, borderRadius: '50%',
backgroundColor: '#fff', transition: 'left 0.15s',
}} />
</span>
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
<Button variant="secondary" onClick={onClose}>Close</Button>
</div>
}
>
<div style={{
backgroundColor: 'var(--color-bg-abyss)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3)',
height: 400,
overflowY: 'auto',
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-xs)',
lineHeight: 1.6,
color: '#a3e635',
}}>
{logs.length === 0 ? (
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>No serial output yet.</span>
) : (
logs.map((line, i) => <div key={i}>{line}</div>)
)}
<div ref={endRef} />
</div>
<SerialLogPanel lines={logs} monitoring={monitoring} />
</Modal>
</>
)
}
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, bespokeOverride, onFlashed }) {
const [portConnected, setPortConnected] = useState(false)
const [portName, setPortName] = useState('')
function StepFlash({ device, bespokeOverride, onFlashed, serialConn }) {
const { portRef, portConnected, portName, requestPort, disconnect: disconnectPort, startMonitor, appendLine: appendSerial, clearLines: clearSerialLines } = serialConn
const [connecting, setConnecting] = useState(false)
const [flashing, setFlashing] = useState(false)
const [done, setDone] = useState(false)
@@ -736,22 +726,43 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const [nvsProgress, setNvsProgress] = useState(0)
const [fwProgress, setFwProgress] = useState(0)
const [log, setLog] = useState([])
const [serial, setSerial] = useState([])
const [nvsProfile, setNvsProfile] = useState('current')
const [error, setError] = useState('')
const loaderRef = useRef(null)
const portRef = useRef(null)
const serialReaderRef = useRef(null)
const serialActiveRef = useRef(false)
const logEndRef = useRef(null)
const serialEndRef = useRef(null)
const infoPanelRef = useRef(null)
// Flash Output must follow the left (info) panel's height one-way — it
// should never influence the left panel's own height in return. CSS Grid's
// `align-items: stretch` computes the row height from BOTH cells' intrinsic
// sizes, so a tall flash log still grows the row (and the left panel with
// it). Measuring the left panel directly and applying that as an explicit
// pixel height on the right panel is the only way to make sizing one-way.
//
// Read getBoundingClientRect().height (the true border-box/visual height),
// NOT ResizeObserver's own contentRect — contentRect always excludes
// border+padding regardless of box-sizing, so applying it as a plain
// `height` on the (border-box) right panel came out 2px short (one
// border-width per side) and made the two cards visibly mismatched.
const [infoPanelHeight, setInfoPanelHeight] = useState(null)
useEffect(() => {
const el = infoPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setInfoPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
const scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
// Scrolling on the *next* render (after the DOM has the new line) rather
// than synchronously in the append call — scrolling immediately after
// setLog targets the sentinel's position from BEFORE the new line was
// committed, so the view lands one line short of the actual end.
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [log])
const fetchBinary = async (url) => {
const token = localStorage.getItem('access_token')
@@ -770,56 +781,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
return str
}
const startSerialMonitor = async (port) => {
serialActiveRef.current = true
await new Promise((r) => setTimeout(r, 1000))
try { await port.open({ baudRate: 115200 }) } catch (openErr) {
appendSerial(`[Error opening port: ${openErr.message}]`); scrollSerial(); return
}
let reader
try { reader = port.readable.getReader() } catch (readerErr) {
appendSerial(`[Error getting reader: ${readerErr.message}]`); scrollSerial()
try { await port.close() } catch (_) {}
return
}
serialReaderRef.current = reader
const dec = new TextDecoder()
let buf = ''
try {
while (serialActiveRef.current) {
const { value, done: streamDone } = await reader.read()
if (streamDone) break
buf += dec.decode(value, { stream: true })
const lines = buf.split(/\r?\n/)
buf = lines.pop()
for (const line of lines) { if (line.trim()) { appendSerial(line); scrollSerial() } }
}
} catch (_) {}
finally { try { reader.releaseLock() } catch (_) {} }
}
const disconnectPort = async () => {
serialActiveRef.current = false
try { await serialReaderRef.current?.cancel() } catch (_) {}
try { serialReaderRef.current?.releaseLock() } catch (_) {}
try { await portRef.current?.close() } catch (_) {}
portRef.current = null
setPortConnected(false)
setPortName('')
appendSerial('[Port disconnected]')
}
const handleConnectPort = async () => {
setError(''); setConnecting(true)
try {
const port = await navigator.serial.requestPort()
portRef.current = port
const info = port.getInfo?.() || {}
const label = info.usbVendorId
? `USB ${info.usbVendorId.toString(16).toUpperCase()}:${(info.usbProductId || 0).toString(16).toUpperCase()}`
: 'Serial Port'
setPortName(label)
setPortConnected(true)
await requestPort()
} catch (err) {
setError(err.message || 'Port selection cancelled.')
} finally {
@@ -829,7 +794,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const handleStartFlash = async () => {
if (!portRef.current) return
setError(''); setLog([]); setSerial([])
setError(''); setLog([]); clearSerialLines()
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
setDone(false)
@@ -866,8 +831,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
transport, baudrate: FLASH_BAUD,
terminal: {
clean() {},
writeLine: (line) => { appendLog(line); scrollLog() },
write: (msg) => { appendLog(msg); scrollLog() },
writeLine: (line) => appendLog(line),
write: (msg) => appendLog(msg),
},
})
await loaderRef.current.main()
@@ -913,7 +878,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
setFlashing(false)
setDone(true)
appendSerial('── Serial monitor started (115200 baud) ──')
startSerialMonitor(port)
startMonitor(port)
} catch (err) {
setError(err.message || String(err))
setFlashing(false)
@@ -926,10 +891,11 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
const boardInfo = BOARD_TYPE_MAP[device.hw_type]
const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
const readyToFlash = portConnected && !flashing && !done && log.length === 0
// ── Left panel: device info + controls ────────────────────────────────────
const InfoPanel = (
<div style={{
<div ref={infoPanelRef} style={{
borderRadius: 'var(--radius-lg)',
border: `1px solid ${familyPal.idleBorder}`,
padding: 'var(--space-5)',
@@ -1029,13 +995,21 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
</div>
</div>
{!webSerialAvailable && (
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-3)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)' }}>
{/* Web Serial warning — space always reserved so its appearance never
shifts panel height; hidden via visibility, not removed from flow. */}
<div style={{
padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--space-3)',
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)',
visibility: webSerialAvailable ? 'hidden' : 'visible',
}} aria-hidden={webSerialAvailable}>
Web Serial API not available. Use Chrome or Edge on a desktop system.
</div>
)}
{error && <div style={{ marginBottom: 'var(--space-3)' }}><ErrorBox msg={error} /></div>}
{/* Error box — same reserved-space treatment. */}
<div style={{ marginBottom: 'var(--space-3)', visibility: error ? 'visible' : 'hidden' }} aria-hidden={!error}>
<ErrorBox msg={error || ' '} />
</div>
{/* Progress bars — always visible, idle at 0% */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
@@ -1052,64 +1026,71 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
{/* Bottom bar */}
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', paddingTop: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
{/* Left: status hint */}
{/* Left: status hint — the "ready/flashing" line always occupies its
slot (visibility toggle, not conditional render) so this column's
height never changes as flash state advances. */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{portConnected && !flashing && !done && log.length === 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: '#4dd6c8' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: '#22c55e', display: 'inline-block' }} />
Ready to flash.
<div
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: flashing ? 'var(--color-text-muted)' : '#4dd6c8' }}
aria-hidden={!(readyToFlash || flashing)}
>
<span style={{
width: 7, height: 7, borderRadius: '50%', display: 'inline-block',
backgroundColor: '#22c55e',
visibility: readyToFlash ? 'visible' : 'hidden',
}} />
<span style={{ visibility: readyToFlash || flashing ? 'visible' : 'hidden' }}>
{flashing ? 'Flashing — do not disconnect…' : 'Ready to flash.'}
</span>
</div>
)}
{flashing && (
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing — do not disconnect…</p>
)}
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}>
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
</p>
</div>
{/* Right: action buttons */}
{!busy && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
{!portConnected && (
{/* Right: action buttons — every possible button always renders (in a
fixed order/slot) so the row's height and the buttons' own sizes
never shift; only the applicable ones are visible at a time. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minHeight: 36 }}>
<span style={{ display: busy ? 'inline-flex' : 'none', alignItems: 'center' }}>
<Spinner size="sm" />
</span>
<span style={{ display: !busy && !portConnected ? 'inline-flex' : 'none' }}>
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
Select COM Port
</Button>
)}
{portConnected && done && (
</span>
<span style={{ display: !busy && portConnected && done ? 'inline-flex' : 'none' }}>
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
)}
{done && (
<Button variant="primary" onClick={onFlashed}>
Proceed to Verify <Icon name="arrow-right" size={14} />
</Button>
)}
{portConnected && !done && (
</span>
<span style={{ display: !busy && portConnected && !done ? 'inline-flex' : 'none' }}>
<Button variant="primary" onClick={handleStartFlash}>
Start Flashing
</Button>
)}
</span>
<span style={{ display: !busy && done ? 'inline-flex' : 'none' }}>
<Button variant="primary" onClick={onFlashed}>
Proceed to Verify <Icon name="arrow-right" size={14} />
</Button>
</span>
</div>
)}
{busy && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<Spinner size="sm" />
</div>
)}
</div>
</div>
)
// ── Right panel: flash output log ─────────────────────────────────────────
// Height is pinned to the measured InfoPanel height (one-way: left drives,
// right follows) — see infoPanelHeight/ResizeObserver above. minHeight: 0
// lets the log body's flex: 1 + overflowY: auto scroll internally within
// that fixed height instead of growing the panel (and the row) with it.
const FlashOutputPanel = (
<div style={{
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--color-border)',
overflow: 'hidden',
display: 'flex', flexDirection: 'column',
height: 320,
alignSelf: 'start',
position: 'sticky', top: 0,
height: infoPanelHeight || undefined,
minHeight: 0,
}}>
<div style={{
padding: '8px 12px',
@@ -1143,8 +1124,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
{/* Info panel (left) | Flash output (right) */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'stretch' }}>
{/* Info panel (left) | Flash output (right) — align-items: start so the
row's height is never driven by the (explicitly-sized) right panel,
only ever by the left panel's natural content height. */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'start' }}>
{InfoPanel}
{FlashOutputPanel}
</div>
@@ -1152,9 +1135,148 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
)
}
// ─── Serial Log Panel (Verify step's right panel) ─────────────────────────────
// Same stepped-severity filter pattern as LogsTab (frontend/src/pages/bellcloud/
// devices/tabs/LogsTab.jsx), applied to raw serial lines instead of MQTT rows.
const SERIAL_LEVEL_STEPS = [
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
{ value: 'INFO', label: 'INFO', color: 'var(--color-info)' },
{ value: 'WARN', label: 'WARN', color: 'var(--color-warning)' },
{ value: 'ERROR', label: 'ERROR', color: 'var(--color-danger)' },
]
const SERIAL_LEVEL_RANK = { VERBOSE: -1, DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }
const SERIAL_LEVEL_COLORS = {
ERROR: { text: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
WARN: { text: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
DEBUG: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
VERBOSE: { text: 'var(--color-text-muted)', bg: 'var(--color-bg-base)' },
}
function SerialLogPanel({ lines, monitoring, height }) {
const [levelStep, setLevelStep] = useState(0)
const [searchText, setSearchText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef(null)
const levelFilter = SERIAL_LEVEL_STEPS[levelStep].value
const parsed = useMemo(() => lines.map((raw) => ({ raw, ...parseSerialLine(raw) })), [lines])
const filtered = useMemo(() => parsed.filter((l) => {
if (levelFilter) {
const rank = l.level ? SERIAL_LEVEL_RANK[l.level] : -1
if (rank < SERIAL_LEVEL_RANK[levelFilter]) return false
}
if (searchText && !l.raw.toLowerCase().includes(searchText.toLowerCase())) return false
return true
}), [parsed, levelFilter, searchText])
const endRef = useRef(null)
useEffect(() => {
if (autoScroll) endRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [filtered, autoScroll])
return (
<div style={{
borderRadius: 'var(--radius-lg)', border: '1px solid var(--color-border)',
overflow: 'hidden', display: 'flex', flexDirection: 'column',
height: height || undefined,
flex: height ? undefined : 1,
minHeight: 0,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap',
padding: '8px 12px', backgroundColor: 'var(--color-bg-elevated)',
borderBottom: '1px solid var(--color-border)', flexShrink: 0,
}}>
<span style={{
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-muted)',
}}>
Serial Output
</span>
{monitoring && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: 'var(--color-success)', boxShadow: '0 0 6px var(--color-success)', display: 'inline-block' }} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
</span>
)}
<div style={{ flex: 1 }} />
<div style={{ display: 'flex', gap: 'var(--space-1)' }}>
{SERIAL_LEVEL_STEPS.map((step, idx) => (
<button
key={step.value}
onClick={() => setLevelStep(idx)}
title={step.label}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${levelStep === idx ? step.color : 'var(--color-border)'}`,
background: levelStep === idx ? `${step.color}22` : 'transparent',
color: levelStep === idx ? step.color : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)', cursor: 'pointer',
}}
>{step.label}</button>
))}
</div>
<input
type="text"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="Filter…"
style={{
height: 22, padding: '0 var(--space-2)', borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-strong)', backgroundColor: 'var(--color-bg-base)',
color: 'var(--color-text-primary)', fontSize: 'var(--font-size-xs)', width: 120, outline: 'none',
}}
/>
<button
onClick={() => setAutoScroll((v) => !v)}
style={{
height: 22, paddingInline: 'var(--space-2)', borderRadius: 'var(--radius-sm)',
border: `1px solid ${autoScroll ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
background: autoScroll ? 'var(--color-primary-subtle)' : 'transparent',
color: autoScroll ? 'var(--color-primary)' : 'var(--color-text-muted)',
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', cursor: 'pointer',
}}
>AUTO-SCROLL</button>
</div>
<div ref={containerRef} style={{
flex: 1, minHeight: 0, padding: 'var(--space-3)', overflowY: 'auto',
fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)',
backgroundColor: 'var(--color-bg-base)', color: 'var(--color-text-secondary)', lineHeight: 1.6,
}}>
{filtered.length === 0 ? (
<span style={{ color: 'var(--color-text-muted)', opacity: 0.5 }}>
{lines.length === 0 ? 'No serial output yet.' : 'No lines match this filter.'}
</span>
) : filtered.map((l, i) => {
const s = l.level ? SERIAL_LEVEL_COLORS[l.level] : null
return (
<div key={i} style={{ display: 'flex', gap: 6, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{l.level && (
<span style={{
flexShrink: 0, display: 'inline-block', padding: '0 4px', borderRadius: 'var(--radius-sm)',
fontSize: '10px', fontWeight: 'var(--font-weight-semibold)', background: s.bg, color: s.text,
letterSpacing: 'var(--tracking-wide)', height: 15, lineHeight: '15px', marginTop: 2,
}}>{l.level}</span>
)}
{l.tag && <span style={{ flexShrink: 0, color: 'var(--color-text-muted)' }}>[{l.tag}]</span>}
<span style={{ color: s ? s.text : 'var(--color-text-secondary)' }}>{l.message}</span>
</div>
)
})}
<div ref={endRef} />
</div>
</div>
)
}
// ─── Step 3: Verify ───────────────────────────────────────────────────────────
function StepVerify({ device, onVerified }) {
function StepVerify({ device, onVerified, serialConn }) {
const [polling, setPolling] = useState(false)
const [timedOut, setTimedOut] = useState(false)
const [verified, setVerified] = useState(false)
@@ -1162,6 +1284,20 @@ function StepVerify({ device, onVerified }) {
const [error, setError] = useState('')
const intervalRef = useRef(null)
const timeoutRef = useRef(null)
const statusPanelRef = useRef(null)
const [statusPanelHeight, setStatusPanelHeight] = useState(null)
// getBoundingClientRect().height (border-box/visual), not contentRect —
// see the matching note on StepFlash's infoPanelHeight above.
useEffect(() => {
const el = statusPanelRef.current
if (!el) return
const observer = new ResizeObserver(() => {
setStatusPanelHeight(el.getBoundingClientRect().height)
})
observer.observe(el)
return () => observer.disconnect()
}, [])
const startPolling = useCallback(() => {
if (polling) return
@@ -1174,8 +1310,13 @@ function StepVerify({ device, onVerified }) {
const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`)
if (hbData.heartbeats?.length > 0) {
const latest = hbData.heartbeats[0]
const receivedMs = latest.received_at
? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN
// received_at already comes back as a full ISO-8601 string with an
// explicit UTC offset (e.g. "2026-07-14T16:03:21+00:00") — see
// backend/database/pg_mqtt.py::_row_to_dict. Parsing it directly is
// correct; appending a literal "Z" (the old code) turns it into an
// invalid string like "...+00:00Z", so Date.parse always returned
// NaN and a live device could never be detected as verified.
const receivedMs = latest.received_at ? Date.parse(latest.received_at) : NaN
if (!isNaN(receivedMs) && receivedMs > startTime) {
clearInterval(intervalRef.current)
clearTimeout(timeoutRef.current)
@@ -1211,9 +1352,8 @@ function StepVerify({ device, onVerified }) {
clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
<div style={{ padding: 'var(--space-5)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', minHeight: 280 }}>
const StatusPanel = (
<div ref={statusPanelRef} style={{ padding: 'var(--space-5)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column' }}>
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-4)' }}>
Waiting for Device
</p>
@@ -1260,17 +1400,25 @@ function StepVerify({ device, onVerified }) {
{error && !timedOut && !verified && (
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div>
)}
</div>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
<div style={{ flex: 1 }} />
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', paddingTop: 'var(--space-3)' }}>
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
</p>
</div>
)
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'start' }}>
{StatusPanel}
<SerialLogPanel lines={serialConn.lines} monitoring={serialConn.monitoring} height={statusPanelHeight} />
</div>
)
}
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
function StepDone({ device, onProvisionNext }) {
function StepDone({ device, onProvisionNext, serialConn }) {
const navigate = useNavigate()
const [showSerialLogs, setShowSerialLogs] = useState(false)
@@ -1314,11 +1462,21 @@ function StepDone({ device, onProvisionNext }) {
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
<Button variant="primary" onClick={onProvisionNext}>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', textAlign: 'center' }}>
Onboard this device now, or stop here and provision another.
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)', justifyContent: 'center' }}>
<Button
variant="primary"
onClick={() => navigate(`/devices/onboard?sn=${encodeURIComponent(device?.serial_number || '')}`)}
>
Onboard This Device
</Button>
<Button variant="secondary" onClick={onProvisionNext}>
Provision Next Device
</Button>
<Button variant="secondary" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
<Button variant="ghost" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
View in Inventory
</Button>
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
@@ -1326,7 +1484,7 @@ function StepDone({ device, onProvisionNext }) {
</Button>
</div>
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={[]} />
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
</div>
)
}
@@ -1345,6 +1503,12 @@ export default function ProvisioningWizard() {
const [bespokeOverride, setBespokeOverride] = useState(null)
const createdSnRef = useRef(null)
// Owned here (not inside StepFlash) so the same live WebSerial session —
// and the log lines it has accumulated — survives the Flash → Verify step
// transition instead of being torn down when StepFlash unmounts.
const serialConn = useSerialConnection()
useEffect(() => () => { serialConn.disconnect() }, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleModePick = (m) => { setMode(m); setStep(1) }
const handleDeviceSelected = (d, bespoke = null) => {
@@ -1364,7 +1528,7 @@ export default function ProvisioningWizard() {
}
return (
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': step === 2 ? '1200px' : 'var(--content-max-width-md)' }}>
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': (step === 2 || step === 3) ? '1200px' : 'var(--content-max-width-md)' }}>
<PageHeader title="Provisioning Wizard" subtitle="Flash firmware to a Bell Systems board via WebSerial">
<Button variant="ghost" onClick={() => navigate('/manufacturing')}>
<Icon name="arrow-left" size={14} />
@@ -1395,15 +1559,16 @@ export default function ProvisioningWizard() {
device={device}
bespokeOverride={bespokeOverride}
onFlashed={handleFlashed}
serialConn={serialConn}
/>
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />
<StepVerify device={device} onVerified={handleVerified} serialConn={serialConn} />
)}
{step === 4 && device && (
<StepDone device={device} onProvisionNext={handleProvisionNext} />
<StepDone device={device} onProvisionNext={handleProvisionNext} serialConn={serialConn} />
)}
</div>
</div>
@@ -0,0 +1,99 @@
// frontend/src/pages/settings/LogRetentionSettings.jsx
import { useState, useEffect } from 'react'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import PageHeader from '@/components/ui/PageHeader'
import Card from '@/components/ui/Card'
import Spinner from '@/components/ui/Spinner'
import FormField from '@/components/ui/FormField'
import Button from '@/components/ui/Button'
const PRESETS = [30, 90, 180, 365]
export default function LogRetentionSettings() {
const { toast } = useToast()
const [days, setDays] = useState(180)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
useEffect(() => {
api.get('/settings/log-retention')
.then(data => setDays(data.days))
.catch(e => toast.danger('Failed to load settings', e.message))
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
try {
const updated = await api.put('/settings/log-retention', { days: Number(days) })
setDays(updated.days)
toast.success('Settings saved', `Device logs are now retained for ${updated.days} days.`)
} catch (e) {
toast.danger('Failed to save', e.message)
} finally {
setSaving(false)
}
}
return (
<div className="page-wrapper page-wrapper--centered" style={{ '--page-content-max-width': 'var(--content-max-width-md)' }}>
<PageHeader
title="Log Retention"
subtitle="Controls how long device MQTT logs (Device Logs, Info, Heartbeats, Commands) are kept before automatic deletion."
/>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-12)' }}>
<Spinner size="lg" />
</div>
) : (
<Card variant="flat">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<FormField
label="Retention period (days)"
name="retention_days"
type="number"
value={days}
onChange={e => setDays(e.target.value)}
hint="Applies globally to every device — there is no per-device override."
inputProps={{ min: 1, max: 3650 }}
/>
<div style={{ display: 'flex', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
{PRESETS.map(p => (
<button
key={p}
type="button"
onClick={() => setDays(p)}
style={{
height: 28,
paddingInline: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
border: `1px solid ${Number(days) === p ? 'var(--color-primary)' : 'var(--color-border-strong)'}`,
background: Number(days) === p ? 'var(--color-primary-subtle)' : 'var(--color-bg-elevated)',
color: Number(days) === p ? 'var(--color-primary)' : 'var(--color-text-secondary)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer',
fontFamily: 'var(--font-family-base)',
}}
>
{p === 30 ? '1 month' : p === 90 ? '3 months' : p === 180 ? '6 months' : '1 year'}
</button>
))}
</div>
<div>
<Button variant="primary" loading={saving} onClick={handleSave}>
Save
</Button>
</div>
</div>
</Card>
)}
</div>
)
}
+6
View File
@@ -9,6 +9,8 @@ import DashboardPage from '@/pages/dashboard/DashboardPage'
import DeviceList from '@/pages/bellcloud/devices/DeviceList'
import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage'
import CrashReport from '@/pages/bellcloud/devices/CrashReport'
import OnboardDevice from '@/pages/bellcloud/devices/OnboardDevice'
import StyleGuide from '@/pages/dev/StyleGuide'
import CardFontSample from '@/pages/dev/CardFontSample'
import UserList from '@/pages/bellcloud/users/UserList'
@@ -32,6 +34,7 @@ import StaffDetail from '@/pages/settings/staff/StaffDetail'
import StaffForm from '@/pages/settings/staff/StaffForm'
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
import LogViewerPage from '@/pages/settings/LogViewerPage'
import LogRetentionSettings from '@/pages/settings/LogRetentionSettings'
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
import CustomerList from '@/pages/crm/customers/CustomerList'
@@ -143,7 +146,9 @@ export default function V2Router() {
<Route path="devices" element={<PermissionGate section="devices"><DeviceList /></PermissionGate>} />
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><ComingSoon /></PermissionGate>} />
{/* Map preview — temporary unlisted route while map view is under construction */}
<Route path="devices/crashes" element={<PermissionGate section="devices"><CrashReport /></PermissionGate>} />
<Route path="devices/map-preview" element={<PermissionGate section="devices"><DeviceMapPage /></PermissionGate>} />
<Route path="devices/onboard" element={<PermissionGate section="devices" action="edit"><OnboardDevice /></PermissionGate>} />
<Route path="devices/:id" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><ComingSoon /></PermissionGate>} />
@@ -196,6 +201,7 @@ export default function V2Router() {
<Route path="settings/public-features" element={<RoleGate roles={['sysadmin', 'admin']}><PublicFeaturesSettings /></RoleGate>} />
<Route path="settings/automations" element={<RoleGate roles={['sysadmin', 'admin']}><AutomationsPage /></RoleGate>} />
<Route path="settings/audit-log" element={<RoleGate roles={['sysadmin']}><LogViewerPage /></RoleGate>} />
<Route path="settings/log-retention" element={<RoleGate roles={['sysadmin', 'admin']}><LogRetentionSettings /></RoleGate>} />
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
+37 -2
View File
@@ -503,6 +503,24 @@ tr:focus-within .btn-table-actions {
color: var(--color-primary);
}
/* ==========================================================================
MULTI-SELECT (.multi-select-menu)
Reuses .select-trigger / .select-menu / .select-option — adds a checkbox
per row and a "Clear selection" row when something is selected.
========================================================================== */
.multi-select-clear {
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
border-radius: 0;
margin-bottom: var(--space-1);
padding-bottom: var(--space-2);
}
.multi-select-clear:hover {
color: var(--color-danger);
}
/* Keep old .select-wrapper for any legacy uses */
.select-wrapper {
position: relative;
@@ -2091,7 +2109,12 @@ tr:focus-within .btn-table-actions {
display: none;
}
/* Header: fixed, covers content area only (not sidebar) — glass treatment */
/* Header: fixed, covers content area only (not sidebar) — glass treatment.
Same tint/blur strength as .sidebar so both fixed chrome surfaces read as
one consistent glass layer — .header previously used a lighter 0.30 tint
than .sidebar's 0.40, which made scrolled content underneath show through
almost undimmed and read as "no glass effect" even though backdrop-filter
was technically applied. */
.header {
position: fixed;
top: 0;
@@ -2099,10 +2122,11 @@ tr:focus-within .btn-table-actions {
right: 0;
height: var(--header-height);
z-index: var(--z-sticky);
background-color: rgba(28, 32, 38, 0.30);
background-color: rgba(28, 32, 38, 0.40);
backdrop-filter: var(--blur-modal);
-webkit-backdrop-filter: var(--blur-modal);
border-bottom: 1px solid rgba(70, 69, 84, 0.12);
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
padding: 0 var(--space-6);
@@ -3220,3 +3244,14 @@ tr:focus-within .btn-table-actions {
filter: brightness(1.06);
box-shadow: var(--shadow-primary-glow);
}
/* EditableText — pencil icon hidden until the whole component is hovered
or the pencil itself has keyboard focus (so it's reachable via Tab). */
.editable-text:hover .editable-text__pencil,
.editable-text__pencil:focus-visible {
opacity: 1 !important;
}
.editable-text__pencil:hover {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}

Some files were not shown because too many files have changed in this diff Show More