Compare commits
66
Commits
1ed5012a95
...
18705aaefb
| 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
|
MQTT_CLIENT_ID=bellsystems-admin-panel
|
||||||
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
|
# HMAC secret used to derive per-device MQTT passwords (must match firmware)
|
||||||
MQTT_SECRET=change-me-in-production
|
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
|
# App
|
||||||
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
||||||
|
|||||||
+3
-1
@@ -40,4 +40,6 @@ Thumbs.db
|
|||||||
.project-vesper-plan.md
|
.project-vesper-plan.md
|
||||||
|
|
||||||
# claude
|
# 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
|
- **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 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.
|
- **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 |
|
| `StatusBadge` | `@/components/ui/StatusBadge` | Coloured status pills |
|
||||||
| `FormField` | `@/components/ui/FormField` | Every text/email/password/textarea input |
|
| `FormField` | `@/components/ui/FormField` | Every text/email/password/textarea input |
|
||||||
| `Select` | `@/components/ui/Select` | Custom dropdown (used inside FormField type="select") |
|
| `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 |
|
| `Modal` | `@/components/ui/Modal` | All overlay dialogs |
|
||||||
| `ConfirmDialog` | `@/components/ui/ConfirmDialog` | Destructive / confirmation prompts |
|
| `ConfirmDialog` | `@/components/ui/ConfirmDialog` | Destructive / confirmation prompts |
|
||||||
| `DataTable` | `@/components/ui/DataTable` | All tabular data with sorting/selection |
|
| `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
|
## API Client
|
||||||
|
|
||||||
```js
|
```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/`.
|
These are the ONLY two files permitted to import from `_archive/auth/`.
|
||||||
All other files use `@/hooks/useAuth`.
|
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_username: str = "admin"
|
||||||
mqtt_admin_password: str = ""
|
mqtt_admin_password: str = ""
|
||||||
mqtt_secret: str = "change-me-in-production"
|
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"
|
mosquitto_password_file: str = "/etc/mosquitto/passwd"
|
||||||
mqtt_client_id: str = "bellsystems-admin-panel"
|
mqtt_client_id: str = "bellsystems-admin-panel"
|
||||||
|
|
||||||
@@ -32,6 +35,9 @@ class Settings(BaseSettings):
|
|||||||
# Local file storage
|
# Local file storage
|
||||||
built_melodies_storage_path: str = "./storage/built_melodies"
|
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||||
firmware_storage_path: str = "./storage/firmware"
|
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"
|
flash_assets_storage_path: str = "./storage/flash_assets"
|
||||||
melody_binaries_storage_path: str = "./storage/melody_binaries"
|
melody_binaries_storage_path: str = "./storage/melody_binaries"
|
||||||
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
|
melody_download_base_url: str = "http://melodies.bellsystems.net/download"
|
||||||
|
|||||||
@@ -16,6 +16,33 @@ from database.pg_mqtt import (
|
|||||||
upsert_alert,
|
upsert_alert,
|
||||||
delete_alert,
|
delete_alert,
|
||||||
get_alerts,
|
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,
|
partition_manager_loop,
|
||||||
ensure_current_partitions,
|
ensure_current_partitions,
|
||||||
)
|
)
|
||||||
@@ -42,6 +69,33 @@ __all__ = [
|
|||||||
"upsert_alert",
|
"upsert_alert",
|
||||||
"delete_alert",
|
"delete_alert",
|
||||||
"get_alerts",
|
"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",
|
"partition_manager_loop",
|
||||||
"ensure_current_partitions",
|
"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
|
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):
|
class MelodyMainItem(BaseModel):
|
||||||
"""Mirrors the Melody schema used in the melodies collection."""
|
"""Mirrors the Melody schema used in the melodies collection."""
|
||||||
information: dict = {}
|
information: dict = {}
|
||||||
@@ -112,12 +141,17 @@ class MelodyMainItem(BaseModel):
|
|||||||
|
|
||||||
class DeviceCreate(BaseModel):
|
class DeviceCreate(BaseModel):
|
||||||
device_name: str = ""
|
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_photo: str = ""
|
||||||
device_location: str = ""
|
device_location: str = ""
|
||||||
is_Online: bool = False
|
is_Online: bool = False
|
||||||
device_attributes: DeviceAttributes = DeviceAttributes()
|
device_attributes: DeviceAttributes = DeviceAttributes()
|
||||||
device_subscription: DeviceSubInformation = DeviceSubInformation()
|
device_subscription: DeviceSubInformation = DeviceSubInformation()
|
||||||
device_stats: DeviceStatistics = DeviceStatistics()
|
device_stats: DeviceStatistics = DeviceStatistics()
|
||||||
|
device_health_settings: DeviceHealthSettings = DeviceHealthSettings()
|
||||||
events_on: bool = False
|
events_on: bool = False
|
||||||
device_location_coordinates: Any = None # GeoPoint dict {lat, lng} or legacy str
|
device_location_coordinates: Any = None # GeoPoint dict {lat, lng} or legacy str
|
||||||
device_melodies_all: List[MelodyMainItem] = []
|
device_melodies_all: List[MelodyMainItem] = []
|
||||||
@@ -136,6 +170,7 @@ class DeviceCreate(BaseModel):
|
|||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
device_name: Optional[str] = None
|
device_name: Optional[str] = None
|
||||||
|
console_name: Optional[str] = None
|
||||||
device_photo: Optional[str] = None
|
device_photo: Optional[str] = None
|
||||||
device_location: Optional[str] = None
|
device_location: Optional[str] = None
|
||||||
is_Online: Optional[bool] = None
|
is_Online: Optional[bool] = None
|
||||||
@@ -143,6 +178,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
device_attributes: Optional[Dict[str, Any]] = None
|
device_attributes: Optional[Dict[str, Any]] = None
|
||||||
device_subscription: Optional[Dict[str, Any]] = None
|
device_subscription: Optional[Dict[str, Any]] = None
|
||||||
device_stats: 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
|
events_on: Optional[bool] = None
|
||||||
device_location_coordinates: Optional[Any] = None # dict {lat, lng} or legacy str
|
device_location_coordinates: Optional[Any] = None # dict {lat, lng} or legacy str
|
||||||
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
||||||
@@ -190,3 +226,39 @@ class DeviceUserInfo(BaseModel):
|
|||||||
class DeviceUsersResponse(BaseModel):
|
class DeviceUsersResponse(BaseModel):
|
||||||
users: List[DeviceUserInfo]
|
users: List[DeviceUserInfo]
|
||||||
total: int
|
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,
|
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
||||||
DeviceUsersResponse, DeviceUserInfo,
|
DeviceUsersResponse, DeviceUserInfo,
|
||||||
DeviceNoteCreate, DeviceNoteUpdate,
|
DeviceNoteCreate, DeviceNoteUpdate,
|
||||||
|
ResetStatsRequest, ResetStatsResult,
|
||||||
)
|
)
|
||||||
from devices import service
|
from devices import service
|
||||||
|
from users import service as users_service
|
||||||
import database as mqtt_db
|
import database as mqtt_db
|
||||||
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||||
from shared.firebase import get_db as get_firestore
|
from shared.firebase import get_db as get_firestore
|
||||||
@@ -65,7 +67,7 @@ async def create_device(
|
|||||||
):
|
):
|
||||||
device = service.create_device(body)
|
device = service.create_device(body)
|
||||||
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device",
|
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
|
return device
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ async def update_device(
|
|||||||
if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []):
|
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 [])}
|
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",
|
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
|
return device
|
||||||
|
|
||||||
|
|
||||||
@@ -437,14 +439,14 @@ async def add_user_to_device(
|
|||||||
db: AsyncSession = Depends(get_pg_session),
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
"""Add a user reference to the device's user_list field."""
|
"""Add a user reference to the device's user_list field."""
|
||||||
db = get_firestore()
|
fs = get_firestore()
|
||||||
device_ref = db.collection("devices").document(device_id)
|
device_ref = fs.collection("devices").document(device_id)
|
||||||
device_doc = device_ref.get()
|
device_doc = device_ref.get()
|
||||||
if not device_doc.exists:
|
if not device_doc.exists:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
# Verify user exists
|
# 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:
|
if not user_doc.exists:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
@@ -460,10 +462,15 @@ async def add_user_to_device(
|
|||||||
elif isinstance(entry, str):
|
elif isinstance(entry, str):
|
||||||
existing_ids.add(entry.split("/")[-1])
|
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:
|
if body.user_id not in existing_ids:
|
||||||
user_ref = db.collection("users").document(body.user_id)
|
|
||||||
user_list.append(user_ref)
|
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",
|
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||||
device_id, device_id, meta={"action_detail": "user_added",
|
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),
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
"""Remove a user reference from the device's user_list field."""
|
"""Remove a user reference from the device's user_list field."""
|
||||||
db = get_firestore()
|
fs = get_firestore()
|
||||||
device_ref = db.collection("devices").document(device_id)
|
device_ref = fs.collection("devices").document(device_id)
|
||||||
device_doc = device_ref.get()
|
device_doc = device_ref.get()
|
||||||
if not device_doc.exists:
|
if not device_doc.exists:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
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)
|
# 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)]
|
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",
|
await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device",
|
||||||
device_id, device_id, meta={"action_detail": "user_removed",
|
device_id, device_id, meta={"action_detail": "user_removed",
|
||||||
"user_id": user_id})
|
"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",
|
"daySilenceFrom", "daySilenceTo",
|
||||||
"nightSilenceFrom", "nightSilenceTo",
|
"nightSilenceFrom", "nightSilenceTo",
|
||||||
"backlightTurnOnTime", "backlightTurnOffTime",
|
"backlightTurnOnTime", "backlightTurnOffTime",
|
||||||
|
"subscrStart", "warrantyStart", "maintainedOn",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -158,9 +159,10 @@ def list_devices(
|
|||||||
if search:
|
if search:
|
||||||
search_lower = search.lower()
|
search_lower = search.lower()
|
||||||
name_match = search_lower in (device.device_name or "").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()
|
location_match = search_lower in (device.device_location or "").lower()
|
||||||
sn_match = search_lower in (device.serial_number 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
|
continue
|
||||||
|
|
||||||
results.append(device)
|
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
|
# Deep-merge nested structs so unmentioned sub-fields are preserved
|
||||||
existing = doc.to_dict()
|
existing = doc.to_dict()
|
||||||
nested_keys = (
|
nested_keys = (
|
||||||
"device_attributes", "device_subscription", "device_stats",
|
"device_attributes", "device_subscription", "device_stats", "device_health_settings",
|
||||||
)
|
)
|
||||||
for key in nested_keys:
|
for key in nested_keys:
|
||||||
if key in update_data and isinstance(existing.get(key), dict):
|
if key in update_data and isinstance(existing.get(key), dict):
|
||||||
update_data[key] = _deep_merge(existing[key], update_data[key])
|
update_data[key] = _deep_merge(existing[key], update_data[key])
|
||||||
|
|
||||||
update_data = _restore_timestamps(update_data)
|
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()
|
updated_doc = doc_ref.get()
|
||||||
return _doc_to_device(updated_doc)
|
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]:
|
def get_device_users(device_doc_id: str) -> list[dict]:
|
||||||
"""Get users assigned to a device from the device_users sub-collection.
|
"""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():
|
if device_id and isinstance(device_id, str) and device_id.strip():
|
||||||
device_doc = db.collection("devices").document(device_id.strip()).get()
|
device_doc = db.collection("devices").document(device_id.strip()).get()
|
||||||
if device_doc.exists:
|
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():
|
if user_id and isinstance(user_id, str) and user_id.strip():
|
||||||
user_doc = db.collection("users").document(user_id.strip()).get()
|
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.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from firmware.models import FirmwareVersion, FirmwareListResponse, FirmwareMetadataResponse, UpdateType
|
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 database.postgres import get_pg_session
|
||||||
from shared.audit import log_action
|
from shared.audit import log_action
|
||||||
|
|
||||||
@@ -29,10 +29,15 @@ async def upload_firmware(
|
|||||||
release_note: Optional[str] = Form(None),
|
release_note: Optional[str] = Form(None),
|
||||||
bespoke_uid: Optional[str] = Form(None),
|
bespoke_uid: Optional[str] = Form(None),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
elf_file: Optional[UploadFile] = File(None),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
db: AsyncSession = Depends(get_pg_session),
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
file_bytes = await file.read()
|
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(
|
fw = service.upload_firmware(
|
||||||
hw_type=hw_type,
|
hw_type=hw_type,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -44,11 +49,48 @@ async def upload_firmware(
|
|||||||
release_note=release_note,
|
release_note=release_note,
|
||||||
bespoke_uid=bespoke_uid,
|
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",
|
await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "firmware",
|
||||||
fw.id, f"{hw_type} v{version} ({channel})")
|
fw.id, f"{hw_type} v{version} ({channel})")
|
||||||
return fw
|
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)
|
@router.get("", response_model=FirmwareListResponse)
|
||||||
def list_firmware(
|
def list_firmware(
|
||||||
hw_type: Optional[str] = Query(None),
|
hw_type: Optional[str] = Query(None),
|
||||||
@@ -114,10 +156,14 @@ async def edit_firmware(
|
|||||||
release_note: Optional[str] = Form(None),
|
release_note: Optional[str] = Form(None),
|
||||||
bespoke_uid: Optional[str] = Form(None),
|
bespoke_uid: Optional[str] = Form(None),
|
||||||
file: Optional[UploadFile] = File(None),
|
file: Optional[UploadFile] = File(None),
|
||||||
|
elf_file: Optional[UploadFile] = File(None),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
db: AsyncSession = Depends(get_pg_session),
|
db: AsyncSession = Depends(get_pg_session),
|
||||||
):
|
):
|
||||||
file_bytes = await file.read() if file and file.filename else None
|
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(
|
fw = service.edit_firmware(
|
||||||
doc_id=firmware_id,
|
doc_id=firmware_id,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
@@ -129,6 +175,10 @@ async def edit_firmware(
|
|||||||
bespoke_uid=bespoke_uid,
|
bespoke_uid=bespoke_uid,
|
||||||
file_bytes=file_bytes,
|
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",
|
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)
|
firmware_id, f"{fw.hw_type} v{fw.version} ({fw.channel})" if fw else firmware_id)
|
||||||
return fw
|
return fw
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ def _resolve_device_name(db, device_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
doc = db.collection("devices").document(device_id.strip()).get()
|
doc = db.collection("devices").document(device_id.strip()).get()
|
||||||
if doc.exists:
|
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:
|
except Exception as e:
|
||||||
print(f"[helpdesk] Error resolving device name: {e}")
|
print(f"[helpdesk] Error resolving device name: {e}")
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async def startup():
|
|||||||
mqtt_manager.start(asyncio.get_event_loop())
|
mqtt_manager.start(asyncio.get_event_loop())
|
||||||
asyncio.create_task(db.partition_manager_loop())
|
asyncio.create_task(db.partition_manager_loop())
|
||||||
asyncio.create_task(db.purge_loop())
|
asyncio.create_task(db.purge_loop())
|
||||||
|
asyncio.create_task(mqtt_manager.ping_loop())
|
||||||
asyncio.create_task(nextcloud_keepalive_loop())
|
asyncio.create_task(nextcloud_keepalive_loop())
|
||||||
asyncio.create_task(crm_poll_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")]
|
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
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceUserSummary(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
display_name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DeviceInventoryItem(BaseModel):
|
class DeviceInventoryItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
serial_number: str
|
serial_number: str
|
||||||
@@ -91,9 +97,16 @@ class DeviceInventoryItem(BaseModel):
|
|||||||
owner: Optional[str] = None
|
owner: Optional[str] = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: Optional[str] = None
|
||||||
device_name: 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
|
lifecycle_history: Optional[List["LifecycleEntry"]] = None
|
||||||
customer_id: Optional[str] = None
|
customer_id: Optional[str] = None
|
||||||
|
customer_name: Optional[str] = None
|
||||||
user_list: Optional[List[str]] = None
|
user_list: Optional[List[str]] = None
|
||||||
|
users: Optional[List[DeviceUserSummary]] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceInventoryListResponse(BaseModel):
|
class DeviceInventoryListResponse(BaseModel):
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ async def create_batch(
|
|||||||
def list_devices(
|
def list_devices(
|
||||||
status: Optional[str] = Query(None),
|
status: Optional[str] = Query(None),
|
||||||
hw_type: 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),
|
search: Optional[str] = Query(None),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
@@ -80,6 +82,8 @@ def list_devices(
|
|||||||
items = service.list_devices(
|
items = service.list_devices(
|
||||||
status=status,
|
status=status,
|
||||||
hw_type=hw_type,
|
hw_type=hw_type,
|
||||||
|
hw_types=hw_types,
|
||||||
|
has_users=has_users,
|
||||||
search=search,
|
search=search,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ from shared.firebase import get_db
|
|||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from utils.serial_number import generate_serial
|
from utils.serial_number import generate_serial
|
||||||
from utils.nvs_generator import generate as generate_nvs_binary
|
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"
|
COLLECTION = "devices"
|
||||||
|
CRM_COLLECTION = "crm_customers"
|
||||||
|
USERS_COLLECTION = "users"
|
||||||
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
_BATCH_ID_CHARS = string.ascii_uppercase + string.digits
|
||||||
|
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ def _resolve_user_list(raw_list: list) -> list[str]:
|
|||||||
return result
|
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 {}
|
data = doc.to_dict() or {}
|
||||||
created_raw = data.get("created_at")
|
created_raw = data.get("created_at")
|
||||||
if isinstance(created_raw, datetime):
|
if isinstance(created_raw, datetime):
|
||||||
@@ -53,6 +55,14 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
|||||||
else:
|
else:
|
||||||
created_str = str(created_raw) if created_raw else None
|
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(
|
return DeviceInventoryItem(
|
||||||
id=doc.id,
|
id=doc.id,
|
||||||
serial_number=data.get("serial_number", ""),
|
serial_number=data.get("serial_number", ""),
|
||||||
@@ -64,12 +74,48 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
|||||||
owner=data.get("owner"),
|
owner=data.get("owner"),
|
||||||
assigned_to=data.get("assigned_to"),
|
assigned_to=data.get("assigned_to"),
|
||||||
device_name=data.get("device_name") or None,
|
device_name=data.get("device_name") or None,
|
||||||
|
console_name=data.get("console_name") or None,
|
||||||
lifecycle_history=data.get("lifecycle_history") or [],
|
lifecycle_history=data.get("lifecycle_history") or [],
|
||||||
customer_id=data.get("customer_id"),
|
customer_id=customer_id,
|
||||||
user_list=_resolve_user_list(data.get("user_list") or []),
|
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:
|
def create_batch(data: BatchCreate) -> BatchResponse:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
existing_sns = _get_existing_sns(db)
|
existing_sns = _get_existing_sns(db)
|
||||||
@@ -123,6 +169,8 @@ def create_batch(data: BatchCreate) -> BatchResponse:
|
|||||||
def list_devices(
|
def list_devices(
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
hw_type: str | None = None,
|
hw_type: str | None = None,
|
||||||
|
hw_types: list[str] | None = None,
|
||||||
|
has_users: bool | None = None,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -136,7 +184,24 @@ def list_devices(
|
|||||||
query = query.where("hw_type", "==", hw_type)
|
query = query.where("hw_type", "==", hw_type)
|
||||||
|
|
||||||
docs = list(query.stream())
|
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:
|
if search:
|
||||||
search_lower = search.lower()
|
search_lower = search.lower()
|
||||||
@@ -145,6 +210,10 @@ def list_devices(
|
|||||||
if search_lower in (item.serial_number or "").lower()
|
if search_lower in (item.serial_number or "").lower()
|
||||||
or search_lower in (item.owner 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.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]
|
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())
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Device")
|
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:
|
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
|
- Deterministic: no storage needed, re-derive on every auth check
|
||||||
- Rotating MQTT_SECRET invalidates all passwords at once if needed
|
- Rotating MQTT_SECRET invalidates all passwords at once if needed
|
||||||
|
|
||||||
Transition support: during rollout, the legacy password "vesper" is also
|
Transition support: while MQTT_ALLOW_LEGACY_PASSWORD is on (default), the
|
||||||
accepted so that devices still on old firmware stay connected.
|
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:
|
User types handled:
|
||||||
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
|
- Device users (e.g. "PV25L22BP01R01", "PV-26A18-BC02R-X7KQA"):
|
||||||
@@ -18,21 +20,49 @@ User types handled:
|
|||||||
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
|
- Kiosk users (e.g. "PV25L22BP01R01-kiosk"):
|
||||||
Same HMAC auth derived from the full kiosk username.
|
Same HMAC auth derived from the full kiosk username.
|
||||||
ACL: allowed to access topics of their base device (suffix stripped).
|
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:
|
- admin, bonamin, NodeRED, and other non-device users:
|
||||||
These connect via the passwd file backend (go-auth file backend).
|
These connect via the passwd file backend (go-auth file backend).
|
||||||
They never reach this HTTP backend — go-auth resolves them first.
|
They never reach this HTTP backend — go-auth resolves them first.
|
||||||
The ACL endpoint below handles them defensively anyway (superuser list).
|
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 hmac
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter, Form, Response
|
from fastapi import APIRouter, Form, Response
|
||||||
|
from firebase_admin import auth as firebase_auth
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
|
from mqtt import app_users
|
||||||
|
|
||||||
|
logger = logging.getLogger("mqtt.auth")
|
||||||
|
|
||||||
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
|
router = APIRouter(prefix="/mqtt/auth", tags=["mqtt-auth"])
|
||||||
|
|
||||||
LEGACY_PASSWORD = "vesper"
|
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).
|
# Users authenticated via passwd file (go-auth file backend).
|
||||||
# If they somehow reach the HTTP ACL endpoint, grant full access.
|
# If they somehow reach the HTTP ACL endpoint, grant full access.
|
||||||
SUPERUSERS = {"admin", "bonamin", "NodeRED"}
|
SUPERUSERS = {"admin", "bonamin", "NodeRED"}
|
||||||
@@ -47,18 +77,44 @@ def _derive_password(username: str) -> str:
|
|||||||
).hexdigest()[:32]
|
).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:
|
def _is_valid_password(username: str, password: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Accept the password if it matches either:
|
Accept the password if it matches either:
|
||||||
- The HMAC-derived password (new firmware)
|
- The HMAC-derived password (new firmware)
|
||||||
- The legacy hardcoded "vesper" password (old firmware, transition period)
|
- The legacy hardcoded "vesper" password (old firmware, transition period),
|
||||||
|
only when MQTT_ALLOW_LEGACY_PASSWORD is on and the username is
|
||||||
Remove the legacy check in Stage 7 once all devices are on new firmware.
|
device-shaped (never app_ users or unknown shapes).
|
||||||
"""
|
"""
|
||||||
expected = _derive_password(username)
|
expected = _derive_password(username)
|
||||||
hmac_ok = hmac.compare_digest(expected, password)
|
if hmac.compare_digest(expected, password):
|
||||||
legacy_ok = hmac.compare_digest(LEGACY_PASSWORD, password)
|
return True
|
||||||
return hmac_ok or legacy_ok
|
|
||||||
|
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:
|
def _base_sn(username: str) -> str:
|
||||||
@@ -72,8 +128,52 @@ def _base_sn(username: str) -> str:
|
|||||||
return username
|
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")
|
@router.post("/user")
|
||||||
async def mqtt_auth_user(
|
def mqtt_auth_user(
|
||||||
username: str = Form(...),
|
username: str = Form(...),
|
||||||
password: str = Form(...),
|
password: str = Form(...),
|
||||||
clientid: str = Form(default=""),
|
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")
|
Username = device SN (new format: "PV-26A18-BC02R-X7KQA", old format: "PV25L22BP01R01")
|
||||||
or kiosk variant: "PV25L22BP01R01-kiosk"
|
or kiosk variant: "PV25L22BP01R01-kiosk"
|
||||||
|
or app user: "app_<firebase_uid>"
|
||||||
Password = HMAC-derived (new firmware) or "vesper" (legacy firmware)
|
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
|
Note: admin, bonamin and NodeRED authenticate via the go-auth passwd file backend
|
||||||
and never reach this endpoint.
|
and never reach this endpoint.
|
||||||
"""
|
"""
|
||||||
|
if username.startswith(APP_USER_PREFIX):
|
||||||
|
return _auth_app_user(username, password)
|
||||||
|
|
||||||
if _is_valid_password(username, password):
|
if _is_valid_password(username, password):
|
||||||
return Response(status_code=200)
|
return Response(status_code=200)
|
||||||
|
|
||||||
return Response(status_code=403)
|
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")
|
@router.post("/acl")
|
||||||
async def mqtt_auth_acl(
|
def mqtt_auth_acl(
|
||||||
username: str = Form(...),
|
username: str = Form(...),
|
||||||
topic: str = Form(...),
|
topic: str = Form(...),
|
||||||
clientid: str = Form(default=""),
|
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.
|
Returns 200 to allow, 403 to deny.
|
||||||
|
|
||||||
Topic pattern: vesper/{sn}/...
|
Topic pattern: vesper/{sn}/...
|
||||||
- Device users: may only access their own SN segment
|
- Device users: may only access their own SN segment
|
||||||
- Kiosk users: stripped of -kiosk suffix, then same rule applies
|
- 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 (bonamin, NodeRED): full access
|
||||||
"""
|
"""
|
||||||
# Superusers get full access (shouldn't reach here but handled defensively)
|
# Superusers get full access (shouldn't reach here but handled defensively)
|
||||||
if username in SUPERUSERS:
|
if username in SUPERUSERS:
|
||||||
return Response(status_code=200)
|
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)
|
# Derive the base SN (handles -kiosk suffix)
|
||||||
base = _base_sn(username)
|
base = _base_sn(username)
|
||||||
|
|
||||||
|
|||||||
+68
-9
@@ -1,12 +1,19 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
from typing import Set
|
from typing import Set
|
||||||
import paho.mqtt.client as paho_mqtt
|
import paho.mqtt.client as paho_mqtt
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger("mqtt.client")
|
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:
|
class MqttManager:
|
||||||
"""Singleton MQTT client manager."""
|
"""Singleton MQTT client manager."""
|
||||||
@@ -61,12 +68,17 @@ class MqttManager:
|
|||||||
if reason_code == 0:
|
if reason_code == 0:
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info("MQTT connected, subscribing to topics")
|
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([
|
client.subscribe([
|
||||||
("vesper/+/data", 1),
|
("vesper/+/control/ack", 1),
|
||||||
|
("vesper/+/control/reports", 1),
|
||||||
("vesper/+/status/heartbeat", 1),
|
("vesper/+/status/heartbeat", 1),
|
||||||
("vesper/+/status/alerts", 1),
|
("vesper/+/status/playback", 1),
|
||||||
("vesper/+/status/info", 0),
|
("vesper/+/system/alerts", 1),
|
||||||
("vesper/+/logs", 1),
|
("vesper/+/system/info", 1),
|
||||||
|
("vesper/+/system/logs", 0),
|
||||||
|
("vesper/+/system/metrics", 0),
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
logger.error(f"MQTT connection failed: {reason_code}")
|
logger.error(f"MQTT connection failed: {reason_code}")
|
||||||
@@ -87,10 +99,17 @@ class MqttManager:
|
|||||||
|
|
||||||
serial = parts[1]
|
serial = parts[1]
|
||||||
topic_type = "/".join(parts[2:])
|
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():
|
if self._loop and self._loop.is_running():
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._process_message(serial, topic_type, payload, topic),
|
self._process_message(serial, topic_type, payload, topic, retained),
|
||||||
self._loop,
|
self._loop,
|
||||||
)
|
)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -99,15 +118,16 @@ class MqttManager:
|
|||||||
logger.error(f"Error processing MQTT message: {e}")
|
logger.error(f"Error processing MQTT message: {e}")
|
||||||
|
|
||||||
async def _process_message(self, serial: str, topic_type: str,
|
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
|
from mqtt.logger import handle_message
|
||||||
await handle_message(serial, topic_type, payload)
|
await handle_message(serial, topic_type, payload, retained=retained)
|
||||||
|
|
||||||
ws_data = {
|
ws_data = {
|
||||||
"type": topic_type,
|
"type": topic_type,
|
||||||
"device_serial": serial,
|
"device_serial": serial,
|
||||||
"payload": payload,
|
"payload": payload,
|
||||||
"topic": raw_topic,
|
"topic": raw_topic,
|
||||||
|
"retained": retained,
|
||||||
}
|
}
|
||||||
await self._broadcast_ws(ws_data)
|
await self._broadcast_ws(ws_data)
|
||||||
|
|
||||||
@@ -132,10 +152,49 @@ class MqttManager:
|
|||||||
if not self._client or not self._connected:
|
if not self._client or not self._connected:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
topic = f"vesper/{device_serial}/control"
|
topic = f"vesper/{device_serial}/control/command"
|
||||||
payload = json.dumps({"cmd": cmd, "contents": contents})
|
payload = json.dumps({"v": 2, "cmd": cmd, "contents": contents})
|
||||||
result = self._client.publish(topic, payload, qos=1)
|
result = self._client.publish(topic, payload, qos=1)
|
||||||
return result.rc == paho_mqtt.MQTT_ERR_SUCCESS
|
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()
|
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 logging
|
||||||
|
import time
|
||||||
import database as db
|
import database as db
|
||||||
|
from mqtt import presence
|
||||||
|
|
||||||
logger = logging.getLogger("mqtt.logger")
|
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:
|
try:
|
||||||
|
# v2 topic set — see project-vesper's vesper_mqtt_topic_spec_v2.md.
|
||||||
if topic_type == "status/heartbeat":
|
if topic_type == "status/heartbeat":
|
||||||
await _handle_heartbeat(serial, payload)
|
await _handle_heartbeat(serial, payload, retained)
|
||||||
elif topic_type == "status/alerts":
|
elif topic_type == "system/alerts":
|
||||||
await _handle_alerts(serial, payload)
|
await _handle_alerts(serial, payload, retained)
|
||||||
elif topic_type == "status/info":
|
elif topic_type == "system/info":
|
||||||
await _handle_info(serial, payload)
|
await _handle_info(serial, payload, retained)
|
||||||
elif topic_type == "logs":
|
elif topic_type == "system/logs":
|
||||||
await _handle_log(serial, payload)
|
await _handle_log(serial, payload)
|
||||||
elif topic_type == "data":
|
elif topic_type == "system/metrics":
|
||||||
await _handle_data_response(serial, payload)
|
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:
|
else:
|
||||||
logger.debug(f"Unhandled topic type: {topic_type} for {serial}")
|
logger.debug(f"Unhandled topic type: {topic_type} for {serial}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling {topic_type} for {serial}: {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.
|
# Store silently — do not log as a visible event.
|
||||||
# The console surfaces an alert only when the device goes silent (no heartbeat for 90s).
|
# 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(
|
await db.insert_heartbeat(
|
||||||
device_serial=serial,
|
device_serial=serial,
|
||||||
device_id=inner.get("device_id", ""),
|
device_id=payload.get("device_id", ""),
|
||||||
firmware_version=inner.get("firmware_version", ""),
|
firmware_version=payload.get("fw_version", ""),
|
||||||
ip_address=inner.get("ip_address", ""),
|
ip_address=payload.get("ip_address", ""),
|
||||||
gateway=inner.get("gateway", ""),
|
gateway=payload.get("gateway", ""),
|
||||||
uptime_ms=inner.get("uptime_ms", 0),
|
uptime_ms=payload.get("uptime_ms", 0),
|
||||||
uptime_display=inner.get("timestamp", ""),
|
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,
|
level=level,
|
||||||
message=message,
|
message=message,
|
||||||
device_timestamp=device_timestamp,
|
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", "")
|
subsystem = payload.get("subsystem", "")
|
||||||
state = payload.get("state", "")
|
state = payload.get("state", "")
|
||||||
if not subsystem or not state:
|
if not subsystem or not state:
|
||||||
@@ -71,24 +108,161 @@ async def _handle_alerts(serial: str, payload: dict):
|
|||||||
if state == "CLEARED":
|
if state == "CLEARED":
|
||||||
await db.delete_alert(serial, subsystem)
|
await db.delete_alert(serial, subsystem)
|
||||||
else:
|
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", "")
|
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", {})
|
data = payload.get("payload", {})
|
||||||
|
|
||||||
if event_type == "playback_started":
|
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":
|
elif event_type == "playback_stopped":
|
||||||
logger.debug(f"{serial}: playback stopped")
|
message = "Playback stopped"
|
||||||
else:
|
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", "")
|
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)
|
pending = await db.get_pending_command(serial)
|
||||||
if pending:
|
if pending:
|
||||||
cmd_status = "success" if status == "SUCCESS" else "error"
|
cmd_status = "success" if status == "SUCCESS" else "error"
|
||||||
@@ -98,4 +272,4 @@ async def _handle_data_response(serial: str, payload: dict):
|
|||||||
response_payload=payload,
|
response_payload=payload,
|
||||||
)
|
)
|
||||||
else:
|
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
|
level: str
|
||||||
message: str
|
message: str
|
||||||
device_timestamp: Optional[int] = None
|
device_timestamp: Optional[int] = None
|
||||||
|
source: str = "log"
|
||||||
received_at: str
|
received_at: str
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +40,10 @@ class HeartbeatEntry(BaseModel):
|
|||||||
gateway: Optional[str] = None
|
gateway: Optional[str] = None
|
||||||
uptime_ms: Optional[int] = None
|
uptime_ms: Optional[int] = None
|
||||||
uptime_display: Optional[str] = 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
|
received_at: str
|
||||||
|
|
||||||
|
|
||||||
@@ -53,11 +58,21 @@ class CommandEntry(BaseModel):
|
|||||||
responded_at: Optional[str] = None
|
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):
|
class DeviceMqttStatus(BaseModel):
|
||||||
device_serial: str
|
device_serial: str
|
||||||
online: bool
|
online: bool
|
||||||
last_heartbeat: Optional[HeartbeatEntry] = None
|
last_heartbeat: Optional[HeartbeatEntry] = None
|
||||||
seconds_since_heartbeat: Optional[int] = None
|
seconds_since_heartbeat: Optional[int] = None
|
||||||
|
last_alert_event: Optional[AlertEventEntry] = None
|
||||||
|
|
||||||
|
|
||||||
class MqttStatusResponse(BaseModel):
|
class MqttStatusResponse(BaseModel):
|
||||||
@@ -96,3 +111,125 @@ class DeviceAlertEntry(BaseModel):
|
|||||||
|
|
||||||
class DeviceAlertsResponse(BaseModel):
|
class DeviceAlertsResponse(BaseModel):
|
||||||
alerts: List[DeviceAlertEntry]
|
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 fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
|
||||||
from typing import Optional
|
from typing import Optional, List
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from mqtt.models import (
|
from mqtt.models import (
|
||||||
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
MqttCommandRequest, CommandSendResponse, MqttStatusResponse,
|
||||||
DeviceMqttStatus, LogListResponse, HeartbeatListResponse,
|
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.client import mqtt_manager
|
||||||
|
from mqtt import presence
|
||||||
import database as db
|
import database as db
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -19,6 +25,8 @@ async def get_all_device_status(
|
|||||||
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
heartbeats = await db.get_latest_heartbeats()
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
devices = []
|
devices = []
|
||||||
for hb in heartbeats:
|
for hb in heartbeats:
|
||||||
@@ -31,11 +39,14 @@ async def get_all_device_status(
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
seconds_ago = 9999
|
seconds_ago = 9999
|
||||||
|
|
||||||
|
alert_event = alert_by_serial.get(hb["device_serial"])
|
||||||
|
|
||||||
devices.append(DeviceMqttStatus(
|
devices.append(DeviceMqttStatus(
|
||||||
device_serial=hb["device_serial"],
|
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),
|
last_heartbeat=HeartbeatEntry(**hb),
|
||||||
seconds_since_heartbeat=seconds_ago,
|
seconds_since_heartbeat=seconds_ago,
|
||||||
|
last_alert_event=AlertEventEntry(**alert_event) if alert_event else None,
|
||||||
))
|
))
|
||||||
return MqttStatusResponse(
|
return MqttStatusResponse(
|
||||||
devices=devices,
|
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)
|
@router.post("/command/{device_serial}", response_model=CommandSendResponse)
|
||||||
async def send_command(
|
async def send_command(
|
||||||
device_serial: str,
|
device_serial: str,
|
||||||
@@ -81,14 +107,18 @@ async def send_command(
|
|||||||
async def get_device_logs(
|
async def get_device_logs(
|
||||||
device_serial: str,
|
device_serial: str,
|
||||||
level: Optional[str] = Query(None, description="Filter: INFO, WARN, ERROR"),
|
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),
|
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),
|
limit: int = Query(100, ge=1, le=1000),
|
||||||
offset: int = Query(0, ge=0),
|
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")),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
logs, total = await db.get_logs(
|
logs, total = await db.get_logs(
|
||||||
device_serial, level=level, search=search,
|
device_serial, level=level, search=search, source=source,
|
||||||
limit=limit, offset=offset,
|
min_level=min_level, limit=limit, offset=offset, since=since, until=until,
|
||||||
)
|
)
|
||||||
return LogListResponse(logs=logs, total=total)
|
return LogListResponse(logs=logs, total=total)
|
||||||
|
|
||||||
@@ -96,12 +126,14 @@ async def get_device_logs(
|
|||||||
@router.get("/heartbeats/{device_serial}", response_model=HeartbeatListResponse)
|
@router.get("/heartbeats/{device_serial}", response_model=HeartbeatListResponse)
|
||||||
async def get_device_heartbeats(
|
async def get_device_heartbeats(
|
||||||
device_serial: str,
|
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),
|
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")),
|
_user: TokenPayload = Depends(require_permission("mqtt", "view")),
|
||||||
):
|
):
|
||||||
heartbeats, total = await db.get_heartbeats(
|
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)
|
return HeartbeatListResponse(heartbeats=heartbeats, total=total)
|
||||||
|
|
||||||
@@ -119,6 +151,93 @@ async def get_device_commands(
|
|||||||
return CommandListResponse(commands=commands, total=total)
|
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")
|
@router.websocket("/ws")
|
||||||
async def mqtt_websocket(websocket: WebSocket):
|
async def mqtt_websocket(websocket: WebSocket):
|
||||||
"""Live MQTT data stream. Auth via query param: ?token=JWT"""
|
"""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 []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for d in results[:LIMIT]:
|
for d in results[:LIMIT]:
|
||||||
label = d.device_name or d.serial_number or d.device_id or d.id
|
display_name = d.console_name or d.device_name
|
||||||
sublabel = d.serial_number if d.device_name else None
|
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({
|
out.append({
|
||||||
"type": "device",
|
"type": "device",
|
||||||
"id": d.id,
|
"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 auth.models import Role
|
||||||
from settings.models import MelodySettings, MelodySettingsUpdate
|
from settings.models import MelodySettings, MelodySettingsUpdate
|
||||||
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
||||||
|
from settings.log_retention_models import LogRetentionSettings, LogRetentionSettingsUpdate
|
||||||
from settings import service
|
from settings import service
|
||||||
from settings import public_features_service
|
from settings import public_features_service
|
||||||
|
from settings import log_retention_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
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)),
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
):
|
):
|
||||||
return public_features_service.update_public_features(body)
|
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 ---
|
# --- Request / Response schemas ---
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserProfile(BaseModel):
|
||||||
email: str = ""
|
email: str = ""
|
||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
photo_url: str = ""
|
photo_url: str = ""
|
||||||
@@ -17,6 +17,10 @@ class UserCreate(BaseModel):
|
|||||||
quickSettingsPIN: str = ""
|
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):
|
class UserUpdate(BaseModel):
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
display_name: Optional[str] = None
|
display_name: Optional[str] = None
|
||||||
@@ -29,7 +33,7 @@ class UserUpdate(BaseModel):
|
|||||||
quickSettingsPIN: Optional[str] = None
|
quickSettingsPIN: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserInDB(UserCreate):
|
class UserInDB(UserProfile):
|
||||||
id: str
|
id: str
|
||||||
created_time: str = ""
|
created_time: str = ""
|
||||||
lastActive: 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 firebase_admin import auth as firebase_auth
|
||||||
from shared.firebase import get_db, get_bucket
|
from shared.firebase import get_db, get_bucket
|
||||||
@@ -9,6 +9,37 @@ from users.models import UserCreate, UserUpdate, UserInDB
|
|||||||
|
|
||||||
COLLECTION = "users"
|
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):
|
def _convert_firestore_value(val):
|
||||||
"""Convert Firestore-specific types (Timestamp, DocumentReference) to strings."""
|
"""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:
|
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()
|
db = get_db()
|
||||||
doc_data = data.model_dump()
|
doc_data = data.model_dump(exclude={"password"})
|
||||||
|
doc_data["email"] = email
|
||||||
doc_data["friendsList"] = []
|
doc_data["friendsList"] = []
|
||||||
doc_data["friendsInvited"] = []
|
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:
|
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)
|
update_data = data.model_dump(exclude_none=True)
|
||||||
doc_ref.update(update_data)
|
doc_ref.update(update_data)
|
||||||
|
invalidate_mqtt_acl_cache(doc.to_dict()) # status may have changed
|
||||||
|
|
||||||
updated_doc = doc_ref.get()
|
updated_doc = doc_ref.get()
|
||||||
return _doc_to_user(updated_doc)
|
return _doc_to_user(updated_doc)
|
||||||
|
|
||||||
|
|
||||||
def delete_user(user_doc_id: str) -> None:
|
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()
|
db = get_db()
|
||||||
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
doc_ref = db.collection(COLLECTION).document(user_doc_id)
|
||||||
doc = doc_ref.get()
|
doc = doc_ref.get()
|
||||||
if not doc.exists:
|
if not doc.exists:
|
||||||
raise NotFoundError("User")
|
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()
|
doc_ref.delete()
|
||||||
|
invalidate_mqtt_acl_cache(doc.to_dict())
|
||||||
|
|
||||||
|
|
||||||
def block_user(user_doc_id: str) -> UserInDB:
|
def block_user(user_doc_id: str) -> UserInDB:
|
||||||
@@ -130,6 +201,7 @@ def block_user(user_doc_id: str) -> UserInDB:
|
|||||||
raise NotFoundError("User")
|
raise NotFoundError("User")
|
||||||
|
|
||||||
doc_ref.update({"status": "blocked"})
|
doc_ref.update({"status": "blocked"})
|
||||||
|
invalidate_mqtt_acl_cache(doc.to_dict())
|
||||||
updated_doc = doc_ref.get()
|
updated_doc = doc_ref.get()
|
||||||
return _doc_to_user(updated_doc)
|
return _doc_to_user(updated_doc)
|
||||||
|
|
||||||
@@ -143,6 +215,7 @@ def unblock_user(user_doc_id: str) -> UserInDB:
|
|||||||
raise NotFoundError("User")
|
raise NotFoundError("User")
|
||||||
|
|
||||||
doc_ref.update({"status": "active"})
|
doc_ref.update({"status": "active"})
|
||||||
|
invalidate_mqtt_acl_cache(doc.to_dict())
|
||||||
updated_doc = doc_ref.get()
|
updated_doc = doc_ref.get()
|
||||||
return _doc_to_user(updated_doc)
|
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
|
already_assigned = True
|
||||||
break
|
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:
|
if not already_assigned:
|
||||||
user_list.append(user_path)
|
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())
|
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:
|
elif entry != user_path:
|
||||||
new_list.append(entry)
|
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())
|
return _doc_to_user(user_ref.get())
|
||||||
|
|
||||||
|
|
||||||
def get_user_devices(user_doc_id: str) -> list[dict]:
|
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()
|
db = get_db()
|
||||||
|
|
||||||
# Verify user exists
|
# Verify user exists
|
||||||
@@ -230,25 +313,32 @@ def get_user_devices(user_doc_id: str) -> list[dict]:
|
|||||||
if not user_doc.exists:
|
if not user_doc.exists:
|
||||||
raise NotFoundError("User")
|
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 = []
|
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()
|
data = doc.to_dict()
|
||||||
user_list = data.get("user_list", [])
|
devices.append({
|
||||||
|
"id": doc.id,
|
||||||
for entry in user_list:
|
"device_name": data.get("device_name", ""),
|
||||||
entry_path = entry.path if isinstance(entry, DocumentReference) else entry
|
"device_id": data.get("device_id", ""),
|
||||||
if entry_path == user_path:
|
"device_location": data.get("device_location", ""),
|
||||||
devices.append({
|
"is_Online": data.get("is_Online", False),
|
||||||
"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
|
|
||||||
|
|
||||||
return devices
|
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:
|
def set_password(user_doc_id: str, new_password: str) -> None:
|
||||||
"""Set a Firebase Auth password for a user via their Firestore document ID.
|
"""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.
|
automatically for users who registered via the Flutter app.
|
||||||
"""
|
"""
|
||||||
if not new_password or len(new_password) < 6:
|
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", "")
|
uid = doc.to_dict().get("uid", "")
|
||||||
if not 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:
|
try:
|
||||||
firebase_auth.update_user(uid, password=new_password)
|
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:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: bellsystems-backend
|
container_name: bellsystems-backend
|
||||||
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
@@ -22,12 +23,14 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build: ./frontend
|
build: ./frontend
|
||||||
container_name: bellsystems-frontend
|
container_name: bellsystems-frontend
|
||||||
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: bellsystems-nginx
|
container_name: bellsystems-nginx
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "90:80" # access v2 on localhost:8001
|
- "90:80" # access v2 on localhost:8001
|
||||||
volumes:
|
volumes:
|
||||||
@@ -47,6 +50,9 @@ services:
|
|||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
volumes:
|
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
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- 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 { useAuth } from '@/hooks/useAuth'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import HeaderSearch from '@/components/ui/HeaderSearch'
|
import HeaderSearch from '@/components/ui/HeaderSearch'
|
||||||
|
import { getLocalizedValue } from '@/lib/melodyUtils'
|
||||||
|
|
||||||
// ─── Breadcrumb config ────────────────────────────────────────────────────────
|
// ─── 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 },
|
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 },
|
products: { path: (id) => `/crm/products/${id}`, label: (d) => d.name || null },
|
||||||
quotations: { path: (id) => `/crm/quotations/${id}`, label: (d) => d.quotation_number || 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 },
|
orders: { path: (id) => `/crm/orders/${id}`, label: (d) => d.order_number || null },
|
||||||
melodies: { path: (id) => `/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) => d.name || 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 },
|
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 },
|
notes: { path: (id) => `/equipment/notes/${id}`, label: (d) => d.title || d.subject || null },
|
||||||
staff: { path: (id) => `/staff/${id}`, label: (d) => d.name || null },
|
staff: { path: (id) => `/staff/${id}`, label: (d) => d.name || null },
|
||||||
@@ -292,6 +293,16 @@ const SETTINGS_ITEMS = [
|
|||||||
</svg>
|
</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',
|
to: '/settings/pages',
|
||||||
label: 'Page Settings',
|
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>,
|
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} />,
|
blackBox: () => <SvgIcon Component={IcoBlackbox} />,
|
||||||
deviceSettings: () => <SvgIcon Component={IcoMelodySettings} />,
|
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>,
|
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} />,
|
melodies: () => <SvgIcon Component={IcoMelodies} />,
|
||||||
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
|
library: () => <SvgIcon Component={IcoMelodiesEditor} />,
|
||||||
@@ -132,6 +133,7 @@ const navSections = [
|
|||||||
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
|
{ to: '/mqtt/commands', label: 'Command Center', icon: 'commandCenter' },
|
||||||
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
|
{ to: '/equipment/notes', label: 'BlackBox', icon: 'blackBox' },
|
||||||
{ to: '/devices/settings', label: 'Device Settings', icon: 'deviceSettings', placeholder: true },
|
{ 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' },
|
{ 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 { useState, useRef, useEffect, Children, isValidElement } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import { computeFloatingMenuPosition } from '@/lib/floatingMenuPosition'
|
||||||
|
|
||||||
// Parse React <option> children into a flat [{value, label, disabled}] array
|
// Parse React <option> children into a flat [{value, label, disabled}] array
|
||||||
function parseOptions(children) {
|
function parseOptions(children) {
|
||||||
@@ -46,7 +47,7 @@ export default function Select({
|
|||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
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 triggerRef = useRef(null)
|
||||||
const menuRef = useRef(null)
|
const menuRef = useRef(null)
|
||||||
|
|
||||||
@@ -105,8 +106,7 @@ export default function Select({
|
|||||||
function openMenu() {
|
function openMenu() {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
if (triggerRef.current) {
|
if (triggerRef.current) {
|
||||||
const r = triggerRef.current.getBoundingClientRect()
|
setMenuPos(computeFloatingMenuPosition(triggerRef.current.getBoundingClientRect()))
|
||||||
setMenuPos({ top: r.bottom + 4, left: r.left, width: r.width })
|
|
||||||
}
|
}
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ export default function Select({
|
|||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="select-menu"
|
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"
|
role="listbox"
|
||||||
aria-label="Options"
|
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).
|
// Each toast auto-dismisses after `duration` ms (default 4000).
|
||||||
// ToastProvider renders the stack itself via a portal — no extra component needed.
|
// 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 { createPortal } from 'react-dom'
|
||||||
|
import Spinner from '@/components/ui/Spinner'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Context
|
// Context
|
||||||
@@ -49,7 +50,7 @@ function ToastIcon({ variant }) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Single Toast item
|
// 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 [exiting, setExiting] = useState(false)
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
@@ -57,15 +58,21 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
|
|||||||
setTimeout(() => onDismiss(id), 300)
|
setTimeout(() => onDismiss(id), 300)
|
||||||
}, [id, onDismiss])
|
}, [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 timerRef = useRef(null)
|
||||||
const startTimer = () => {
|
const startTimer = () => {
|
||||||
|
if (pending) return
|
||||||
timerRef.current = setTimeout(dismiss, duration)
|
timerRef.current = setTimeout(dismiss, duration)
|
||||||
}
|
}
|
||||||
const clearTimer = () => clearTimeout(timerRef.current)
|
const clearTimer = () => clearTimeout(timerRef.current)
|
||||||
|
|
||||||
// Start timer on mount
|
// (Re)start the timer whenever `pending` resolves to false, or on mount if already resolved
|
||||||
useState(() => { startTimer() })
|
useEffect(() => {
|
||||||
|
clearTimer()
|
||||||
|
startTimer()
|
||||||
|
return clearTimer
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pending, duration])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -76,7 +83,7 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
|
|||||||
onMouseLeave={startTimer}
|
onMouseLeave={startTimer}
|
||||||
>
|
>
|
||||||
<div className="toast-icon">
|
<div className="toast-icon">
|
||||||
<ToastIcon variant={variant} />
|
{pending ? <Spinner size="sm" /> : <ToastIcon variant={variant} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="toast-content">
|
<div className="toast-content">
|
||||||
@@ -94,11 +101,13 @@ function ToastItem({ id, variant = 'info', title, message, duration = 4000, onDi
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Progress bar — shrinks over duration */}
|
{/* Progress bar — shrinks over duration. Hidden while pending (no timeout running). */}
|
||||||
<div
|
{!pending && (
|
||||||
className="toast-progress"
|
<div
|
||||||
style={{ animationDuration: `${duration}ms` }}
|
className="toast-progress"
|
||||||
/>
|
style={{ animationDuration: `${duration}ms` }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -111,9 +120,9 @@ let _nextId = 1
|
|||||||
export function ToastProvider({ children }) {
|
export function ToastProvider({ children }) {
|
||||||
const [toasts, setToasts] = useState([])
|
const [toasts, setToasts] = useState([])
|
||||||
|
|
||||||
const addToast = useCallback((variant, title, message, duration) => {
|
const addToast = useCallback((variant, title, message, duration, pending = false) => {
|
||||||
const id = _nextId++
|
const id = _nextId++
|
||||||
setToasts((prev) => [...prev, { id, variant, title, message, duration }])
|
setToasts((prev) => [...prev, { id, variant, title, message, duration, pending }])
|
||||||
return id
|
return id
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -121,11 +130,20 @@ export function ToastProvider({ children }) {
|
|||||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
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 = {
|
const toast = {
|
||||||
success: (title, message, duration) => addToast('success', title, message, duration),
|
success: (title, message, duration) => addToast('success', title, message, duration),
|
||||||
danger: (title, message, duration) => addToast('danger', title, message, duration),
|
danger: (title, message, duration) => addToast('danger', title, message, duration),
|
||||||
warning: (title, message, duration) => addToast('warning', title, message, duration),
|
warning: (title, message, duration) => addToast('warning', title, message, duration),
|
||||||
info: (title, message, duration) => addToast('info', 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,
|
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
|
// frontend/src/hooks/useMqttWebSocket.js
|
||||||
export function useMqttWebSocket() {}
|
// 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 })
|
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. */
|
/** Relative time: "just now", "5 minutes ago", "3 days ago", etc. */
|
||||||
export function fmtRelative(iso) {
|
export function fmtRelative(iso) {
|
||||||
const d = safeDate(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 */}
|
{/* 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 ? (
|
{loading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import Modal from '@/components/ui/Modal'
|
|||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
|
|
||||||
export default function DeleteDeviceModal({ device, onConfirm, onCancel, error }) {
|
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
|
const serial = device?.serial_number || device?.device_id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -112,8 +112,23 @@ function SilenceBlock({ title, isOn, onToggle, from, onFromChange, to, onToChang
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, id }) {
|
// Console UI uses alertType 'disabled'|'single'|'multi'; firmware uses
|
||||||
const totalBells = attr?.totalBells || 8
|
// 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 [alertType, setAlertType] = useState(clock?.ringAlerts || 'disabled')
|
||||||
const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000)
|
const [ringIntervals, setRingIntervals] = useState(clock?.ringIntervals ?? 1000)
|
||||||
@@ -141,12 +156,28 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: {
|
||||||
clockSettings: {
|
clockSettings: {
|
||||||
...clock,
|
...clock,
|
||||||
ringAlerts: alertType,
|
ringAlerts: alertType,
|
||||||
ringAlertsMasterOn: alertType !== 'disabled',
|
|
||||||
ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals,
|
ringIntervals: alertType === 'multi' ? ringIntervals : clock?.ringIntervals,
|
||||||
hourAlertsBell: Number(hourBell),
|
hourAlertsBell: Number(hourBell),
|
||||||
halfhourAlertsBell: Number(halfBell),
|
halfhourAlertsBell: Number(halfBell),
|
||||||
@@ -163,6 +194,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
|||||||
await onSaved()
|
await onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||||
setError(err.message || 'Failed to save.')
|
setError(err.message || 'Failed to save.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
@@ -171,7 +203,7 @@ export default function EditAlertsModal({ open, onClose, onSaved, clock, attr, i
|
|||||||
|
|
||||||
const bellOptions = [
|
const bellOptions = [
|
||||||
{ value: '0', label: 'Disabled' },
|
{ 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 (
|
return (
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
|||||||
const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false))
|
const [bellGuardOn, setBellGuardOn] = useState(String(attr?.bellGuardOn ?? false))
|
||||||
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? false))
|
const [bellGuardSafetyOn, setBellGuardSafetyOn] = useState(String(attr?.bellGuardSafetyOn ?? false))
|
||||||
const [warningsOn, setWarningsOn] = useState(String(attr?.warningsOn ?? 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 [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
@@ -20,13 +18,12 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
|
// App-only settings — no device-side equivalent, Firestore is the source of truth.
|
||||||
await api.put(`/devices/${id}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: {
|
||||||
bellGuardOn: toBool(bellGuardOn),
|
bellGuardOn: toBool(bellGuardOn),
|
||||||
warningsOn: toBool(warningsOn),
|
warningsOn: toBool(warningsOn),
|
||||||
bellGuardSafetyOn: toBool(bellGuardSafetyOn),
|
bellGuardSafetyOn: toBool(bellGuardSafetyOn),
|
||||||
hasBells: toBool(hasBells),
|
|
||||||
hasClock: toBool(hasClock),
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await onSaved()
|
await onSaved()
|
||||||
@@ -42,7 +39,7 @@ export default function EditAttributesModal({ open, onClose, onSaved, attr, id }
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Edit Basic Attributes"
|
title="Edit Safety Settings"
|
||||||
size="sm"
|
size="sm"
|
||||||
footer={
|
footer={
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-3)' }}>
|
<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="true">Active</option>
|
||||||
<option value="false">Disabled</option>
|
<option value="false">Disabled</option>
|
||||||
</FormField>
|
</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 && (
|
{error && (
|
||||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-danger)' }}>{error}</p>
|
<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
|
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 maxOutputs = sub?.maxOutputs || 8
|
||||||
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0)
|
const bellOutputsUsed = (attr?.bellOutputs || []).filter((o) => o > 0)
|
||||||
const clockOutputsUsed = (clock?.clockOutputs || []).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)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: {
|
||||||
clockSettings: {
|
clockSettings: {
|
||||||
...clock,
|
...clock,
|
||||||
isBacklightAutomationOn: toBool(isOn),
|
isBacklightAutomationOn: enabled,
|
||||||
backlightOutput: Number(output),
|
backlightOutput: Number(output),
|
||||||
backlightTurnOnTime: toBool(isOn) && onTime ? timeToIso(onTime) : '',
|
backlightTurnOnTime: enabled && onTime ? timeToIso(onTime) : '',
|
||||||
backlightTurnOffTime: toBool(isOn) && offTime ? timeToIso(offTime) : '',
|
backlightTurnOffTime: enabled && offTime ? timeToIso(offTime) : '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await onSaved()
|
await onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||||
setError(err.message || 'Failed to save.')
|
setError(err.message || 'Failed to save.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const TIMING_LABELS = {
|
|||||||
140: 'Max — 140ms',
|
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 maxOutputs = sub?.maxOutputs || 8
|
||||||
const existingCount = attr?.totalBells || 0
|
const existingCount = attr?.totalBells || 0
|
||||||
const existingOutputs = attr?.bellOutputs || []
|
const existingOutputs = attr?.bellOutputs || []
|
||||||
@@ -25,10 +25,15 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
|||||||
|
|
||||||
const [activeBells, setActiveBells] = useState(existingCount)
|
const [activeBells, setActiveBells] = useState(existingCount)
|
||||||
const [rows, setRows] = useState(() =>
|
const [rows, setRows] = useState(() =>
|
||||||
Array.from({ length: maxOutputs }, (_, i) => ({
|
Array.from({ length: maxOutputs }, (_, i) => {
|
||||||
output: existingOutputs[i] ?? 0,
|
// 65535 is the firmware's uint16 "unset" sentinel — treat it like 0 (Disabled)
|
||||||
timing: existingTimings[i] ?? 90,
|
// 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 [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -43,6 +48,25 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: {
|
||||||
totalBells: activeBells,
|
totalBells: activeBells,
|
||||||
@@ -53,6 +77,7 @@ export default function EditBellOutputsModal({ open, onClose, onSaved, attr, sub
|
|||||||
await onSaved()
|
await onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||||
setError(err.message || 'Failed to save.')
|
setError(err.message || 'Failed to save.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
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 clock = attr?.clockSettings || {}
|
||||||
const maxOutputs = sub?.maxOutputs || 8
|
const maxOutputs = sub?.maxOutputs || 8
|
||||||
const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0)
|
const bellOutputsUsed = (attr?.bellOutputs || []).filter(o => o > 0)
|
||||||
@@ -85,6 +85,16 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
|
|||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
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}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: {
|
||||||
hasClock,
|
hasClock,
|
||||||
@@ -98,6 +108,7 @@ export default function EditClockSettingsModal({ open, onClose, onSaved, attr, s
|
|||||||
await onSaved()
|
await onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// sendMqttCommand already surfaced a failure toast if the device rejected it.
|
||||||
setError(err.message || 'Failed to save.')
|
setError(err.message || 'Failed to save.')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
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 api from '@/lib/api'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
|
import { useDeviceCommand } from '@/hooks/useDeviceCommand'
|
||||||
import PageHeader from '@/components/ui/PageHeader'
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
import Button from '@/components/ui/Button'
|
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 Tabs from '@/components/ui/Tabs'
|
||||||
import Spinner from '@/components/ui/Spinner'
|
import Spinner from '@/components/ui/Spinner'
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||||
|
|
||||||
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
|
import EditLocationModal from '@/modals/bellcloud/devices/EditLocationModal'
|
||||||
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
|
import EditAttributesModal from '@/modals/bellcloud/devices/EditAttributesModal'
|
||||||
import EditLoggingModal from '@/modals/bellcloud/devices/EditLoggingModal'
|
|
||||||
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
|
import EditMiscModal from '@/modals/bellcloud/devices/EditMiscModal'
|
||||||
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
|
import EditBellOutputsModal from '@/modals/bellcloud/devices/EditBellOutputsModal'
|
||||||
import EditClockSettingsModal from '@/modals/bellcloud/devices/EditClockSettingsModal'
|
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 WarrantyTab from '@/pages/bellcloud/devices/tabs/WarrantyTab'
|
||||||
import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
|
import ManageTab from '@/pages/bellcloud/devices/tabs/ManageTab'
|
||||||
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
|
import ControlTab from '@/pages/bellcloud/devices/tabs/ControlTab'
|
||||||
|
import HealthTab from '@/pages/bellcloud/devices/tabs/HealthTab'
|
||||||
|
|
||||||
// ─── Tab config ───────────────────────────────────────────────────────────────
|
// ─── Tab config ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: 'overview', label: 'Overview' },
|
{ key: 'overview', label: 'Overview' },
|
||||||
|
{ key: 'health', label: 'Health' },
|
||||||
{ key: 'general', label: 'General' },
|
{ key: 'general', label: 'General' },
|
||||||
{ key: 'bells', label: 'Bell Mechanisms' },
|
{ key: 'bells', label: 'Bell Mechanisms' },
|
||||||
{ key: 'clock', label: 'Clock & Alerts' },
|
{ key: 'clock', label: 'Clock & Alerts' },
|
||||||
@@ -44,10 +50,15 @@ const TABS = [
|
|||||||
{ key: 'control', label: 'Control' },
|
{ 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) {
|
function resolveInitialTab(searchParams) {
|
||||||
const raw = searchParams.get('tab')
|
const raw = searchParams.get('tab')?.toLowerCase()
|
||||||
if (!raw) return 'overview'
|
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'
|
return match ? match.key : 'overview'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +92,10 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
const [cmdHistory, setCmdHistory] = useState([])
|
const [cmdHistory, setCmdHistory] = useState([])
|
||||||
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
|
const [cmdHistoryLoading, setCmdHistoryLoading] = useState(false)
|
||||||
const [sendingCmd, setSendingCmd] = useState('')
|
|
||||||
|
|
||||||
// Modal open flags
|
// Modal open flags
|
||||||
const [editingLocation, setEditingLocation] = useState(false)
|
const [editingLocation, setEditingLocation] = useState(false)
|
||||||
const [editingAttributes, setEditingAttributes] = useState(false)
|
const [editingAttributes, setEditingAttributes] = useState(false)
|
||||||
const [editingLogging, setEditingLogging] = useState(false)
|
|
||||||
const [editingMisc, setEditingMisc] = useState(false)
|
const [editingMisc, setEditingMisc] = useState(false)
|
||||||
const [editingBellOutputs, setEditingBellOutputs] = useState(false)
|
const [editingBellOutputs, setEditingBellOutputs] = useState(false)
|
||||||
const [editingClockSettings, setEditingClockSettings] = useState(false)
|
const [editingClockSettings, setEditingClockSettings] = useState(false)
|
||||||
@@ -139,6 +148,19 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
useEffect(() => { loadDevice() }, [loadDevice])
|
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 loadCmdHistory = useCallback(async () => {
|
||||||
const sn = device?.serial_number || device?.device_id
|
const sn = device?.serial_number || device?.device_id
|
||||||
if (!sn) return
|
if (!sn) return
|
||||||
@@ -157,6 +179,61 @@ export default function DeviceDetail() {
|
|||||||
if (activeTab === 'control' && device) loadCmdHistory()
|
if (activeTab === 'control' && device) loadCmdHistory()
|
||||||
}, [activeTab, 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 ───────────────────────────────────────────────────────────────
|
// ── Tab sync ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleTabChange = key => {
|
const handleTabChange = key => {
|
||||||
@@ -213,20 +290,19 @@ export default function DeviceDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMqttCommand = async (cmd, contents = {}) => {
|
// Console-only friendly name — separate from device_name (the name the end
|
||||||
const deviceId = device?.device_id || device?.serial_number
|
// user gave the device in the app). Saves optimistically into local state
|
||||||
if (!deviceId) return
|
// so the hero title updates immediately; a failed save reverts and toasts.
|
||||||
setSendingCmd(cmd)
|
const handleSaveConsoleName = useCallback(async (newName) => {
|
||||||
|
const previous = device?.console_name || ''
|
||||||
|
setDevice(d => d ? { ...d, console_name: newName } : d)
|
||||||
try {
|
try {
|
||||||
await api.post(`/mqtt/command/${deviceId}`, { cmd, contents })
|
await api.put(`/devices/${id}`, { console_name: newName })
|
||||||
toast.success('Sent', `Command "${cmd}" sent.`)
|
|
||||||
loadCmdHistory()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.danger('Error', err.message || 'Failed to send command.')
|
setDevice(d => d ? { ...d, console_name: previous } : d)
|
||||||
} finally {
|
toast({ variant: 'danger', title: 'Failed to rename device', description: err.message || 'Please try again.' })
|
||||||
setSendingCmd('')
|
|
||||||
}
|
}
|
||||||
}
|
}, [id, device, toast])
|
||||||
|
|
||||||
// ── Derived ────────────────────────────────────────────────────────────────
|
// ── Derived ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -272,6 +348,7 @@ export default function DeviceDetail() {
|
|||||||
stats,
|
stats,
|
||||||
sn,
|
sn,
|
||||||
isOnline,
|
isOnline,
|
||||||
|
mqttStatus,
|
||||||
tags,
|
tags,
|
||||||
setTags,
|
setTags,
|
||||||
staffNotes,
|
staffNotes,
|
||||||
@@ -284,14 +361,15 @@ export default function DeviceDetail() {
|
|||||||
cmdHistoryLoading,
|
cmdHistoryLoading,
|
||||||
sendingCmd,
|
sendingCmd,
|
||||||
loadDevice,
|
loadDevice,
|
||||||
|
reloadDeviceSilent,
|
||||||
loadCmdHistory,
|
loadCmdHistory,
|
||||||
sendMqttCommand,
|
sendMqttCommand,
|
||||||
|
sendMqttCommandSilent,
|
||||||
onShowDelete: () => setShowDelete(true),
|
onShowDelete: () => setShowDelete(true),
|
||||||
onAssignCustomer: () => setShowAssignCustomer(true),
|
onAssignCustomer: () => setShowAssignCustomer(true),
|
||||||
onUnassignCustomer: handleUnassignCustomer,
|
onUnassignCustomer: handleUnassignCustomer,
|
||||||
onEditLocation: () => setEditingLocation(true),
|
onEditLocation: () => setEditingLocation(true),
|
||||||
onEditAttributes: () => setEditingAttributes(true),
|
onEditAttributes: () => setEditingAttributes(true),
|
||||||
onEditLogging: () => setEditingLogging(true),
|
|
||||||
onEditMisc: () => setEditingMisc(true),
|
onEditMisc: () => setEditingMisc(true),
|
||||||
onEditBellOutputs: () => setEditingBellOutputs(true),
|
onEditBellOutputs: () => setEditingBellOutputs(true),
|
||||||
onEditClockSettings: () => setEditingClockSettings(true),
|
onEditClockSettings: () => setEditingClockSettings(true),
|
||||||
@@ -305,12 +383,44 @@ export default function DeviceDetail() {
|
|||||||
// ── Render ─────────────────────────────────────────────────────────────────
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrapper">
|
<div className="page-wrapper page-wrapper--capped">
|
||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={device.device_name || 'Unnamed Device'}
|
title={
|
||||||
subtitle={sn}
|
<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 && (
|
{canEdit && (
|
||||||
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
|
<Button variant="secondary" onClick={() => navigate(`/devices/${id}/edit`)}>
|
||||||
Edit Device
|
Edit Device
|
||||||
@@ -326,6 +436,7 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
{activeTab === 'overview' && <OverviewTab {...tabProps} />}
|
{activeTab === 'overview' && <OverviewTab {...tabProps} />}
|
||||||
|
{activeTab === 'health' && <HealthTab {...tabProps} />}
|
||||||
{activeTab === 'general' && <GeneralTab {...tabProps} />}
|
{activeTab === 'general' && <GeneralTab {...tabProps} />}
|
||||||
{activeTab === 'bells' && <BellsTab {...tabProps} />}
|
{activeTab === 'bells' && <BellsTab {...tabProps} />}
|
||||||
{activeTab === 'clock' && <ClockTab {...tabProps} />}
|
{activeTab === 'clock' && <ClockTab {...tabProps} />}
|
||||||
@@ -350,13 +461,6 @@ export default function DeviceDetail() {
|
|||||||
attr={attr}
|
attr={attr}
|
||||||
id={id}
|
id={id}
|
||||||
/>
|
/>
|
||||||
<EditLoggingModal
|
|
||||||
open={editingLogging}
|
|
||||||
onClose={() => setEditingLogging(false)}
|
|
||||||
onSaved={loadDevice}
|
|
||||||
attr={attr}
|
|
||||||
id={id}
|
|
||||||
/>
|
|
||||||
<EditMiscModal
|
<EditMiscModal
|
||||||
open={editingMisc}
|
open={editingMisc}
|
||||||
onClose={() => setEditingMisc(false)}
|
onClose={() => setEditingMisc(false)}
|
||||||
@@ -372,6 +476,7 @@ export default function DeviceDetail() {
|
|||||||
attr={attr}
|
attr={attr}
|
||||||
sub={sub}
|
sub={sub}
|
||||||
id={id}
|
id={id}
|
||||||
|
sendMqttCommand={sendMqttCommand}
|
||||||
/>
|
/>
|
||||||
<EditClockSettingsModal
|
<EditClockSettingsModal
|
||||||
open={editingClockSettings}
|
open={editingClockSettings}
|
||||||
@@ -380,6 +485,7 @@ export default function DeviceDetail() {
|
|||||||
attr={attr}
|
attr={attr}
|
||||||
sub={sub}
|
sub={sub}
|
||||||
id={id}
|
id={id}
|
||||||
|
sendMqttCommand={sendMqttCommand}
|
||||||
/>
|
/>
|
||||||
<EditAlertsModal
|
<EditAlertsModal
|
||||||
open={editingAlerts}
|
open={editingAlerts}
|
||||||
@@ -388,6 +494,7 @@ export default function DeviceDetail() {
|
|||||||
clock={clock}
|
clock={clock}
|
||||||
attr={attr}
|
attr={attr}
|
||||||
id={id}
|
id={id}
|
||||||
|
sendMqttCommand={sendMqttCommand}
|
||||||
/>
|
/>
|
||||||
<EditBacklightModal
|
<EditBacklightModal
|
||||||
open={editingBacklight}
|
open={editingBacklight}
|
||||||
@@ -397,6 +504,7 @@ export default function DeviceDetail() {
|
|||||||
clock={clock}
|
clock={clock}
|
||||||
sub={sub}
|
sub={sub}
|
||||||
id={id}
|
id={id}
|
||||||
|
sendMqttCommand={sendMqttCommand}
|
||||||
/>
|
/>
|
||||||
<EditSubscriptionModal
|
<EditSubscriptionModal
|
||||||
open={editingSubscription}
|
open={editingSubscription}
|
||||||
@@ -422,7 +530,7 @@ export default function DeviceDetail() {
|
|||||||
open={showDelete}
|
open={showDelete}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
title="Delete Device"
|
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"
|
confirmLabel="Delete"
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setShowDelete(false)}
|
onCancel={() => setShowDelete(false)}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
|
||||||
import PageHeader from '@/components/ui/PageHeader'
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import DataTable from '@/components/ui/DataTable'
|
import DataTable from '@/components/ui/DataTable'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
import StatusBadge from '@/components/ui/StatusBadge'
|
||||||
|
import SignalIndicator from '@/components/ui/SignalIndicator'
|
||||||
import Pagination from '@/components/ui/Pagination'
|
import Pagination from '@/components/ui/Pagination'
|
||||||
import SearchBar from '@/components/ui/SearchBar'
|
import SearchBar from '@/components/ui/SearchBar'
|
||||||
import RowActions from '@/components/ui/RowActions'
|
import RowActions from '@/components/ui/RowActions'
|
||||||
@@ -25,6 +27,7 @@ import DeviceListMapView from './DeviceListMapView'
|
|||||||
const ALL_COLUMNS = [
|
const ALL_COLUMNS = [
|
||||||
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
|
{ key: 'status', label: '', pickerLabel: 'Status', defaultOn: true, width: '44px' },
|
||||||
{ key: 'name', label: 'Name', defaultOn: true, alwaysOn: true, sortable: true },
|
{ 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: 'serialNumber', label: 'Serial Number', defaultOn: true },
|
||||||
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
|
{ key: 'location', label: 'Location', defaultOn: true, sortable: true },
|
||||||
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
|
{ key: 'subscrTier', label: 'Tier', defaultOn: true },
|
||||||
@@ -48,8 +51,97 @@ const ALL_COLUMNS = [
|
|||||||
{ key: 'tags', label: 'Tags', defaultOn: false },
|
{ key: 'tags', label: 'Tags', defaultOn: false },
|
||||||
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
|
{ key: 'hw_family', label: 'HW Family', defaultOn: false },
|
||||||
{ key: 'hw_revision', label: 'HW Revision', 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)
|
const ALL_KEYS = ALL_COLUMNS.map((c) => c.key)
|
||||||
|
|
||||||
function loadColumnPrefs() {
|
function loadColumnPrefs() {
|
||||||
@@ -75,6 +167,13 @@ function saveColumnPrefs(order, visible) {
|
|||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── 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) {
|
function parseFirestoreDate(str) {
|
||||||
if (!str) return null
|
if (!str) return null
|
||||||
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
|
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+\d{4}/, 'UTC')
|
||||||
@@ -157,12 +256,17 @@ function Muted({ children }) {
|
|||||||
|
|
||||||
// ─── Sorting helper ───────────────────────────────────────────────────────────
|
// ─── Sorting helper ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function getSortValue(device, key) {
|
function getSortValue(device, key, metrics) {
|
||||||
const attr = device.device_attributes || {}
|
const attr = device.device_attributes || {}
|
||||||
const sub = device.device_subscription || {}
|
const sub = device.device_subscription || {}
|
||||||
const stats = device.device_stats || {}
|
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) {
|
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 'location': return (device.device_location || '').toLowerCase()
|
||||||
case 'totalBells': return attr.totalBells ?? -1
|
case 'totalBells': return attr.totalBells ?? -1
|
||||||
case 'assignedUsers': return Array.isArray(device.user_list) ? device.user_list.length : 0
|
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 'totalHammerStrikes': return stats.totalHammerStrikes ?? -1
|
||||||
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
|
case 'totalWarnings': return stats.totalWarningsGiven ?? -1
|
||||||
case 'totalMelodies': return device.device_melodies_all?.length ?? -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
|
default: return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortDevices(devices, key, dir) {
|
function sortDevices(devices, key, dir, metrics) {
|
||||||
if (!key) return devices
|
if (!key) return devices
|
||||||
return [...devices].sort((a, b) => {
|
return [...devices].sort((a, b) => {
|
||||||
const va = getSortValue(a, key)
|
const va = getSortValue(a, key, metrics)
|
||||||
const vb = getSortValue(b, key)
|
const vb = getSortValue(b, key, metrics)
|
||||||
if (va < vb) return dir === 'asc' ? -1 : 1
|
if (va < vb) return dir === 'asc' ? -1 : 1
|
||||||
if (va > vb) return dir === 'asc' ? 1 : -1
|
if (va > vb) return dir === 'asc' ? 1 : -1
|
||||||
return 0
|
return 0
|
||||||
@@ -199,6 +307,8 @@ export default function DeviceList() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
const [mqttStatusMap, setMqttStatusMap] = useState({})
|
||||||
const [mqttLoaded, setMqttLoaded] = useState(false)
|
const [mqttLoaded, setMqttLoaded] = useState(false)
|
||||||
|
const [latestDiagMap, setLatestDiagMap] = useState({})
|
||||||
|
const [latestPingMap, setLatestPingMap] = useState({})
|
||||||
|
|
||||||
// Server-side filters
|
// Server-side filters
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
@@ -291,6 +401,21 @@ export default function DeviceList() {
|
|||||||
}
|
}
|
||||||
setMqttLoaded(true)
|
setMqttLoaded(true)
|
||||||
}).catch(() => { 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) {
|
} catch (err) {
|
||||||
setError(err.message || 'Failed to load devices.')
|
setError(err.message || 'Failed to load devices.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -300,6 +425,54 @@ export default function DeviceList() {
|
|||||||
|
|
||||||
useEffect(() => { fetchDevices() }, [fetchDevices])
|
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(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
|
}, [search, onlineFilter, tierFilter, subscrStatusFilter, warrantyStatusFilter, hasClockFilter, hasBellsFilter])
|
||||||
@@ -340,7 +513,7 @@ export default function DeviceList() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir)
|
const sortedDevices = sortDevices(filteredDevices, sortKey, sortDir, { mqttStatusMap, latestDiagMap, latestPingMap })
|
||||||
const total = sortedDevices.length
|
const total = sortedDevices.length
|
||||||
const pagedDevices = sortedDevices.slice((page - 1) * pageSize, page * pageSize)
|
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 sn = device.serial_number || device.device_id
|
||||||
const mqtt = mqttStatusMap[sn]
|
const mqtt = mqttStatusMap[sn]
|
||||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
const isOnline = mqtt ? mqtt.online : device.is_Online
|
||||||
|
const hb = mqtt?.last_heartbeat
|
||||||
|
const diag = latestDiagMap[sn]
|
||||||
|
const ping = latestPingMap[sn]
|
||||||
|
|
||||||
switch (c.key) {
|
switch (c.key) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return <OnlineDot isOnline={isOnline} />
|
return <OnlineDot isOnline={isOnline} />
|
||||||
case 'name':
|
case 'name':
|
||||||
return (
|
return (
|
||||||
<span style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||||
{device.device_name || 'Unnamed Device'}
|
<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>
|
</span>
|
||||||
)
|
)
|
||||||
|
case 'appName':
|
||||||
|
return device.device_name || <Muted>—</Muted>
|
||||||
case 'serialNumber':
|
case 'serialNumber':
|
||||||
return (
|
return (
|
||||||
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-sm)' }}>
|
<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>
|
return device.hw_family || <Muted>—</Muted>
|
||||||
case 'hw_revision':
|
case 'hw_revision':
|
||||||
return device.hw_revision || <Muted>—</Muted>
|
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:
|
default:
|
||||||
return <Muted>—</Muted>
|
return <Muted>—</Muted>
|
||||||
}
|
}
|
||||||
@@ -493,6 +713,9 @@ export default function DeviceList() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Button variant="secondary" icon={<Icon name="warning" size={14} />} onClick={() => navigate('/devices/crashes')}>
|
||||||
|
Crash Report
|
||||||
|
</Button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button variant="primary" onClick={() => navigate('/devices/new')}>
|
<Button variant="primary" onClick={() => navigate('/devices/new')}>
|
||||||
Add Device
|
Add Device
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import Icon from '@/components/ui/Icon'
|
import Icon from '@/components/ui/Icon'
|
||||||
|
import SignalIndicator from '@/components/ui/SignalIndicator'
|
||||||
|
|
||||||
// ─── Tier config ──────────────────────────────────────────────────────────────
|
// ─── Tier config ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -194,13 +195,17 @@ function MapBackground({ coords }) {
|
|||||||
|
|
||||||
// ─── Online status block (rectangular, spans both identity rows) ──────────────
|
// ─── Online status block (rectangular, spans both identity rows) ──────────────
|
||||||
|
|
||||||
function OnlineBlock({ online }) {
|
function OnlineBlock({ online, rssi }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
|
className={`dcard__online-block ${online ? 'dcard__online-block--on' : 'dcard__online-block--off'}`}
|
||||||
aria-label={online ? 'Online' : 'Offline'}
|
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>
|
<span className="dcard__online-block-label">{online ? 'Online' : 'Offline'}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -241,7 +246,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
|
|||||||
const mqtt = mqttStatusMap[sn]
|
const mqtt = mqttStatusMap[sn]
|
||||||
const isOnline = mqtt ? mqtt.online : device.is_Online
|
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 location = device.device_location
|
||||||
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
|
const coords = useMemo(() => parseCoords(device.device_location_coordinates), [device.device_location_coordinates])
|
||||||
const tierLabel = getTierLabel(sub.subscrTier)
|
const tierLabel = getTierLabel(sub.subscrTier)
|
||||||
@@ -266,7 +271,7 @@ function DeviceCard({ device, mqttStatusMap, onView, onMiddleClick }) {
|
|||||||
|
|
||||||
{/* ── Header: status block + identity ── */}
|
{/* ── Header: status block + identity ── */}
|
||||||
<header className="dcard__header">
|
<header className="dcard__header">
|
||||||
<OnlineBlock online={isOnline} />
|
<OnlineBlock online={isOnline} rssi={mqtt?.last_heartbeat?.rssi} />
|
||||||
|
|
||||||
<div className="dcard__identity">
|
<div className="dcard__identity">
|
||||||
<div className="dcard__name-row">
|
<div className="dcard__name-row">
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
|
|||||||
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
|
onClick={(e) => { e.stopPropagation(); selectDevice(device) }}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
role="button"
|
role="button"
|
||||||
aria-label={`${device.device_name || 'Device'} — ${color.label}`}
|
aria-label={`${device.console_name || device.device_name || 'Device'} — ${color.label}`}
|
||||||
>
|
>
|
||||||
{/* Breathing ring */}
|
{/* Breathing ring */}
|
||||||
<circle
|
<circle
|
||||||
@@ -767,7 +767,7 @@ export default function DeviceListMapView({ devices, loading, mqttStatusMap, onV
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<h2 className="dmv-drawer__name">
|
<h2 className="dmv-drawer__name">
|
||||||
{selected.device_name || 'Unnamed Device'}
|
{selected.console_name || selected.device_name || 'Unnamed Device'}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
className="dmv-drawer__close"
|
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,
|
// 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.
|
// 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 api from '@/lib/api'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
import StatusBadge from '@/components/ui/StatusBadge'
|
||||||
import Icon from '@/components/ui/Icon'
|
import Icon from '@/components/ui/Icon'
|
||||||
@@ -91,7 +90,7 @@ const SPEC_TILE = {
|
|||||||
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
|
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
|
||||||
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
|
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
|
||||||
const strikerSize = getStrikerSize(hammingMs)
|
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 [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
|
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
|
||||||
@@ -272,23 +271,85 @@ export default function BellsTab({
|
|||||||
attr,
|
attr,
|
||||||
canEdit,
|
canEdit,
|
||||||
loadDevice,
|
loadDevice,
|
||||||
|
reloadDeviceSilent,
|
||||||
sendMqttCommand,
|
sendMqttCommand,
|
||||||
|
sendMqttCommandSilent,
|
||||||
onEditBellOutputs,
|
onEditBellOutputs,
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
const [togglingCommander, setTogglingCommander] = useState(false)
|
const [togglingCommander, setTogglingCommander] = useState(false)
|
||||||
const [requestingCounters, setRequestingCounters] = useState(false)
|
const [requestingCounters, setRequestingCounters] = useState(false)
|
||||||
const [liveCounters] = useState(null)
|
const [liveCounters] = useState(null)
|
||||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
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 bellOutputs = attr?.bellOutputs || []
|
||||||
const hammerTimings = attr?.hammerTimings || []
|
const hammerTimings = attr?.hammerTimings || []
|
||||||
const totalBells = attr?.totalBells || 0
|
const totalBells = attr?.totalBells || 0
|
||||||
const hasBells = attr?.hasBells ?? false
|
const hasBells = attr?.hasBells ?? false
|
||||||
const bellGuardOn = attr?.bellGuardOn ?? 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
|
// Request strike counters via MQTT
|
||||||
const requestStrikeCounters = useCallback(async () => {
|
const requestStrikeCounters = useCallback(async () => {
|
||||||
if (!sendMqttCommand) return
|
if (!sendMqttCommand) return
|
||||||
@@ -301,20 +362,19 @@ export default function BellsTab({
|
|||||||
}
|
}
|
||||||
}, [sendMqttCommand])
|
}, [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) => {
|
const handleToggleCommander = async (newVal) => {
|
||||||
setTogglingCommander(true)
|
setTogglingCommander(true)
|
||||||
try {
|
try {
|
||||||
|
await sendMqttCommand(newVal ? 'bells.enable' : 'bells.disable')
|
||||||
await api.put(`/devices/${device.id || device.device_id}`, {
|
await api.put(`/devices/${device.id || device.device_id}`, {
|
||||||
device_attributes: { hasBells: newVal },
|
device_attributes: { hasBells: newVal },
|
||||||
})
|
})
|
||||||
await loadDevice()
|
await loadDevice()
|
||||||
toast.success(
|
} catch {
|
||||||
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
|
// sendMqttCommand's toast already reported the failure — nothing further to do.
|
||||||
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.')
|
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingCommander(false)
|
setTogglingCommander(false)
|
||||||
}
|
}
|
||||||
@@ -495,7 +555,7 @@ export default function BellsTab({
|
|||||||
setConfirmDisable(false)
|
setConfirmDisable(false)
|
||||||
handleToggleCommander(false)
|
handleToggleCommander(false)
|
||||||
}}
|
}}
|
||||||
onCancel={() => setConfirmDisable(false)}
|
onClose={() => setConfirmDisable(false)}
|
||||||
loading={togglingCommander}
|
loading={togglingCommander}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import api from '@/lib/api'
|
|||||||
import Card from '@/components/ui/Card'
|
import Card from '@/components/ui/Card'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
import StatusBadge from '@/components/ui/StatusBadge'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
|
||||||
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
|
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
@@ -45,6 +44,16 @@ function silenceLabel(isOn, from, to) {
|
|||||||
return 'On'
|
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 ────────────────────────────────────────────────────────
|
// ─── Analog Clock Face ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AnalogClock({ time, label, variant = 'primary' }) {
|
function AnalogClock({ time, label, variant = 'primary' }) {
|
||||||
@@ -261,19 +270,111 @@ export default function ClockTab({
|
|||||||
onEditClockSettings,
|
onEditClockSettings,
|
||||||
onEditAlerts,
|
onEditAlerts,
|
||||||
onEditBacklight,
|
onEditBacklight,
|
||||||
onDeviceUpdated,
|
sendMqttCommand,
|
||||||
|
sendMqttCommandSilent,
|
||||||
|
loadDevice,
|
||||||
|
reloadDeviceSilent,
|
||||||
}) {
|
}) {
|
||||||
const [now, setNow] = useState(new Date())
|
const [now, setNow] = useState(new Date())
|
||||||
const [showSync, setShowSync] = useState(false)
|
const [showSync, setShowSync] = useState(false)
|
||||||
const [flashFields, setFlashFields] = useState(false)
|
const [flashFields, setFlashFields] = useState(false)
|
||||||
const [togglingClock, setTogglingClock] = useState(false)
|
const [togglingClock, setTogglingClock] = useState(false)
|
||||||
const { toast } = useToast()
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setInterval(() => setNow(new Date()), 1000)
|
const id = setInterval(() => setNow(new Date()), 1000)
|
||||||
return () => clearInterval(id)
|
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 ────────────────────────────────────────────────────────
|
// ── Derived values ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const hasClock = attr?.hasClock ?? false
|
const hasClock = attr?.hasClock ?? false
|
||||||
@@ -285,7 +386,7 @@ export default function ClockTab({
|
|||||||
const pauseTime = clockTimings[1]
|
const pauseTime = clockTimings[1]
|
||||||
|
|
||||||
const alertType = clock?.ringAlerts || 'disabled'
|
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 ringIntervals = clock?.ringIntervals
|
||||||
const hourBell = clock?.hourAlertsBell || 0
|
const hourBell = clock?.hourAlertsBell || 0
|
||||||
const halfBell = clock?.halfhourAlertsBell || 0
|
const halfBell = clock?.halfhourAlertsBell || 0
|
||||||
@@ -298,9 +399,9 @@ export default function ClockTab({
|
|||||||
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
|
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
|
||||||
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
|
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
|
||||||
|
|
||||||
const timezone = attr?.timezone || device?.timezone || null
|
const timezone = formatUtcOffset(clock?.gmtOffsetSec)
|
||||||
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
|
const ntpServer = clock?.ntpServer || null
|
||||||
const autoDst = attr?.autoDST ?? false
|
const dstActive = !!clock?.dstOffsetSec
|
||||||
|
|
||||||
const alertTypeLabel =
|
const alertTypeLabel =
|
||||||
alertType === 'single' ? 'Single Fire' :
|
alertType === 'single' ? 'Single Fire' :
|
||||||
@@ -312,6 +413,9 @@ export default function ClockTab({
|
|||||||
// All 4 hero fields must be set before ENABLING the master switch
|
// All 4 hero fields must be set before ENABLING the master switch
|
||||||
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
|
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 () => {
|
const handleMasterToggleClick = async () => {
|
||||||
if (!canEdit || togglingClock) return
|
if (!canEdit || togglingClock) return
|
||||||
|
|
||||||
@@ -326,23 +430,13 @@ export default function ClockTab({
|
|||||||
const newValue = !hasClock
|
const newValue = !hasClock
|
||||||
setTogglingClock(true)
|
setTogglingClock(true)
|
||||||
try {
|
try {
|
||||||
const updated = await api.put(`/devices/${deviceId}`, {
|
await sendMqttCommand(newValue ? 'clock.enable' : 'clock.disable')
|
||||||
device_attributes: { ...(attr || {}), hasClock: newValue },
|
await api.put(`/devices/${deviceId}`, {
|
||||||
|
device_attributes: { hasClock: newValue },
|
||||||
})
|
})
|
||||||
if (onDeviceUpdated) {
|
await loadDevice?.()
|
||||||
// If API returns the full device object, use it; otherwise patch manually
|
} catch {
|
||||||
if (updated?.device_attributes !== undefined) {
|
// sendMqttCommand's toast already reported the failure — nothing further to do.
|
||||||
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.')
|
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingClock(false)
|
setTogglingClock(false)
|
||||||
}
|
}
|
||||||
@@ -607,28 +701,14 @@ export default function ClockTab({
|
|||||||
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
|
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
|
||||||
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
|
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Auto-DST"
|
label="DST"
|
||||||
badge={
|
badge={<StatusBadge variant={dstActive ? 'success' : 'neutral'}>{dstActive ? 'Active' : 'Not Active'}</StatusBadge>}
|
||||||
<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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
|
<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)' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,691 @@
|
|||||||
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
|
// 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 }) {
|
import { useState, useEffect, useRef } from 'react'
|
||||||
return null
|
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 = 'rgba(28, 32, 38, 0.30)'
|
||||||
const GLASS_BG_INNER = 'rgba(28, 32, 38, 0.40)'
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function parseCoordinates(coordStr) {
|
function parseCoordinates(coordStr) {
|
||||||
@@ -851,7 +854,9 @@ export default function GeneralTab({
|
|||||||
setTags,
|
setTags,
|
||||||
onEditLocation,
|
onEditLocation,
|
||||||
onEditAttributes,
|
onEditAttributes,
|
||||||
onEditLogging,
|
sendMqttCommand,
|
||||||
|
sendMqttCommandSilent,
|
||||||
|
reloadDeviceSilent,
|
||||||
}) {
|
}) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [locationName, setLocationName] = useState(null)
|
const [locationName, setLocationName] = useState(null)
|
||||||
@@ -874,6 +879,63 @@ export default function GeneralTab({
|
|||||||
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
|
if (Number.isFinite(attr?.mqttLogLevel)) setMqttLevel(attr.mqttLogLevel)
|
||||||
}, [attr?.serialLogLevel, attr?.sdLogLevel, 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
|
// Nearest place
|
||||||
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
|
const coords = useMemo(() => parseCoordinates(device?.device_location_coordinates), [device?.device_location_coordinates])
|
||||||
|
|
||||||
@@ -886,11 +948,6 @@ export default function GeneralTab({
|
|||||||
.catch(() => setLocationName(null))
|
.catch(() => setLocationName(null))
|
||||||
}, [coords])
|
}, [coords])
|
||||||
|
|
||||||
const net = attr?.networkSettings || {}
|
|
||||||
const staticIpAddress = Array.isArray(net.ipAddress)
|
|
||||||
? net.ipAddress.filter(Boolean).join('.')
|
|
||||||
: (net.ipAddress || '')
|
|
||||||
|
|
||||||
// ── Tag handlers ────────────────────────────────────────────────────────────
|
// ── Tag handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleAddTag = useCallback(async (tag) => {
|
const handleAddTag = useCallback(async (tag) => {
|
||||||
@@ -921,34 +978,43 @@ export default function GeneralTab({
|
|||||||
}
|
}
|
||||||
}, [id, tags, setTags, toast])
|
}, [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) => {
|
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 {
|
try {
|
||||||
|
await sendMqttCommand(LOG_CMD[field], { level: value })
|
||||||
await api.put(`/devices/${id}`, {
|
await api.put(`/devices/${id}`, {
|
||||||
device_attributes: {
|
device_attributes: { [LOG_ATTR[field]]: value },
|
||||||
...(attr || {}),
|
|
||||||
serialLogLevel: field === 'serial' ? value : serialLevel,
|
|
||||||
sdLogLevel: field === 'sd' ? value : sdLevel,
|
|
||||||
mqttLogLevel: field === 'mqtt' ? value : mqttLevel,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
toast.success('Saved', 'Log levels updated.')
|
} catch {
|
||||||
} catch (err) {
|
// sendMqttCommand's toast already reported the failure — revert the slider.
|
||||||
toast.danger('Error', err.message || 'Failed to save log levels.')
|
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 = [
|
const netFields = [
|
||||||
{ label: 'Hostname', value: net.hostname || '—' },
|
{ label: 'Hostname', value: networkInfo?.hostname || '—' },
|
||||||
{ label: 'IP Address', value: staticIpAddress || '—' },
|
{ label: 'IP Address', value: networkInfo?.ip || '—' },
|
||||||
{ label: 'Gateway', value: net.gateway || '—' },
|
{ label: 'Gateway', value: networkInfo?.gateway || '—' },
|
||||||
{ label: 'DNS', value: net.dns || '—' },
|
{ label: 'DNS', value: networkInfo?.dns || '—' },
|
||||||
{ label: 'Subnet', value: net.subnet || '—' },
|
{ label: 'Subnet', value: networkInfo?.subnet || '—' },
|
||||||
{ label: 'MAC Address', value: net.mac || net.macAddress || '—' },
|
{ label: 'MAC Address', value: networkInfo?.mac || '—' },
|
||||||
|
{ label: 'SSID', value: networkInfo?.ssid || '—' },
|
||||||
|
{ label: 'Connection', value: networkInfo?.connection_type || '—' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── Render ──────────────────────────────────────────────────────────────────
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
@@ -956,6 +1022,116 @@ export default function GeneralTab({
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
|
<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
|
ROW 1 — Map (2/3) + Safety Settings (1/3) equal height
|
||||||
════════════════════════════════════════════════════════════════════ */}
|
════════════════════════════════════════════════════════════════════ */}
|
||||||
@@ -1013,9 +1189,6 @@ export default function GeneralTab({
|
|||||||
<GlassCard
|
<GlassCard
|
||||||
title="Mechanisms"
|
title="Mechanisms"
|
||||||
fullHeight
|
fullHeight
|
||||||
titleAction={canEdit && (
|
|
||||||
<EditActionButton onClick={onEditAttributes} />
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
|
||||||
|
|
||||||
@@ -1058,9 +1231,6 @@ export default function GeneralTab({
|
|||||||
<GlassCard
|
<GlassCard
|
||||||
title="Log Settings"
|
title="Log Settings"
|
||||||
fullHeight
|
fullHeight
|
||||||
titleAction={canEdit && (
|
|
||||||
<EditActionButton onClick={onEditLogging} />
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', flex: 1, justifyContent: 'center' }}>
|
||||||
<LogSlider
|
<LogSlider
|
||||||
@@ -1091,9 +1261,6 @@ export default function GeneralTab({
|
|||||||
<GlassCard
|
<GlassCard
|
||||||
title="Network Info"
|
title="Network Info"
|
||||||
fullHeight
|
fullHeight
|
||||||
titleAction={canEdit && (
|
|
||||||
<EditActionButton onClick={onEditAttributes} />
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'grid',
|
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"
|
defaultType="issue"
|
||||||
prefilledLinks={issueModal.entry
|
prefilledLinks={issueModal.entry
|
||||||
? undefined
|
? 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 })}
|
onClose={() => setIssueModal({ open: false, entry: null })}
|
||||||
onSaved={handleIssueSaved}
|
onSaved={handleIssueSaved}
|
||||||
onDelete={canEdit ? (entryId) => {
|
onDelete={canEdit ? (entryId) => {
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import Button from '@/components/ui/Button'
|
|||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
import StatusBadge from '@/components/ui/StatusBadge'
|
||||||
import Spinner from '@/components/ui/Spinner'
|
import Spinner from '@/components/ui/Spinner'
|
||||||
import Icon from '@/components/ui/Icon'
|
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 { useMqttWebSocket } from '@/hooks/useMqttWebSocket'
|
||||||
import {
|
import {
|
||||||
parseFirestoreDate,
|
parseFirestoreDate,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
} from './shared'
|
} from './shared'
|
||||||
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
|
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
|
||||||
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
|
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
|
||||||
|
import CopyableSerial from '@/components/shared/CopyableSerial'
|
||||||
|
|
||||||
// ─── Glass surface tokens ─────────────────────────────────────────────────────
|
// ─── Glass surface tokens ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -52,6 +54,23 @@ function subscrDurationLabel(days) {
|
|||||||
return `${days}d`
|
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 ───────────────────────────────────────────────────────────
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GlassCard({ children, style = {}, className = '' }) {
|
function GlassCard({ children, style = {}, className = '' }) {
|
||||||
@@ -146,6 +165,14 @@ const LOG_COLORS = {
|
|||||||
INFO: { text: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
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
|
// Level slider steps: 0=All, 1=INFO, 2=WARN, 3=ERROR
|
||||||
const LEVEL_STEPS = [
|
const LEVEL_STEPS = [
|
||||||
{ value: '', label: 'ALL', color: 'var(--color-text-muted)' },
|
{ 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 [issueModal, setIssueModal] = useState({ open: false, entry: null })
|
||||||
const [viewNote, setViewNote] = useState(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 = () => {
|
const handleNoteSaved = () => {
|
||||||
setNoteModal({ open: false })
|
setNoteModal({ open: false })
|
||||||
loadNotes()
|
loadNotes()
|
||||||
@@ -277,43 +322,52 @@ export default function OverviewTab({
|
|||||||
|
|
||||||
// ── Live logs ─────────────────────────────────────────────────────────────
|
// ── Live logs ─────────────────────────────────────────────────────────────
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [logsLoading, setLogsLoading] = useState(false)
|
const [logsInitialLoading, setLogsInitialLoading] = useState(true)
|
||||||
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
|
const [levelStep, setLevelStep] = useState(0) // index into LEVEL_STEPS
|
||||||
const [searchText, setSearchText] = useState('')
|
const [searchText, setSearchText] = useState('')
|
||||||
const [autoScroll, setAutoScroll] = useState(true)
|
const [autoScroll, setAutoScroll] = useState(true)
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false)
|
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||||
const [liveLogs, setLiveLogs] = useState([])
|
const [liveLogs, setLiveLogs] = useState([])
|
||||||
const logsContainerRef = useRef(null)
|
const logsContainerRef = useRef(null)
|
||||||
|
const logsHasLoadedOnce = useRef(false)
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
const levelFilter = LEVEL_STEPS[levelStep].value
|
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
|
if (!sn) return
|
||||||
setLogsLoading(true)
|
if (!background && !logsHasLoadedOnce.current) setLogsInitialLoading(true)
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ limit: LIMIT, offset: 0 })
|
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)
|
if (searchText) params.set('search', searchText)
|
||||||
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
|
const data = await api.get(`/mqtt/logs/${sn}?${params}`)
|
||||||
setLogs(data.logs || [])
|
setLogs(data.logs || [])
|
||||||
} catch {
|
} catch {
|
||||||
// silent
|
// silent
|
||||||
} finally {
|
} finally {
|
||||||
setLogsLoading(false)
|
logsHasLoadedOnce.current = true
|
||||||
|
setLogsInitialLoading(false)
|
||||||
}
|
}
|
||||||
}, [sn, levelFilter, searchText])
|
}, [sn, levelFilter, searchText])
|
||||||
|
|
||||||
useEffect(() => { if (sn) fetchLogs() }, [sn, fetchLogs])
|
useEffect(() => { if (sn) fetchLogs(false) }, [sn, fetchLogs])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefresh || !sn) return
|
if (!autoRefresh || !sn) return
|
||||||
const t = setInterval(fetchLogs, 5000)
|
const t = setInterval(() => fetchLogs(true), 5000)
|
||||||
return () => clearInterval(t)
|
return () => clearInterval(t)
|
||||||
}, [autoRefresh, sn, fetchLogs])
|
}, [autoRefresh, sn, fetchLogs])
|
||||||
|
|
||||||
const handleWsMessage = useCallback((data) => {
|
const handleWsMessage = useCallback((data) => {
|
||||||
if (data.type === 'logs' && data.device_serial === sn) {
|
if (data.type === 'system/logs' && data.device_serial === sn) {
|
||||||
const entry = {
|
const entry = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
|
level: data.payload?.level?.includes('EROR') ? 'ERROR' :
|
||||||
@@ -330,18 +384,24 @@ export default function OverviewTab({
|
|||||||
|
|
||||||
const allLogs = [
|
const allLogs = [
|
||||||
...liveLogs.filter(l => {
|
...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
|
if (searchText && !l.message.toLowerCase().includes(searchText.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
...logs,
|
...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(() => {
|
useEffect(() => {
|
||||||
if (autoScroll && logsContainerRef.current) {
|
if (liveLogCount > prevLiveLogCount.current && autoScroll && logsContainerRef.current) {
|
||||||
logsContainerRef.current.scrollTop = 0
|
logsContainerRef.current.scrollTop = 0
|
||||||
}
|
}
|
||||||
}, [allLogs.length, autoScroll])
|
prevLiveLogCount.current = liveLogCount
|
||||||
|
}, [liveLogCount, autoScroll])
|
||||||
|
|
||||||
// ── Service lifecycle ─────────────────────────────────────────────────────
|
// ── Service lifecycle ─────────────────────────────────────────────────────
|
||||||
const subscrStart = parseFirestoreDate(sub?.subscrStart)
|
const subscrStart = parseFirestoreDate(sub?.subscrStart)
|
||||||
@@ -429,68 +489,98 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Section B: Hardware info ── */}
|
{/* ── Section B+D: Device info grid (Serial/HW/Uptime/Location/Rev/Issue) ── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: 1,
|
flex: 2,
|
||||||
padding: 'var(--space-6)',
|
padding: 'var(--space-6)',
|
||||||
|
marginLeft: 'var(--space-4)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 'var(--space-4)',
|
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
}}>
|
}} className="overview-hero-grid-wrap">
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-5) var(--space-8)', maxWidth: 480 }}>
|
<div className="overview-hero-grid" style={{
|
||||||
<InfoField label="Serial Number" mono>{sn || '—'}</InfoField>
|
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="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>
|
<InfoField label="Hardware Revision">{hwRevision || device.device_attributes?.firmwareVersion || '—'}</InfoField>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', minWidth: 0 }}>
|
||||||
{/* ── Section D: Uptime + Latest Issue ── */}
|
<span style={{
|
||||||
<div style={{
|
fontSize: 'var(--font-size-xs)',
|
||||||
width: 220,
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
flexShrink: 0,
|
color: 'var(--color-text-more-muted)',
|
||||||
padding: 'var(--space-6)',
|
letterSpacing: 'var(--tracking-wide)',
|
||||||
display: 'flex',
|
textTransform: 'uppercase',
|
||||||
flexDirection: 'column',
|
}}>
|
||||||
justifyContent: 'center',
|
Latest Device Issue
|
||||||
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
|
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -930,7 +1020,7 @@ export default function OverviewTab({
|
|||||||
{LEVEL_STEPS.map((step, idx) => (
|
{LEVEL_STEPS.map((step, idx) => (
|
||||||
<button
|
<button
|
||||||
key={step.value}
|
key={step.value}
|
||||||
onClick={() => { setLevelStep(idx); setLogs([]) }}
|
onClick={() => setLevelStep(idx)}
|
||||||
title={step.label}
|
title={step.label}
|
||||||
style={{
|
style={{
|
||||||
height: 24,
|
height: 24,
|
||||||
@@ -1020,10 +1110,10 @@ export default function OverviewTab({
|
|||||||
AUTO-SYNC
|
AUTO-SYNC
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={fetchLogs}
|
onClick={() => fetchLogs(true)}
|
||||||
disabled={logsLoading}
|
|
||||||
style={{
|
style={{
|
||||||
height: 28,
|
height: 28,
|
||||||
paddingInline: 'var(--space-3)',
|
paddingInline: 'var(--space-3)',
|
||||||
@@ -1033,24 +1123,19 @@ export default function OverviewTab({
|
|||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
fontSize: 'var(--font-size-xs)',
|
fontSize: 'var(--font-size-xs)',
|
||||||
fontWeight: 'var(--font-weight-semibold)',
|
fontWeight: 'var(--font-weight-semibold)',
|
||||||
cursor: logsLoading ? 'not-allowed' : 'pointer',
|
cursor: 'pointer',
|
||||||
letterSpacing: 'var(--tracking-wide)',
|
letterSpacing: 'var(--tracking-wide)',
|
||||||
fontFamily: 'var(--font-family-base)',
|
fontFamily: 'var(--font-family-base)',
|
||||||
opacity: logsLoading ? 0.5 : 1,
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 'var(--space-1)',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{logsLoading ? <Spinner size="sm" /> : null}
|
|
||||||
REFRESH
|
REFRESH
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Log rows */}
|
{/* Log rows */}
|
||||||
<div ref={logsContainerRef} style={{ maxHeight: 320, overflowY: 'auto', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)' }}>
|
<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)' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
</div>
|
</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' }}>
|
<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 && (
|
{log._live && (
|
||||||
<span style={{ marginLeft: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', fontWeight: 'var(--font-weight-semibold)' }}>LIVE</span>
|
<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}
|
{log.level}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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}
|
{log.message}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1134,6 +1219,12 @@ export default function OverviewTab({
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.overview-row2 { grid-template-columns: 1fr !important; }
|
.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 {
|
@keyframes ov-pulse {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.35; }
|
50% { opacity: 0.35; }
|
||||||
@@ -1160,8 +1251,8 @@ export default function OverviewTab({
|
|||||||
open={issueModal.open}
|
open={issueModal.open}
|
||||||
entry={issueModal.entry}
|
entry={issueModal.entry}
|
||||||
defaultType="issue"
|
defaultType="issue"
|
||||||
prefilledLinks={issueModal.entry ? undefined : [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]}
|
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?.device_name || sn } : undefined}
|
knownEntities={id ? { [id]: device?.console_name || device?.device_name || sn } : undefined}
|
||||||
onClose={() => setIssueModal({ open: false, entry: null })}
|
onClose={() => setIssueModal({ open: false, entry: null })}
|
||||||
onSaved={handleIssueSaved}
|
onSaved={handleIssueSaved}
|
||||||
/>
|
/>
|
||||||
@@ -1224,6 +1315,92 @@ export default function OverviewTab({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -820,7 +820,7 @@ export default function UserDetail() {
|
|||||||
<option value="">Choose a device…</option>
|
<option value="">Choose a device…</option>
|
||||||
{availableDevices.map((d) => (
|
{availableDevices.map((d) => (
|
||||||
<option key={d.id} value={d.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -848,7 +848,7 @@ export default function UserDetail() {
|
|||||||
onClick={() => navigate(`/devices/${device.id}`)}
|
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' }}
|
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>
|
</button>
|
||||||
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
|
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: '2px' }}>
|
||||||
{device.device_id || device.id}
|
{device.device_id || device.id}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function StatusToggle({ value, onChange }) {
|
|||||||
|
|
||||||
function PhotoField({ value, onChange, userId }) {
|
function PhotoField({ value, onChange, userId }) {
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const { toast } = useToast()
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ function EyeToggle({ show, onToggle }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PasswordCard({ userId, hasUid }) {
|
function PasswordCard({ userId, hasUid }) {
|
||||||
const toast = useToast()
|
const { toast } = useToast()
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const [confirm, setConfirm] = useState('')
|
const [confirm, setConfirm] = useState('')
|
||||||
const [showPass, setShowPass] = useState(false)
|
const [showPass, setShowPass] = useState(false)
|
||||||
@@ -302,9 +302,9 @@ function PasswordCard({ userId, hasUid }) {
|
|||||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function UserForm() {
|
export default function UserForm() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const toast = useToast()
|
const { toast } = useToast()
|
||||||
const isEdit = Boolean(id)
|
const isEdit = Boolean(id)
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -317,7 +317,9 @@ export default function UserForm() {
|
|||||||
userTitle: '',
|
userTitle: '',
|
||||||
settingsPIN: '',
|
settingsPIN: '',
|
||||||
quickSettingsPIN: '',
|
quickSettingsPIN: '',
|
||||||
|
password: '',
|
||||||
})
|
})
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('')
|
||||||
const [uid, setUid] = useState('')
|
const [uid, setUid] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -355,6 +357,10 @@ export default function UserForm() {
|
|||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
if (!isEdit && form.password !== confirmPassword) {
|
||||||
|
setError('Passwords do not match.')
|
||||||
|
return
|
||||||
|
}
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -435,6 +441,30 @@ export default function UserForm() {
|
|||||||
placeholder="+1 234 567 8900"
|
placeholder="+1 234 567 8900"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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')} />
|
<StatusToggle value={form.status} onChange={setVal('status')} />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ function LinkedDevicesModal({ userId, userName, open, onClose }) {
|
|||||||
<Icon name="device" size={14} color="var(--color-primary)" />
|
<Icon name="device" size={14} color="var(--color-primary)" />
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<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>
|
</div>
|
||||||
{d.device_id && (
|
{d.device_id && (
|
||||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)', fontFamily: 'var(--font-family-mono)' }}>
|
<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 Button from '@/components/ui/Button'
|
||||||
import RowActions from '@/components/ui/RowActions'
|
import RowActions from '@/components/ui/RowActions'
|
||||||
import StatusBadge from '@/components/ui/StatusBadge'
|
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 FormField from '@/components/ui/FormField'
|
||||||
import Modal from '@/components/ui/Modal'
|
import Modal from '@/components/ui/Modal'
|
||||||
import DataTable from '@/components/ui/DataTable'
|
import DataTable from '@/components/ui/DataTable'
|
||||||
@@ -18,6 +24,7 @@ import Tabs from '@/components/ui/Tabs'
|
|||||||
import { ToastProvider, useToast } from '@/components/ui/Toast'
|
import { ToastProvider, useToast } from '@/components/ui/Toast'
|
||||||
import SearchBar from '@/components/ui/SearchBar'
|
import SearchBar from '@/components/ui/SearchBar'
|
||||||
import Select from '@/components/ui/Select'
|
import Select from '@/components/ui/Select'
|
||||||
|
import MultiSelect from '@/components/ui/MultiSelect'
|
||||||
import HeaderSearch from '@/components/ui/HeaderSearch'
|
import HeaderSearch from '@/components/ui/HeaderSearch'
|
||||||
import Breadcrumbs from '@/components/ui/Breadcrumbs'
|
import Breadcrumbs from '@/components/ui/Breadcrumbs'
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||||
@@ -42,6 +49,35 @@ function urlMapToList(map) {
|
|||||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
}).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 = [
|
const ASSET_ICON_GROUPS = [
|
||||||
{ title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
|
{ title: 'Global Icons', items: urlMapToList(_globalIconUrls) },
|
||||||
{ title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
|
{ title: 'Side Menu Icons', items: urlMapToList(_sideMenuIconUrls) },
|
||||||
@@ -404,7 +440,11 @@ export default function StyleGuide() {
|
|||||||
const [modalSize, setModalSize] = useState('md')
|
const [modalSize, setModalSize] = useState('md')
|
||||||
const [paginationPage, setPaginationPage] = useState(3)
|
const [paginationPage, setPaginationPage] = useState(3)
|
||||||
const [paginationSize, setPaginationSize] = useState(20)
|
const [paginationSize, setPaginationSize] = useState(20)
|
||||||
|
const [demoRange, setDemoRange] = useState(() => resolveRange('7d'))
|
||||||
const [formValues, setFormValues] = useState({ text: '', email: '', select: '', textarea: '' })
|
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
|
// DataTable interactive demo state
|
||||||
const [tableSortKey, setTableSortKey] = useState('')
|
const [tableSortKey, setTableSortKey] = useState('')
|
||||||
@@ -499,7 +539,13 @@ export default function StyleGuide() {
|
|||||||
['#spacing', 'Spacing'],
|
['#spacing', 'Spacing'],
|
||||||
['#buttons', 'Button'],
|
['#buttons', 'Button'],
|
||||||
['#badges', 'StatusBadge'],
|
['#badges', 'StatusBadge'],
|
||||||
|
['#signal-indicator', 'SignalIndicator'],
|
||||||
|
['#editable-text', 'EditableText'],
|
||||||
|
['#line-chart', 'LineChart'],
|
||||||
|
['#press-hold-button', 'PressHoldButton'],
|
||||||
|
['#time-range-select', 'TimeRangeSelect'],
|
||||||
['#formfields', 'FormField'],
|
['#formfields', 'FormField'],
|
||||||
|
['#multiselect', 'MultiSelect'],
|
||||||
['#spinner', 'Spinner'],
|
['#spinner', 'Spinner'],
|
||||||
['#datatable', 'DataTable'],
|
['#datatable', 'DataTable'],
|
||||||
['#rowactions', 'RowActions'],
|
['#rowactions', 'RowActions'],
|
||||||
@@ -1008,6 +1054,202 @@ export default function StyleGuide() {
|
|||||||
|
|
||||||
</Section>
|
</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
|
5b. PILL BUTTON
|
||||||
================================================================ */}
|
================================================================ */}
|
||||||
@@ -1281,6 +1523,45 @@ export default function StyleGuide() {
|
|||||||
|
|
||||||
</Section>
|
</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
|
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 [changelog, setChangelog] = useState(initial?.changelog ?? '')
|
||||||
const [releaseNote, setReleaseNote] = useState(initial?.release_note ?? '')
|
const [releaseNote, setReleaseNote] = useState(initial?.release_note ?? '')
|
||||||
const [file, setFile] = useState(null)
|
const [file, setFile] = useState(null)
|
||||||
|
const [elfFile, setElfFile] = useState(null)
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
const elfInputRef = useRef(null)
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -130,12 +132,14 @@ function FirmwareFormModal({ initial, onClose, onSaved }) {
|
|||||||
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
||||||
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
||||||
fd.append('file', file)
|
fd.append('file', file)
|
||||||
|
if (elfFile) fd.append('elf_file', elfFile)
|
||||||
} else {
|
} else {
|
||||||
fd.append('channel', channel); fd.append('version', version)
|
fd.append('channel', channel); fd.append('version', version)
|
||||||
fd.append('update_type', updateType); fd.append('min_fw_version', minFw)
|
fd.append('update_type', updateType); fd.append('min_fw_version', minFw)
|
||||||
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
||||||
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
||||||
if (file) fd.append('file', file)
|
if (file) fd.append('file', file)
|
||||||
|
if (elfFile) fd.append('elf_file', elfFile)
|
||||||
}
|
}
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
const url = isEdit ? `/api/firmware/${initial.id}` : '/api/firmware/upload'
|
const url = isEdit ? `/api/firmware/${initial.id}` : '/api/firmware/upload'
|
||||||
@@ -274,6 +278,30 @@ function FirmwareFormModal({ initial, onClose, onSaved }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import PageHeader from '@/components/ui/PageHeader'
|
import PageHeader from '@/components/ui/PageHeader'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import Card from '@/components/ui/Card'
|
import Card from '@/components/ui/Card'
|
||||||
@@ -15,6 +16,7 @@ import FormField from '@/components/ui/FormField'
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||||
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
|
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
|
||||||
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
||||||
|
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -455,6 +457,7 @@ export default function DeviceInventoryDetail() {
|
|||||||
const { sn } = useParams()
|
const { sn } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { hasPermission } = useAuth()
|
const { hasPermission } = useAuth()
|
||||||
|
const { toast } = useToast()
|
||||||
const canEdit = hasPermission('manufacturing', 'edit')
|
const canEdit = hasPermission('manufacturing', 'edit')
|
||||||
const canDelete = hasPermission('manufacturing', 'delete')
|
const canDelete = hasPermission('manufacturing', 'delete')
|
||||||
|
|
||||||
@@ -462,13 +465,19 @@ export default function DeviceInventoryDetail() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [assignedCustomer, setAssignedCustomer] = useState(null)
|
const [assignedCustomer, setAssignedCustomer] = useState(null)
|
||||||
const [resolvedUsers, setResolvedUsers] = useState([])
|
const [deviceUsers, setDeviceUsers] = useState([])
|
||||||
|
const [usersLoading, setUsersLoading] = useState(false)
|
||||||
|
|
||||||
// Assignment
|
// Assignment
|
||||||
const [showCustomerModal, setShowCustomerModal] = useState(false)
|
const [showCustomerModal, setShowCustomerModal] = useState(false)
|
||||||
const [assignSaving, setAssignSaving] = useState(false)
|
const [assignSaving, setAssignSaving] = useState(false)
|
||||||
const [assignError, setAssignError] = useState('')
|
const [assignError, setAssignError] = useState('')
|
||||||
|
|
||||||
|
// User assignment
|
||||||
|
const [showAddUser, setShowAddUser] = useState(false)
|
||||||
|
const [removingUser, setRemovingUser] = useState(null)
|
||||||
|
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
const [statusSaving, setStatusSaving] = useState(false)
|
const [statusSaving, setStatusSaving] = useState(false)
|
||||||
const [statusError, setStatusError] = useState('')
|
const [statusError, setStatusError] = useState('')
|
||||||
@@ -493,17 +502,6 @@ export default function DeviceInventoryDetail() {
|
|||||||
} else {
|
} else {
|
||||||
setAssignedCustomer(null)
|
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) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -513,6 +511,42 @@ export default function DeviceInventoryDetail() {
|
|||||||
|
|
||||||
useEffect(() => { loadDevice() }, [loadDevice])
|
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 ───────────────────────────────────────────────────────
|
// ─── Status change ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleStatusChange = async (newStatus) => {
|
const handleStatusChange = async (newStatus) => {
|
||||||
@@ -612,7 +646,7 @@ export default function DeviceInventoryDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
|
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 editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
|
||||||
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
|
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
|
||||||
@@ -742,19 +776,30 @@ export default function DeviceInventoryDetail() {
|
|||||||
|
|
||||||
{/* User Assignment sub-section */}
|
{/* User Assignment sub-section */}
|
||||||
<div>
|
<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)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2)' }}>
|
||||||
Assigned Users {userList.length > 0 && `(${userList.length})`}
|
<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 }}>
|
||||||
</p>
|
Assigned Users {deviceUsers.length > 0 && `(${deviceUsers.length})`}
|
||||||
{userList.length === 0 ? (
|
</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)' }}>
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||||
No users assigned to this device.
|
No users assigned to this device.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||||
{userList.map((uid) => {
|
{deviceUsers.map((user) => {
|
||||||
const resolved = resolvedUsers.find((u) => u.uid === uid)
|
const uid = user.user_id
|
||||||
const displayName = resolved?.display_name || ''
|
const displayName = user.display_name || ''
|
||||||
const email = resolved?.email || ''
|
const email = user.email || ''
|
||||||
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -792,6 +837,24 @@ export default function DeviceInventoryDetail() {
|
|||||||
}
|
}
|
||||||
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
||||||
</div>
|
</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)" />
|
<Icon name="chevron-right" size={14} color="var(--color-text-muted)" />
|
||||||
</div>
|
</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 && (
|
{editModalData && editStepMeta && (
|
||||||
<LifecycleEditModal
|
<LifecycleEditModal
|
||||||
open={editModalData !== null}
|
open={editModalData !== null}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Provisions an ESP32 board via WebSerial + esptool-js.
|
// Provisions an ESP32 board via WebSerial + esptool-js.
|
||||||
// Steps: 0=Mode 1=Select/Create device 2=Flash 3=Verify 4=Done
|
// 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 { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { ESPLoader, Transport } from 'esptool-js'
|
import { ESPLoader, Transport } from 'esptool-js'
|
||||||
import api from '@/lib/api'
|
import api from '@/lib/api'
|
||||||
@@ -13,6 +13,7 @@ import StatusBadge from '@/components/ui/StatusBadge'
|
|||||||
import SearchBar from '@/components/ui/SearchBar'
|
import SearchBar from '@/components/ui/SearchBar'
|
||||||
import Spinner from '@/components/ui/Spinner'
|
import Spinner from '@/components/ui/Spinner'
|
||||||
import Icon from '@/components/ui/Icon'
|
import Icon from '@/components/ui/Icon'
|
||||||
|
import { useSerialConnection } from '@/hooks/useSerialConnection'
|
||||||
|
|
||||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -82,6 +83,21 @@ function formatHwVersion(v) {
|
|||||||
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
|
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 ───────────────────────────────────────────────────────────
|
// ─── Step Indicator ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
|
const STEP_LABELS = ['Mode', 'Device', 'Flash', 'Verify', 'Done']
|
||||||
@@ -660,74 +676,48 @@ function StepSelectDevice({ mode, preloadSn, onSelected, onCreatedSn }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Serial Log Modal ──────────────────────────────────────────────────────────
|
// ─── 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 }) {
|
function SerialLogModal({ open, onClose, logs, monitoring }) {
|
||||||
const [autoScroll, setAutoScroll] = useState(true)
|
if (!open) return null
|
||||||
const endRef = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoScroll && open) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}, [logs, autoScroll, open])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<>
|
||||||
open={open}
|
{/* .modal-body is a plain padded/scrolling block by default (the shape
|
||||||
onClose={onClose}
|
every other modal's content wants). This instance needs it to be a
|
||||||
title="Serial Output Logs"
|
non-scrolling flex column instead, so SerialLogPanel's own internal
|
||||||
size="lg"
|
scroll area is the only thing that scrolls and can stretch to fill
|
||||||
footer={
|
the near-fullscreen modal. */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
<style>{`
|
||||||
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', cursor: 'pointer', userSelect: 'none' }}>
|
.serial-log-modal .modal-body {
|
||||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Auto-scroll</span>
|
display: flex; flex-direction: column;
|
||||||
<span
|
overflow: hidden; min-height: 0;
|
||||||
onClick={() => setAutoScroll((v) => !v)}
|
}
|
||||||
style={{
|
`}</style>
|
||||||
position: 'relative', display: 'inline-flex', alignItems: 'center',
|
<Modal
|
||||||
width: 32, height: 18,
|
open={open}
|
||||||
backgroundColor: autoScroll ? 'var(--color-primary)' : 'var(--color-bg-elevated)',
|
onClose={onClose}
|
||||||
borderRadius: 9, border: '1px solid var(--color-border)',
|
title="Serial Output Logs"
|
||||||
cursor: 'pointer', transition: 'background-color 0.2s', flexShrink: 0,
|
size="xxl"
|
||||||
}}
|
className="serial-log-modal"
|
||||||
>
|
footer={
|
||||||
<span style={{
|
<div style={{ display: 'flex', justifyContent: 'flex-end', width: '100%' }}>
|
||||||
position: 'absolute', left: autoScroll ? 14 : 2,
|
<Button variant="secondary" onClick={onClose}>Close</Button>
|
||||||
width: 14, height: 14, borderRadius: '50%',
|
</div>
|
||||||
backgroundColor: '#fff', transition: 'left 0.15s',
|
}
|
||||||
}} />
|
>
|
||||||
</span>
|
<SerialLogPanel lines={logs} monitoring={monitoring} />
|
||||||
</label>
|
</Modal>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
|
// ─── Step 2: Flash ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepFlash({ device, bespokeOverride, onFlashed }) {
|
function StepFlash({ device, bespokeOverride, onFlashed, serialConn }) {
|
||||||
const [portConnected, setPortConnected] = useState(false)
|
const { portRef, portConnected, portName, requestPort, disconnect: disconnectPort, startMonitor, appendLine: appendSerial, clearLines: clearSerialLines } = serialConn
|
||||||
const [portName, setPortName] = useState('')
|
|
||||||
const [connecting, setConnecting] = useState(false)
|
const [connecting, setConnecting] = useState(false)
|
||||||
const [flashing, setFlashing] = useState(false)
|
const [flashing, setFlashing] = useState(false)
|
||||||
const [done, setDone] = useState(false)
|
const [done, setDone] = useState(false)
|
||||||
@@ -736,22 +726,43 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
const [nvsProgress, setNvsProgress] = useState(0)
|
const [nvsProgress, setNvsProgress] = useState(0)
|
||||||
const [fwProgress, setFwProgress] = useState(0)
|
const [fwProgress, setFwProgress] = useState(0)
|
||||||
const [log, setLog] = useState([])
|
const [log, setLog] = useState([])
|
||||||
const [serial, setSerial] = useState([])
|
|
||||||
const [nvsProfile, setNvsProfile] = useState('current')
|
const [nvsProfile, setNvsProfile] = useState('current')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
const loaderRef = useRef(null)
|
const loaderRef = useRef(null)
|
||||||
const portRef = useRef(null)
|
|
||||||
const serialReaderRef = useRef(null)
|
|
||||||
const serialActiveRef = useRef(false)
|
|
||||||
const logEndRef = useRef(null)
|
const logEndRef = useRef(null)
|
||||||
const serialEndRef = useRef(null)
|
const infoPanelRef = useRef(null)
|
||||||
|
|
||||||
const appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
|
// Flash Output must follow the left (info) panel's height one-way — it
|
||||||
const appendSerial = (msg) => setSerial((prev) => [...prev, String(msg)])
|
// 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 appendLog = (msg) => setLog((prev) => [...prev, String(msg)])
|
||||||
const scrollSerial = () => serialEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
|
// Scrolling on the *next* render (after the DOM has the new line) rather
|
||||||
|
// than synchronously in the append call — scrolling immediately after
|
||||||
|
// setLog targets the sentinel's position from BEFORE the new line was
|
||||||
|
// committed, so the view lands one line short of the actual end.
|
||||||
|
useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }) }, [log])
|
||||||
|
|
||||||
const fetchBinary = async (url) => {
|
const fetchBinary = async (url) => {
|
||||||
const token = localStorage.getItem('access_token')
|
const token = localStorage.getItem('access_token')
|
||||||
@@ -770,56 +781,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
return str
|
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 () => {
|
const handleConnectPort = async () => {
|
||||||
setError(''); setConnecting(true)
|
setError(''); setConnecting(true)
|
||||||
try {
|
try {
|
||||||
const port = await navigator.serial.requestPort()
|
await 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)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Port selection cancelled.')
|
setError(err.message || 'Port selection cancelled.')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -829,7 +794,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
|
|
||||||
const handleStartFlash = async () => {
|
const handleStartFlash = async () => {
|
||||||
if (!portRef.current) return
|
if (!portRef.current) return
|
||||||
setError(''); setLog([]); setSerial([])
|
setError(''); setLog([]); clearSerialLines()
|
||||||
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
|
setBlProgress(0); setPartProgress(0); setNvsProgress(0); setFwProgress(0)
|
||||||
setDone(false)
|
setDone(false)
|
||||||
|
|
||||||
@@ -866,8 +831,8 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
transport, baudrate: FLASH_BAUD,
|
transport, baudrate: FLASH_BAUD,
|
||||||
terminal: {
|
terminal: {
|
||||||
clean() {},
|
clean() {},
|
||||||
writeLine: (line) => { appendLog(line); scrollLog() },
|
writeLine: (line) => appendLog(line),
|
||||||
write: (msg) => { appendLog(msg); scrollLog() },
|
write: (msg) => appendLog(msg),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await loaderRef.current.main()
|
await loaderRef.current.main()
|
||||||
@@ -913,7 +878,7 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
setFlashing(false)
|
setFlashing(false)
|
||||||
setDone(true)
|
setDone(true)
|
||||||
appendSerial('── Serial monitor started (115200 baud) ──')
|
appendSerial('── Serial monitor started (115200 baud) ──')
|
||||||
startSerialMonitor(port)
|
startMonitor(port)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || String(err))
|
setError(err.message || String(err))
|
||||||
setFlashing(false)
|
setFlashing(false)
|
||||||
@@ -926,10 +891,11 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
const boardInfo = BOARD_TYPE_MAP[device.hw_type]
|
const boardInfo = BOARD_TYPE_MAP[device.hw_type]
|
||||||
|
|
||||||
const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
|
const familyPal = BOARD_FAMILY_COLORS[boardInfo?.family || 'vesper']
|
||||||
|
const readyToFlash = portConnected && !flashing && !done && log.length === 0
|
||||||
|
|
||||||
// ── Left panel: device info + controls ────────────────────────────────────
|
// ── Left panel: device info + controls ────────────────────────────────────
|
||||||
const InfoPanel = (
|
const InfoPanel = (
|
||||||
<div style={{
|
<div ref={infoPanelRef} style={{
|
||||||
borderRadius: 'var(--radius-lg)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: `1px solid ${familyPal.idleBorder}`,
|
border: `1px solid ${familyPal.idleBorder}`,
|
||||||
padding: 'var(--space-5)',
|
padding: 'var(--space-5)',
|
||||||
@@ -1029,13 +995,21 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!webSerialAvailable && (
|
{/* Web Serial warning — space always reserved so its appearance never
|
||||||
<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)' }}>
|
shifts panel height; hidden via visibility, not removed from flow. */}
|
||||||
Web Serial API not available. Use Chrome or Edge on a desktop system.
|
<div style={{
|
||||||
</div>
|
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% */}
|
{/* Progress bars — always visible, idle at 0% */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)', marginBottom: 'var(--space-3)' }}>
|
<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 */}
|
{/* Bottom bar */}
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 'var(--space-3)', paddingTop: 'var(--space-3)', marginTop: 'var(--space-2)' }}>
|
<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 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{portConnected && !flashing && !done && log.length === 0 && (
|
<div
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: '#4dd6c8' }}>
|
style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-xs)', color: flashing ? 'var(--color-text-muted)' : '#4dd6c8' }}
|
||||||
<span style={{ width: 7, height: 7, borderRadius: '50%', backgroundColor: '#22c55e', display: 'inline-block' }} />
|
aria-hidden={!(readyToFlash || flashing)}
|
||||||
Ready to flash.
|
>
|
||||||
</div>
|
<span style={{
|
||||||
)}
|
width: 7, height: 7, borderRadius: '50%', display: 'inline-block',
|
||||||
{flashing && (
|
backgroundColor: '#22c55e',
|
||||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>Flashing — do not disconnect…</p>
|
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 }}>
|
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', opacity: 0.5 }}>
|
||||||
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
|
NVS 0x9000 · FW 0x10000 · {FLASH_BAUD} baud
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: action buttons */}
|
{/* Right: action buttons — every possible button always renders (in a
|
||||||
{!busy && (
|
fixed order/slot) so the row's height and the buttons' own sizes
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
never shift; only the applicable ones are visible at a time. */}
|
||||||
{!portConnected && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minHeight: 36 }}>
|
||||||
<Button variant="secondary" onClick={handleConnectPort} disabled={!webSerialAvailable}>
|
<span style={{ display: busy ? 'inline-flex' : 'none', alignItems: 'center' }}>
|
||||||
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)' }}>
|
|
||||||
<Spinner size="sm" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Right panel: flash output log ─────────────────────────────────────────
|
// ── 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 = (
|
const FlashOutputPanel = (
|
||||||
<div style={{
|
<div style={{
|
||||||
borderRadius: 'var(--radius-lg)',
|
borderRadius: 'var(--radius-lg)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex', flexDirection: 'column',
|
display: 'flex', flexDirection: 'column',
|
||||||
height: 320,
|
height: infoPanelHeight || undefined,
|
||||||
alignSelf: 'start',
|
minHeight: 0,
|
||||||
position: 'sticky', top: 0,
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@@ -1143,8 +1124,10 @@ function StepFlash({ device, bespokeOverride, onFlashed }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||||
{/* Info panel (left) | Flash output (right) */}
|
{/* Info panel (left) | Flash output (right) — align-items: start so the
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)', alignItems: 'stretch' }}>
|
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}
|
{InfoPanel}
|
||||||
{FlashOutputPanel}
|
{FlashOutputPanel}
|
||||||
</div>
|
</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 ───────────────────────────────────────────────────────────
|
// ─── Step 3: Verify ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepVerify({ device, onVerified }) {
|
function StepVerify({ device, onVerified, serialConn }) {
|
||||||
const [polling, setPolling] = useState(false)
|
const [polling, setPolling] = useState(false)
|
||||||
const [timedOut, setTimedOut] = useState(false)
|
const [timedOut, setTimedOut] = useState(false)
|
||||||
const [verified, setVerified] = useState(false)
|
const [verified, setVerified] = useState(false)
|
||||||
@@ -1162,6 +1284,20 @@ function StepVerify({ device, onVerified }) {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const intervalRef = useRef(null)
|
const intervalRef = useRef(null)
|
||||||
const timeoutRef = 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(() => {
|
const startPolling = useCallback(() => {
|
||||||
if (polling) return
|
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`)
|
const hbData = await api.get(`/mqtt/heartbeats/${device.serial_number}?limit=1&offset=0`)
|
||||||
if (hbData.heartbeats?.length > 0) {
|
if (hbData.heartbeats?.length > 0) {
|
||||||
const latest = hbData.heartbeats[0]
|
const latest = hbData.heartbeats[0]
|
||||||
const receivedMs = latest.received_at
|
// received_at already comes back as a full ISO-8601 string with an
|
||||||
? Date.parse(latest.received_at.replace(' ', 'T') + 'Z') : NaN
|
// 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) {
|
if (!isNaN(receivedMs) && receivedMs > startTime) {
|
||||||
clearInterval(intervalRef.current)
|
clearInterval(intervalRef.current)
|
||||||
clearTimeout(timeoutRef.current)
|
clearTimeout(timeoutRef.current)
|
||||||
@@ -1211,66 +1352,73 @@ function StepVerify({ device, onVerified }) {
|
|||||||
clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false)
|
clearInterval(intervalRef.current); clearTimeout(timeoutRef.current); setPolling(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const StatusPanel = (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<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' }}>
|
||||||
<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)' }}>
|
||||||
<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
|
||||||
Waiting for Device
|
</p>
|
||||||
</p>
|
|
||||||
|
|
||||||
{polling && !verified && (
|
{polling && !verified && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 'var(--space-6) 0', gap: 'var(--space-4)' }}>
|
||||||
<Spinner size="lg" />
|
<Spinner size="lg" />
|
||||||
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
<p style={{ fontSize: 'var(--font-size-sm)', textAlign: 'center', color: 'var(--color-text-secondary)' }}>
|
||||||
Waiting for device to connect…<br />
|
Waiting for device to connect…<br />
|
||||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||||
Power cycle the device and ensure it can reach the MQTT broker.
|
Power cycle the device and ensure it can reach the MQTT broker.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
|
<Button variant="ghost" onClick={stopPolling}>Stop</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{verified && heartbeatData && (
|
{verified && heartbeatData && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
<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' }}>
|
<div style={{ width: 32, height: 32, borderRadius: '50%', backgroundColor: '#0a2e2a', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Icon name="check" size={16} color="#4dd6c8" />
|
<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 || '—'} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: '#4dd6c8' }}>Device is live!</p>
|
||||||
</div>
|
</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 || '—'} />
|
||||||
{timedOut && !verified && (
|
<InfoCell label="IP Address" value={heartbeatData.ip_address || '—'} />
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', paddingTop: 'var(--space-4)' }}>
|
<InfoCell label="Uptime" value={heartbeatData.uptime_display || '—'} />
|
||||||
<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' }}>
|
<InfoCell label="Gateway" value={heartbeatData.gateway || '—'} />
|
||||||
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>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && !timedOut && !verified && (
|
{timedOut && !verified && (
|
||||||
<div style={{ marginTop: 'var(--space-3)' }}><ErrorBox msg={`Poll error (will retry): ${error}`} /></div>
|
<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' }}>
|
||||||
</div>
|
Timed out after {VERIFY_TIMEOUT_MS / 1000}s. Check WiFi credentials and MQTT broker connectivity.
|
||||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
</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
|
Polling every {VERIFY_POLL_MS / 1000}s · timeout {VERIFY_TIMEOUT_MS / 1000}s
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 ─────────────────────────────────────────────────────────────
|
// ─── Step 4: Done ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepDone({ device, onProvisionNext }) {
|
function StepDone({ device, onProvisionNext, serialConn }) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [showSerialLogs, setShowSerialLogs] = useState(false)
|
const [showSerialLogs, setShowSerialLogs] = useState(false)
|
||||||
|
|
||||||
@@ -1314,11 +1462,21 @@ function StepDone({ device, onProvisionNext }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-3)' }}>
|
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', textAlign: 'center' }}>
|
||||||
<Button variant="primary" onClick={onProvisionNext}>
|
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
|
Provision Next Device
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
|
<Button variant="ghost" onClick={() => navigate(`/manufacturing/devices/${device?.serial_number}`)}>
|
||||||
View in Inventory
|
View in Inventory
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
|
<Button variant="ghost" onClick={() => setShowSerialLogs(true)}>
|
||||||
@@ -1326,7 +1484,7 @@ function StepDone({ device, onProvisionNext }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={[]} />
|
<SerialLogModal open={showSerialLogs} onClose={() => setShowSerialLogs(false)} logs={serialConn.lines} monitoring={serialConn.monitoring} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1345,6 +1503,12 @@ export default function ProvisioningWizard() {
|
|||||||
const [bespokeOverride, setBespokeOverride] = useState(null)
|
const [bespokeOverride, setBespokeOverride] = useState(null)
|
||||||
const createdSnRef = useRef(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 handleModePick = (m) => { setMode(m); setStep(1) }
|
||||||
|
|
||||||
const handleDeviceSelected = (d, bespoke = null) => {
|
const handleDeviceSelected = (d, bespoke = null) => {
|
||||||
@@ -1364,7 +1528,7 @@ export default function ProvisioningWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<PageHeader title="Provisioning Wizard" subtitle="Flash firmware to a Bell Systems board via WebSerial">
|
||||||
<Button variant="ghost" onClick={() => navigate('/manufacturing')}>
|
<Button variant="ghost" onClick={() => navigate('/manufacturing')}>
|
||||||
<Icon name="arrow-left" size={14} />
|
<Icon name="arrow-left" size={14} />
|
||||||
@@ -1395,15 +1559,16 @@ export default function ProvisioningWizard() {
|
|||||||
device={device}
|
device={device}
|
||||||
bespokeOverride={bespokeOverride}
|
bespokeOverride={bespokeOverride}
|
||||||
onFlashed={handleFlashed}
|
onFlashed={handleFlashed}
|
||||||
|
serialConn={serialConn}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 3 && device && (
|
{step === 3 && device && (
|
||||||
<StepVerify device={device} onVerified={handleVerified} />
|
<StepVerify device={device} onVerified={handleVerified} serialConn={serialConn} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 4 && device && (
|
{step === 4 && device && (
|
||||||
<StepDone device={device} onProvisionNext={handleProvisionNext} />
|
<StepDone device={device} onProvisionNext={handleProvisionNext} serialConn={serialConn} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 DeviceList from '@/pages/bellcloud/devices/DeviceList'
|
||||||
import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
|
import DeviceDetail from '@/pages/bellcloud/devices/DeviceDetail'
|
||||||
import DeviceMapPage from '@/pages/bellcloud/devices/DeviceMapPage'
|
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 StyleGuide from '@/pages/dev/StyleGuide'
|
||||||
import CardFontSample from '@/pages/dev/CardFontSample'
|
import CardFontSample from '@/pages/dev/CardFontSample'
|
||||||
import UserList from '@/pages/bellcloud/users/UserList'
|
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 StaffForm from '@/pages/settings/staff/StaffForm'
|
||||||
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
|
import PublicFeaturesSettings from '@/pages/settings/PublicFeaturesSettings'
|
||||||
import LogViewerPage from '@/pages/settings/LogViewerPage'
|
import LogViewerPage from '@/pages/settings/LogViewerPage'
|
||||||
|
import LogRetentionSettings from '@/pages/settings/LogRetentionSettings'
|
||||||
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
|
import AutomationsPage from '@/pages/settings/automations/AutomationsPage'
|
||||||
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
|
import ApiReferencePage from '@/pages/engineering/developer/ApiReferencePage'
|
||||||
import CustomerList from '@/pages/crm/customers/CustomerList'
|
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" element={<PermissionGate section="devices"><DeviceList /></PermissionGate>} />
|
||||||
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><ComingSoon /></PermissionGate>} />
|
<Route path="devices/new" element={<PermissionGate section="devices" action="add"><ComingSoon /></PermissionGate>} />
|
||||||
{/* Map preview — temporary unlisted route while map view is under construction */}
|
{/* 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/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" element={<PermissionGate section="devices"><DeviceDetail /></PermissionGate>} />
|
||||||
<Route path="devices/:id/edit" element={<PermissionGate section="devices" action="edit"><ComingSoon /></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/public-features" element={<RoleGate roles={['sysadmin', 'admin']}><PublicFeaturesSettings /></RoleGate>} />
|
||||||
<Route path="settings/automations" element={<RoleGate roles={['sysadmin', 'admin']}><AutomationsPage /></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/audit-log" element={<RoleGate roles={['sysadmin']}><LogViewerPage /></RoleGate>} />
|
||||||
|
<Route path="settings/log-retention" element={<RoleGate roles={['sysadmin', 'admin']}><LogRetentionSettings /></RoleGate>} />
|
||||||
|
|
||||||
{/* Catch-all */}
|
{/* Catch-all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -503,6 +503,24 @@ tr:focus-within .btn-table-actions {
|
|||||||
color: var(--color-primary);
|
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 */
|
/* Keep old .select-wrapper for any legacy uses */
|
||||||
.select-wrapper {
|
.select-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -2091,7 +2109,12 @@ tr:focus-within .btn-table-actions {
|
|||||||
display: none;
|
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 {
|
.header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -2099,10 +2122,11 @@ tr:focus-within .btn-table-actions {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: var(--header-height);
|
height: var(--header-height);
|
||||||
z-index: var(--z-sticky);
|
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);
|
backdrop-filter: var(--blur-modal);
|
||||||
-webkit-backdrop-filter: var(--blur-modal);
|
-webkit-backdrop-filter: var(--blur-modal);
|
||||||
border-bottom: 1px solid rgba(70, 69, 84, 0.12);
|
border-bottom: 1px solid rgba(70, 69, 84, 0.12);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 var(--space-6);
|
padding: 0 var(--space-6);
|
||||||
@@ -3220,3 +3244,14 @@ tr:focus-within .btn-table-actions {
|
|||||||
filter: brightness(1.06);
|
filter: brightness(1.06);
|
||||||
box-shadow: var(--shadow-primary-glow);
|
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