feat(devices): add Reset Stats action for clearing pre-ship QA/bench data
New POST /devices/{id}/reset-stats + ResetStatsModal lets staff clear
QA/bench test data accumulated before a device ships to a customer:
Firestore bell/playback counters and Postgres history tables (logs,
heartbeats, commands, boot events, alert events, ping samples,
diagnostics reports, control/reports, and opt-in live alerts). The two
firmware-side resets (telemetry.reset_boot_data, logs.clear) go over
the existing MQTT command/ack flow instead, since they need the device
online and duplicating that round-trip server-side would just be a
second, inferior implementation.
Also includes two incidental cleanups in devices/router.py: audit-log
entries for create/update now use console_name in their label (missed
by the earlier console_name commit), and add/remove-device-user rename
their local Firestore client from `db` to `fs` to stop shadowing the
`db: AsyncSession` dependency param in the same function scope.
AddDeviceUserModal's results list also gets a max-height + scroll so a
long match list doesn't grow the modal off-screen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ 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
|
||||||
import database as mqtt_db
|
import database as mqtt_db
|
||||||
@@ -65,7 +66,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 +88,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 +438,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")
|
||||||
|
|
||||||
@@ -461,7 +462,7 @@ async def add_user_to_device(
|
|||||||
existing_ids.add(entry.split("/")[-1])
|
existing_ids.add(entry.split("/")[-1])
|
||||||
|
|
||||||
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_ref = fs.collection("users").document(body.user_id)
|
||||||
user_list.append(user_ref)
|
user_list.append(user_ref)
|
||||||
device_ref.update({"user_list": user_list})
|
device_ref.update({"user_list": user_list})
|
||||||
|
|
||||||
@@ -479,8 +480,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")
|
||||||
@@ -504,4 +505,75 @@ async def remove_user_from_device(
|
|||||||
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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user