diff --git a/backend/devices/router.py b/backend/devices/router.py index 7508763..d5d9c83 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -10,6 +10,7 @@ from devices.models import ( DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, DeviceUsersResponse, DeviceUserInfo, DeviceNoteCreate, DeviceNoteUpdate, + ResetStatsRequest, ResetStatsResult, ) from devices import service import database as mqtt_db @@ -65,7 +66,7 @@ async def create_device( ): device = service.create_device(body) await log_action(db, _user.sub, _user.name or _user.email, "CREATE", "device", - device.device_id, device.device_name or device.device_id) + device.device_id, device.console_name or device.device_name or device.device_id) return device @@ -87,7 +88,7 @@ async def update_device( if "tags" in body.model_fields_set and (old.tags or []) != (device.tags or []): changes["tags"] = {"old": sorted(old.tags or []), "new": sorted(device.tags or [])} await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", - device_id, device.device_name or device_id, changes=changes or None) + device_id, device.console_name or device.device_name or device_id, changes=changes or None) return device @@ -437,14 +438,14 @@ async def add_user_to_device( db: AsyncSession = Depends(get_pg_session), ): """Add a user reference to the device's user_list field.""" - db = get_firestore() - device_ref = db.collection("devices").document(device_id) + fs = get_firestore() + device_ref = fs.collection("devices").document(device_id) device_doc = device_ref.get() if not device_doc.exists: raise HTTPException(status_code=404, detail="Device not found") # Verify user exists - user_doc = db.collection("users").document(body.user_id).get() + user_doc = fs.collection("users").document(body.user_id).get() if not user_doc.exists: raise HTTPException(status_code=404, detail="User not found") @@ -461,7 +462,7 @@ async def add_user_to_device( existing_ids.add(entry.split("/")[-1]) if body.user_id not in existing_ids: - user_ref = db.collection("users").document(body.user_id) + user_ref = fs.collection("users").document(body.user_id) user_list.append(user_ref) device_ref.update({"user_list": user_list}) @@ -479,8 +480,8 @@ async def remove_user_from_device( db: AsyncSession = Depends(get_pg_session), ): """Remove a user reference from the device's user_list field.""" - db = get_firestore() - device_ref = db.collection("devices").document(device_id) + fs = get_firestore() + device_ref = fs.collection("devices").document(device_id) device_doc = device_ref.get() if not device_doc.exists: raise HTTPException(status_code=404, detail="Device not found") @@ -504,4 +505,75 @@ async def remove_user_from_device( await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", device_id, device_id, meta={"action_detail": "user_removed", "user_id": user_id}) - return {"status": "removed", "user_id": user_id} + + +@router.post("/{device_id}/reset-stats", response_model=ResetStatsResult) +async def reset_device_stats( + device_id: str, + body: ResetStatsRequest, + _user: TokenPayload = Depends(require_permission("devices", "edit")), + db: AsyncSession = Depends(get_pg_session), +): + """Clears QA/bench test data accumulated before a device ships to a + customer. Only handles categories that don't require the device itself + to be online — Postgres history tables and Firestore device_stats + counters. The two firmware-side resets (telemetry.reset_boot_data, + logs.clear) are sent directly by the frontend over MQTT via the existing + command-ack flow, since they need the device online and this console + already has that round-trip built (see DeviceDetail.jsx's + sendMqttCommand) — duplicating it backend-side would just be a second, + inferior implementation of the same wait-for-device-reply logic. + + Nothing here touches device identity, customer assignment, notes, tags, + warranty/maintenance fields, or audit logs — see ResetStatsRequest's + field list for exactly what's in scope. + """ + device = service.get_device(device_id) + serial = device.serial_number or device.device_id + + result = ResetStatsResult() + + # Firestore device_stats — field-level only. DeviceStatistics mixes true + # counters with warranty/maintenance fields in one object; a blanket + # clear would destroy warranty data, so only the 4 named counter fields + # are ever sent here (deep-merge in service.update_device preserves + # every other key, including warrantyActive/warrantyStart/etc.). + stats_update = {} + if body.bell_stats: + stats_update["totalHammerStrikes"] = 0 + stats_update["perBellStrikes"] = [] + if body.playback_stats: + stats_update["totalPlaybacks"] = 0 + stats_update["totalWarningsGiven"] = 0 + if stats_update: + service.update_device(device_id, DeviceUpdate(device_stats=stats_update)) + if body.bell_stats: + result.bell_stats = True + if body.playback_stats: + result.playback_stats = True + + # Postgres — pure history/telemetry tables, per-device row deletes. + if body.device_logs: + result.device_logs = await mqtt_db.delete_device_logs(serial) + if body.heartbeats: + result.heartbeats = await mqtt_db.delete_device_heartbeats(serial) + if body.commands: + result.commands = await mqtt_db.delete_device_commands(serial) + if body.boot_events: + result.boot_events = await mqtt_db.delete_device_boot_events(serial) + if body.alert_history: + result.alert_history = await mqtt_db.delete_device_alert_events(serial) + if body.ping_samples: + result.ping_samples = await mqtt_db.delete_device_ping_samples(serial) + if body.diagnostics_reports: + result.diagnostics_reports = await mqtt_db.delete_device_diagnostics_reports(serial) + if body.reports: + result.reports = await mqtt_db.delete_device_reports(serial) + if body.current_alerts: + result.current_alerts = await mqtt_db.delete_device_current_alerts(serial) + + await log_action(db, _user.sub, _user.name or _user.email, "UPDATE", "device", + device_id, device.console_name or device.device_name or serial, + meta={"action_detail": "reset_stats", "categories": body.model_dump()}) + + return result diff --git a/frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx b/frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx index 28e2b27..0e061ed 100644 --- a/frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx +++ b/frontend/src/modals/bellcloud/devices/AddDeviceUserModal.jsx @@ -172,7 +172,7 @@ export default function AddDeviceUserModal({ open, deviceId, existingUserIds = [ )} {/* Results */} -
+
{loading ? (
diff --git a/frontend/src/modals/bellcloud/devices/ResetStatsModal.jsx b/frontend/src/modals/bellcloud/devices/ResetStatsModal.jsx new file mode 100644 index 0000000..cf94adb --- /dev/null +++ b/frontend/src/modals/bellcloud/devices/ResetStatsModal.jsx @@ -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 ( + + ) +} + +// ─── 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 ( + +
+ + {!results && ( + <> +
+ + + 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. This action cannot be undone. + +
+ + {!isOnline && ( +
+ + Device is offline — console history will still be cleared, but on-device resets (boot count, SD logs) will be skipped. +
+ )} + +
+ + +
+ +
+ {CATEGORIES.map(group => ( +
+ + {group.group} + +
+ {group.items.map(item => ( + + ))} +
+
+ ))} +
+ +
+ + + {running ? 'Resetting…' : 'Hold to Reset Selected'} + +
+ + )} + + {results && ( + <> +
+ {CATEGORIES.flatMap(g => g.items).filter(item => selection[item.key]).map(item => { + const r = results[item.key] + return ( +
+ {item.label} +
+ {r?.detail} + {r?.ok ? 'Done' : 'Skipped'} +
+
+ ) + })} +
+
+ +
+ + )} + +
+
+ ) +}