Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18705aaefb | ||
|
|
f1a7c5a7ca | ||
|
|
82cf95a9af | ||
|
|
963516ece0 | ||
|
|
46c5c0a846 | ||
|
|
18e57c6e5f | ||
|
|
98dd16b597 | ||
|
|
36f9540a89 | ||
|
|
c1df3b5aa3 | ||
|
|
3f3d2cc63c | ||
|
|
e663b0a609 | ||
|
|
61e9bfab7c | ||
|
|
43102c617d | ||
|
|
ecfe00307a | ||
|
|
6ea7e9d2c5 | ||
|
|
f5db83f26c | ||
|
|
3acd89a2c6 | ||
|
|
81365eed89 | ||
|
|
1253ec155e | ||
|
|
8a668ca60f | ||
|
|
85bd2f7e51 | ||
|
|
79a87e48f1 | ||
|
|
017911bece | ||
|
|
c36e4c41f8 | ||
|
|
8b229e7fb3 | ||
|
|
9d9b46056a | ||
|
|
adec979182 | ||
|
|
99baec9fcf | ||
|
|
c56344a6dc | ||
|
|
0c0dd9d0e9 | ||
|
|
529e866fff | ||
|
|
c8ac0b78c5 | ||
|
|
80c0e955f0 | ||
|
|
ae5da6046f | ||
|
|
aad5f4b98c | ||
|
|
751cac72ce | ||
|
|
cef5e1f0f5 | ||
|
|
f036000769 | ||
|
|
d87925516a | ||
|
|
f54500a9d9 | ||
|
|
4737ab4306 | ||
|
|
6686faad6b | ||
|
|
b07bdafd85 | ||
|
|
dff1a35560 | ||
|
|
acd1896788 | ||
|
|
0d79a9f72c | ||
|
|
6e0a291228 | ||
|
|
8ec13947cb | ||
|
|
0bec4c4648 | ||
|
|
cc138e3d82 | ||
|
|
7c533b9245 | ||
|
|
e5d556fee1 | ||
|
|
38750ba951 | ||
|
|
7f6145ca35 | ||
|
|
2f7c5e859d | ||
|
|
883c0556dd | ||
|
|
1ce0137ba9 | ||
|
|
eaf92f1581 | ||
|
|
cc28ed880b | ||
|
|
44609352b2 | ||
|
|
70e0f9f734 | ||
|
|
a9b3b4e9b0 | ||
|
|
65ce446e44 | ||
|
|
bff90965bf | ||
|
|
3394716f8f | ||
|
|
abf3c3825b |
@@ -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"]
|
||||
|
||||
+3
-1
@@ -40,4 +40,6 @@ Thumbs.db
|
||||
.project-vesper-plan.md
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
.claude/
|
||||
# Per-machine compose overrides (e.g. Windows Postgres volume)
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+858
-28
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
doc_ref.update(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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 = ""
|
||||
|
||||
+118
-28
@@ -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,25 +313,32 @@ 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", ""),
|
||||
"device_id": data.get("device_id", ""),
|
||||
"device_location": data.get("device_location", ""),
|
||||
"is_Online": data.get("is_Online", False),
|
||||
})
|
||||
break
|
||||
devices.append({
|
||||
"id": doc.id,
|
||||
"device_name": data.get("device_name", ""),
|
||||
"device_id": data.get("device_id", ""),
|
||||
"device_location": data.get("device_location", ""),
|
||||
"is_Online": data.get("is_Online", False),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 */}
|
||||
<div
|
||||
className="toast-progress"
|
||||
style={{ animationDuration: `${duration}ms` }}
|
||||
/>
|
||||
{/* 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
timing: existingTimings[i] ?? 90,
|
||||
}))
|
||||
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={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{device.device_name || 'Unnamed Device'}
|
||||
<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)' }}>
|
||||
{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'}
|
||||
>
|
||||
<span className="dcard__online-block-dot" aria-hidden="true" />
|
||||
{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,68 +489,98 @@ 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,
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
|
||||
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
|
||||
}} 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%',
|
||||
}}>
|
||||
<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="Device Uptime">
|
||||
{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>
|
||||
</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>
|
||||
: <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic', fontWeight: 'var(--font-weight-normal)' }}>Unavailable</span>}
|
||||
</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)' }}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-warning)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
MQTT · WARN · Health Monitor
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{mqttStatus.last_warn_message}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No recent issues
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||||
<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_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.4,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 1,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{mqttStatus.last_alert_event.message || 'No details provided.'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No recent issues
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 & 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)
|
||||
@@ -302,9 +302,9 @@ function PasswordCard({ userId, hasUid }) {
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function UserForm() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
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)' }}>
|
||||
|
||||
@@ -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})`}
|
||||
</p>
|
||||
{userList.length === 0 ? (
|
||||
<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>
|
||||
{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
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Serial Output Logs"
|
||||
size="lg"
|
||||
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>
|
||||
<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>
|
||||
</Modal>
|
||||
<>
|
||||
{/* .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="xxl"
|
||||
className="serial-log-modal"
|
||||
footer={
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Button variant="secondary" onClick={onClose}>Close</Button>
|
||||
</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)
|
||||
|
||||
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
|
||||
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
|
||||
// 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 scrollLog = () => logEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
|
||||
|
||||
// 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 API not available. Use Chrome or Edge on a desktop system.
|
||||
</div>
|
||||
)}
|
||||
{/* 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>
|
||||
)}
|
||||
{flashing && (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing — do not disconnect…</p>
|
||||
)}
|
||||
<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>
|
||||
<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 && (
|
||||
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
|
||||
Select COM Port
|
||||
</Button>
|
||||
)}
|
||||
{portConnected && done && (
|
||||
<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 && (
|
||||
<Button variant="primary" onClick={handleStartFlash}>
|
||||
Start Flashing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{busy && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
{/* 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" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ display: !busy && !portConnected ? 'inline-flex' : 'none' }}>
|
||||
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
|
||||
Select COM Port
|
||||
</Button>
|
||||
</span>
|
||||
<span style={{ display: !busy && portConnected && done ? 'inline-flex' : 'none' }}>
|
||||
<Button variant="ghost" onClick={handleStartFlash}>Flash Again</Button>
|
||||
</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>
|
||||
</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,66 +1352,73 @@ 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 }}>
|
||||
<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>
|
||||
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>
|
||||
|
||||
{polling && !verified && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
|
||||
<Spinner size="lg" />
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||
Waiting for device to connect…<br />
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Power cycle the device and ensure it can reach the MQTT broker.
|
||||
</span>
|
||||
</p>
|
||||
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
|
||||
</div>
|
||||
)}
|
||||
{polling && !verified && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
|
||||
<Spinner size="lg" />
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||
Waiting for device to connect…<br />
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Power cycle the device and ensure it can reach the MQTT broker.
|
||||
</span>
|
||||
</p>
|
||||
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{verified && heartbeatData && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#0a2e2a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="check" size={16} color="#4dd6c8" />
|
||||
</div>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: '#4dd6c8' }}>Device is live!</p>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--color-bg-base)', borderRadius: 'var(--radius-md)' }}>
|
||||
<InfoCell label="Firmware" value={heartbeatData.firmware_version || '—'} />
|
||||
<InfoCell label="IP Address" value={heartbeatData.ip_address || '—'} />
|
||||
<InfoCell label="Uptime" value={heartbeatData.uptime_display || '—'} />
|
||||
<InfoCell label="Gateway" value={heartbeatData.gateway || '—'} />
|
||||
{verified && heartbeatData && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#0a2e2a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon name="check" size={16} color="#4dd6c8" />
|
||||
</div>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: '#4dd6c8' }}>Device is live!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timedOut && !verified && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', paddingTop: 'var(--space-4)' }}>
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>
|
||||
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
|
||||
</div>
|
||||
<Button variant="primary" onClick={startPolling}>Retry Verification</Button>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', padding: 'var(--space-3)', backgroundColor: 'var(--color-bg-base)', borderRadius: 'var(--radius-md)' }}>
|
||||
<InfoCell label="Firmware" value={heartbeatData.firmware_version || '—'} />
|
||||
<InfoCell label="IP Address" value={heartbeatData.ip_address || '—'} />
|
||||
<InfoCell label="Uptime" value={heartbeatData.uptime_display || '—'} />
|
||||
<InfoCell label="Gateway" value={heartbeatData.gateway || '—'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)' }}>
|
||||
{timedOut && !verified && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', paddingTop: 'var(--space-4)' }}>
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)', color: 'var(--color-warning)', fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>
|
||||
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
|
||||
</div>
|
||||
<Button variant="primary" onClick={startPolling}>Retry Verification</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !timedOut && !verified && (
|
||||
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user