Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -0,0 +1,733 @@
|
||||
// frontend/src/pages/developer/ApiReferencePage.jsx
|
||||
// Vesper Firmware API Reference v2 — Command explorer
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Card from '@/components/ui/Card'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import Tabs from '@/components/ui/Tabs'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TRANSPORTS = [
|
||||
{ transport: 'MQTT', address: 'vesper/{device_id}/control', direction: 'Inbound', notes: 'Commands in. Responses go to vesper/{device_id}/data' },
|
||||
{ transport: 'MQTT', address: 'vesper/{device_id}/data', direction: 'Outbound', notes: 'Responses + events' },
|
||||
{ transport: 'MQTT', address: 'vesper/{device_id}/status/heartbeat', direction: 'Outbound, retained', notes: 'Heartbeat every 30 s' },
|
||||
{ transport: 'MQTT', address: 'vesper/{device_id}/status/alerts', direction: 'Outbound, QoS 1', notes: 'Subsystem state changes (WARNING / CRITICAL / FAILED / CLEARED). Published on transition only.' },
|
||||
{ transport: 'MQTT', address: 'vesper/{device_id}/status/info', direction: 'Outbound, QoS 0', notes: 'Significant device events (playback start/stop, etc.).' },
|
||||
{ transport: 'WebSocket', address: 'ws://{device_ip}/ws', direction: 'Bidirectional', notes: 'Requires identify on connect' },
|
||||
{ transport: 'HTTP', address: 'http://{device_ip}/...', direction: 'REST', notes: 'Web console endpoints' },
|
||||
{ transport: 'UART', address: 'Hardware serial', direction: 'Bidirectional', notes: 'Restricted — whitelist only' },
|
||||
{ transport: 'UDP', address: 'Port 32101', direction: 'Discovery', notes: 'Passive — device announces itself' },
|
||||
]
|
||||
|
||||
const UART_WHITELIST = ['ping', 'playback', 'clock.pause', 'clock.resume', 'system.get_time']
|
||||
|
||||
const NAMESPACES = [
|
||||
{
|
||||
id: 'root',
|
||||
label: 'Root',
|
||||
description: 'Top-level commands — no namespace prefix.',
|
||||
commands: [
|
||||
{ cmd: 'ping', handler: 'SystemHandler', transports: ['All'], description: 'Connectivity check. No contents required.', contents: null, returns: '{ "status": "SUCCESS", "type": "pong" }', example: '{ "v": 2, "cmd": "ping" }', warning: null },
|
||||
{ cmd: 'identify', handler: 'SystemHandler', transports: ['WebSocket'], description: 'Register client type with the device. Must be sent immediately after connecting on WebSocket or targeted responses will not be received.', contents: [{ field: 'device_type', type: 'string', required: true, notes: '"master" (app/console) or "secondary" (slave board)' }], returns: null, example: '{ "v": 2, "cmd": "identify", "contents": { "device_type": "master" } }', warning: null },
|
||||
{ cmd: 'playback', handler: 'PlaybackHandler', transports: ['All'], description: 'Control melody playback. The action field is always required.', contents: [{ field: 'action', type: 'string', required: true, notes: '"play" | "stop" | "pause" | "unpause"' }, { field: 'melody_uid', type: 'string', required: false, notes: 'Required when action is play' }], returns: null, example: '{ "v": 2, "cmd": "playback", "contents": { "action": "play", "melody_uid": "ABC123" } }\n{ "v": 2, "cmd": "playback", "contents": { "action": "stop" } }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'relay',
|
||||
label: 'relay',
|
||||
description: 'Bell relay channel configuration and testing. Handler: RelayHandler.',
|
||||
commands: [
|
||||
{ cmd: 'relay.set_durations', handler: 'RelayHandler', transports: ['All'], description: 'Set strike duration (ms) for one or more bell channels. Partial updates accepted — omitted channels are unchanged.', contents: [{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → duration ms. e.g. { "0": 95, "2": 110 }. At least one entry required.' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.set_durations",\n "contents": { "bells": { "0": 95, "1": 100, "2": 110 } }\n}', warning: null },
|
||||
{ cmd: 'relay.set_outputs', handler: 'RelayHandler', transports: ['All'], description: 'Map bell channels to physical relay outputs. Partial updates accepted.', contents: [{ field: 'bells', type: 'object', required: true, notes: 'Map of channel index → relay output index. e.g. { "0": 2, "1": 3 }' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.set_outputs",\n "contents": { "bells": { "0": 2, "1": 3, "2": 4 } }\n}', warning: null },
|
||||
{ cmd: 'relay.get_config', handler: 'RelayHandler', transports: ['All'], description: 'Read current bell durations and relay output assignments.', contents: null, returns: '{ "durations": { "0": 95, ... }, "outputs": { "0": 2, ... } }', example: '{ "v": 2, "cmd": "relay.get_config" }', warning: null },
|
||||
{ cmd: 'relay.test_output', handler: 'RelayHandler', transports: ['All'], description: 'Fire a specific relay for a caller-specified duration. Safety-gated: Player must be stopped.', contents: [{ field: 'output', type: 'int', required: true, notes: 'Physical relay output index' }, { field: 'duration_ms', type: 'int', required: true, notes: 'Duration in ms. No defaults. Typical range: 85–140 ms' }], returns: null, example: '{\n "v": 2,\n "cmd": "relay.test_output",\n "contents": { "output": 2, "duration_ms": 95 }\n}', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'clock',
|
||||
label: 'clock',
|
||||
description: 'Clock strike configuration, RTC time, face position, timezone, and silence periods. Handler: ClockHandler.',
|
||||
commands: [
|
||||
{ cmd: 'clock.set_outputs', handler: 'ClockHandler', transports: ['All'], description: 'Set which relay outputs the clock strikes use. Partial updates accepted.', contents: [{ field: 'c1', type: 'int', required: false, notes: 'Relay output for the first clock channel' }, { field: 'c2', type: 'int', required: false, notes: 'Relay output for the second clock channel. At least one of c1/c2 required.' }], returns: null, example: '{ "v": 2, "cmd": "clock.set_outputs", "contents": { "c1": 4, "c2": 5 } }', warning: null },
|
||||
{ cmd: 'clock.set_timings', handler: 'ClockHandler', transports: ['All'], description: 'Set clock strike timing configuration. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_timings", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_alerts', handler: 'ClockHandler', transports: ['All'], description: 'Configure hourly/quarter alert behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_alerts", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_backlight', handler: 'ClockHandler', transports: ['All'], description: 'Configure LCD backlight behavior. Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_backlight", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_silence', handler: 'ClockHandler', transports: ['All'], description: 'Configure silence period (quiet hours). Partial updates accepted.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.set_silence", "contents": { ... } }', warning: null },
|
||||
{ cmd: 'clock.set_time', handler: 'ClockHandler', transports: ['All'], description: 'Set the RTC time.', contents: [{ field: 'timestamp', type: 'int', required: true, notes: 'Unix epoch timestamp' }, { field: 'timezone_offset', type: 'int', required: false, notes: 'Offset in seconds (e.g. 7200 for UTC+2)' }, { field: 'dst_offset', type: 'int', required: false, notes: 'DST offset in seconds' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_time",\n "contents": { "timestamp": 1740000000, "timezone_offset": 7200 }\n}', warning: null },
|
||||
{ cmd: 'clock.set_face', handler: 'ClockHandler', transports: ['All'], description: 'Set the physical analog clock face position.', contents: [{ field: 'hour', type: 'int', required: true, notes: 'Hour hand position (0–11)' }, { field: 'minute', type: 'int', required: true, notes: 'Minute hand position (0–59)' }], returns: null, example: '{ "v": 2, "cmd": "clock.set_face", "contents": { "hour": 10, "minute": 10 } }', warning: null },
|
||||
{ cmd: 'clock.set_timezone', handler: 'ClockHandler', transports: ['All'], description: 'Set timezone independently of RTC time.', contents: [{ field: 'gmt_offset_sec', type: 'int', required: true, notes: 'GMT offset in seconds (e.g. 7200 for UTC+2)' }, { field: 'dst_offset_sec', type: 'int', required: false, notes: 'DST offset in seconds' }, { field: 'timezone_name', type: 'string', required: false, notes: 'IANA timezone name, e.g. "Europe/Athens"' }], returns: null, example: '{\n "v": 2,\n "cmd": "clock.set_timezone",\n "contents": { "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "Europe/Athens" }\n}', warning: null },
|
||||
{ cmd: 'clock.get_timezone', handler: 'ClockHandler', transports: ['All'], description: 'Read current timezone settings.', contents: null, returns: '{ "gmt_offset_sec": 7200, "dst_offset_sec": 3600, "timezone_name": "..." }', example: '{ "v": 2, "cmd": "clock.get_timezone" }', warning: null },
|
||||
{ cmd: 'clock.sync_ntp', handler: 'ClockHandler', transports: ['All'], description: 'Force an immediate NTP time sync.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.sync_ntp" }', warning: null },
|
||||
{ cmd: 'clock.enable', handler: 'ClockHandler', transports: ['All'], description: 'Enable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.enable" }', warning: null },
|
||||
{ cmd: 'clock.disable', handler: 'ClockHandler', transports: ['All'], description: 'Disable clock strike output.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.disable" }', warning: null },
|
||||
{ cmd: 'clock.pause', handler: 'ClockHandler', transports: ['All'], description: 'Pause clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.pause" }', warning: null },
|
||||
{ cmd: 'clock.resume', handler: 'ClockHandler', transports: ['All'], description: 'Resume clock updates.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.resume" }', warning: null },
|
||||
{ cmd: 'clock.get_face', handler: 'ClockHandler', transports: ['All'], description: 'Read current analog clock face position.', contents: null, returns: '{ "clock_hour", "clock_minute", "last_sync_time", "next_output_is_c1" }', example: '{ "v": 2, "cmd": "clock.get_face" }', warning: null },
|
||||
{ cmd: 'clock.get_config', handler: 'ClockHandler', transports: ['All'], description: 'Read full clock configuration — outputs, timings, alerts, backlight, silence, enabled state.', contents: null, returns: null, example: '{ "v": 2, "cmd": "clock.get_config" }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
label: 'system',
|
||||
description: 'Device status, settings, health, and control. Handler: SystemHandler.',
|
||||
commands: [
|
||||
{ cmd: 'system.status', handler: 'SystemHandler', transports: ['All'], description: 'Get current device status: player state, strike counters, projected run time.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.status" }', warning: null },
|
||||
{ cmd: 'system.get_time', handler: 'SystemHandler', transports: ['All'], description: 'Get current RTC time.', contents: null, returns: '{ "local_timestamp", "utc_timestamp", "year", "month", "day", "hour", "minute", "second", "rtc_available" }', example: '{ "v": 2, "cmd": "system.get_time" }', warning: null },
|
||||
{ cmd: 'system.get_settings', handler: 'SystemHandler', transports: ['All'], description: 'Get full ConfigManager JSON dump (all saved settings).', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.get_settings" }', warning: null },
|
||||
{ cmd: 'system.get_device_info', handler: 'SystemHandler', transports: ['All'], description: 'Get device identity and runtime stats.', contents: null, returns: '{ "uid", "hw_type", "hw_version", "fw_version", "uptime_ms", "free_heap", "min_free_heap" }', example: '{ "v": 2, "cmd": "system.get_device_info" }', warning: null },
|
||||
{ cmd: 'system.health', handler: 'SystemHandler', transports: ['All'], description: 'Get full HealthMonitor report — all subsystem states, warnings, and critical failures.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.health" }', warning: null },
|
||||
{ cmd: 'system.factory_reset', handler: 'SystemHandler', transports: ['All'], description: 'Reset all settings to factory defaults.', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.factory_reset" }', warning: 'Non-reversible. All saved configuration is permanently wiped.' },
|
||||
{ cmd: 'system.restart', handler: 'SystemHandler', transports: ['All'], description: 'Reboot the device. Response is sent before reboot (2 s delay).', contents: null, returns: null, example: '{ "v": 2, "cmd": "system.restart" }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'firmware',
|
||||
label: 'firmware',
|
||||
description: 'OTA slot management — validate, commit, and roll back firmware. Handler: FirmwareHandler.',
|
||||
commands: [
|
||||
{ cmd: 'firmware.status', handler: 'FirmwareHandler', transports: ['All'], description: 'Get firmware validation state.', contents: null, returns: '{ "validation_state", "version", "boot_count", "can_commit", "can_rollback" }', example: '{ "v": 2, "cmd": "firmware.status" }', warning: null },
|
||||
{ cmd: 'firmware.commit', handler: 'FirmwareHandler', transports: ['All'], description: 'Commit the current firmware as permanent (marks OTA slot as valid).', contents: null, returns: null, example: '{ "v": 2, "cmd": "firmware.commit" }', warning: null },
|
||||
{ cmd: 'firmware.rollback', handler: 'FirmwareHandler', transports: ['All'], description: 'Roll back to the previous firmware. Device reboots.', contents: null, returns: null, example: '{ "v": 2, "cmd": "firmware.rollback" }', warning: 'Device will reboot immediately after this command.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ota',
|
||||
label: 'ota',
|
||||
description: 'Over-the-air update management. Handler: FirmwareHandler.',
|
||||
commands: [
|
||||
{ cmd: 'ota.status', handler: 'FirmwareHandler', transports: ['All'], description: 'Get OTA update status.', contents: null, returns: '{ "current_version", "available_version", "channel", "update_available", "last_check", "progress" }', example: '{ "v": 2, "cmd": "ota.status" }', warning: null },
|
||||
{ cmd: 'ota.check', handler: 'FirmwareHandler', transports: ['All'], description: 'Check for available updates.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.check", "contents": { "channel": "beta" } }', warning: null },
|
||||
{ cmd: 'ota.update', handler: 'FirmwareHandler', transports: ['All'], description: 'Trigger OTA update from VPS. Device may reboot after flashing.', contents: [{ field: 'channel', type: 'string', required: false, notes: 'Update channel. Default: "stable"' }], returns: null, example: '{ "v": 2, "cmd": "ota.update", "contents": { "channel": "stable" } }', warning: 'Device may reboot after the update is applied.' },
|
||||
{ cmd: 'ota.custom', handler: 'FirmwareHandler', transports: ['All'], description: 'Flash firmware from a custom URL.', contents: [{ field: 'firmware_url', type: 'string', required: true, notes: 'Direct download URL for the firmware binary' }, { field: 'checksum', type: 'string', required: false, notes: 'Optional integrity checksum' }, { field: 'file_size', type: 'int', required: false, notes: 'Optional file size in bytes' }, { field: 'version', type: 'string', required: false, notes: 'Version label for display purposes' }], returns: null, example: '{\n "v": 2,\n "cmd": "ota.custom",\n "contents": {\n "firmware_url": "http://example.com/firmware.bin",\n "checksum": "abc123",\n "version": "142"\n }\n}', warning: 'Device may reboot after the update is applied.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'network',
|
||||
description: 'Network configuration and connection status. Handler: NetworkHandler.',
|
||||
commands: [
|
||||
{ cmd: 'network.info', handler: 'NetworkHandler', transports: ['All'], description: 'Get IP address, gateway, and DNS.', contents: null, returns: '{ "ip", "gateway", "dns" }', example: '{ "v": 2, "cmd": "network.info" }', warning: null },
|
||||
{ cmd: 'network.status', handler: 'NetworkHandler', transports: ['All'], description: 'Get full connection state.', contents: null, returns: '{ "connected", "state", "type", "ip", "rssi", "ap_mode" }', example: '{ "v": 2, "cmd": "network.status" }', warning: null },
|
||||
{ cmd: 'network.set_config', handler: 'NetworkHandler', transports: ['All'], description: 'Update network configuration. Partial updates accepted. Returns restart_required flag.', contents: [{ field: 'hostname', type: 'string', required: false, notes: 'Device hostname on the network' }, { field: 'useStaticIP', type: 'bool', required: false, notes: 'Enable static IP mode' }, { field: 'ip', type: 'string', required: false, notes: 'Static IP address. Required together with gateway + subnet when using static IP.' }, { field: 'gateway', type: 'string', required: false, notes: 'Gateway address' }, { field: 'subnet', type: 'string', required: false, notes: 'Subnet mask' }, { field: 'dns1', type: 'string', required: false, notes: 'Primary DNS' }, { field: 'dns2', type: 'string', required: false, notes: 'Secondary DNS' }], returns: '{ "restart_required": true/false }', example: '{\n "v": 2,\n "cmd": "network.set_config",\n "contents": { "hostname": "vesper-lab", "useStaticIP": false }\n}', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
label: 'files',
|
||||
description: 'Melody file management on the SD card and built-in firmware storage. Handler: FileHandler.',
|
||||
commands: [
|
||||
{ cmd: 'files.list', handler: 'FileHandler', transports: ['All'], description: 'List melody files on the SD card.', contents: null, returns: 'Array of SD card melody file objects', example: '{ "v": 2, "cmd": "files.list" }', warning: null },
|
||||
{ cmd: 'files.list_builtin', handler: 'FileHandler', transports: ['All'], description: 'List all built-in melodies compiled into the firmware.', contents: null, returns: '[{ "name": "...", "uid": "..." }, ...]', example: '{ "v": 2, "cmd": "files.list_builtin" }', warning: null },
|
||||
{ cmd: 'files.download', handler: 'FileHandler', transports: ['All'], description: 'Download a melody from the server to the SD card.', contents: [{ field: 'melodys_uid', type: 'string', required: true, notes: 'UID of the melody to download' }], returns: null, example: '{ "v": 2, "cmd": "files.download", "contents": { "melodys_uid": "ABC123" } }', warning: null },
|
||||
{ cmd: 'files.delete', handler: 'FileHandler', transports: ['All'], description: 'Delete a melody file from the SD card.', contents: [{ field: 'name', type: 'string', required: true, notes: 'Filename including extension, e.g. "westminster.mel"' }], returns: null, example: '{ "v": 2, "cmd": "files.delete", "contents": { "name": "westminster.mel" } }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'log',
|
||||
label: 'log',
|
||||
description: 'Log verbosity control per output channel. Level 0 = off, 5 = verbose. Handler: LoggingHandler.',
|
||||
commands: [
|
||||
{ cmd: 'log.set_serial', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for the UART serial output.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_serial", "contents": { "level": 3 } }', warning: null },
|
||||
{ cmd: 'log.set_sd', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for SD card logging.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_sd", "contents": { "level": 2 } }', warning: null },
|
||||
{ cmd: 'log.set_mqtt', handler: 'LoggingHandler', transports: ['All'], description: 'Set log verbosity for MQTT log publishing.', contents: [{ field: 'level', type: 'int', required: true, notes: '0 = None, 1 = Error, 2 = Warning, 3 = Info, 4 = Debug, 5 = Verbose' }], returns: null, example: '{ "v": 2, "cmd": "log.set_mqtt", "contents": { "level": 1 } }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mqtt',
|
||||
label: 'mqtt',
|
||||
description: 'MQTT connectivity control. Handler: MQTTHandler.',
|
||||
commands: [
|
||||
{ cmd: 'mqtt.enable', handler: 'MQTTHandler', transports: ['All'], description: 'Enable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.enable" }', warning: null },
|
||||
{ cmd: 'mqtt.disable', handler: 'MQTTHandler', transports: ['All'], description: 'Disable MQTT connectivity.', contents: null, returns: null, example: '{ "v": 2, "cmd": "mqtt.disable" }', warning: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'telemetry',
|
||||
label: 'telemetry',
|
||||
description: 'Bell strike counters and thermal load monitoring. Handler: TelemetryHandler.',
|
||||
commands: [
|
||||
{ cmd: 'telemetry.get_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Get strike counts per bell channel.', contents: null, returns: '{ "strikes": { "0": 1234, "1": 567, ... } }', example: '{ "v": 2, "cmd": "telemetry.get_strikes" }', warning: null },
|
||||
{ cmd: 'telemetry.reset_strikes', handler: 'TelemetryHandler', transports: ['All'], description: 'Reset strike counters. Omit bell to reset all channels; include it to reset a single channel.', contents: [{ field: 'bell', type: 'int', required: false, notes: 'Channel index. If omitted, all channels are reset.' }], returns: null, example: '{ "v": 2, "cmd": "telemetry.reset_strikes" }\n{ "v": 2, "cmd": "telemetry.reset_strikes", "contents": { "bell": 2 } }', warning: null },
|
||||
{ cmd: 'telemetry.get_loads', handler: 'TelemetryHandler', transports: ['All'], description: 'Get heat load per bell channel, cooling status, and overload flags.', contents: null, returns: '{ "loads": { "0": 42.5, ... }, "cooling_active": false, "overloaded": [2, 5] }', example: '{ "v": 2, "cmd": "telemetry.get_loads" }', warning: null },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const LEGACY_MAP = [
|
||||
{ v1_cmd: 'ping', v1_action: '—', v2: 'ping' },
|
||||
{ v1_cmd: 'identify', v1_action: '—', v2: 'identify' },
|
||||
{ v1_cmd: 'playback', v1_action: '—', v2: 'playback' },
|
||||
{ v1_cmd: 'file_manager', v1_action: 'list_melodies', v2: 'files.list' },
|
||||
{ v1_cmd: 'file_manager', v1_action: 'download_melody', v2: 'files.download' },
|
||||
{ v1_cmd: 'file_manager', v1_action: 'delete_melody', v2: 'files.delete' },
|
||||
{ v1_cmd: 'relay_setup', v1_action: 'set_timings', v2: 'relay.set_durations' },
|
||||
{ v1_cmd: 'relay_setup', v1_action: 'set_outputs', v2: 'relay.set_outputs' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_outputs', v2: 'clock.set_outputs' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_timings', v2: 'clock.set_timings' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_alerts', v2: 'clock.set_alerts' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_backlight', v2: 'clock.set_backlight' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_silence', v2: 'clock.set_silence' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_rtc_time', v2: 'clock.set_time' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_physical_clock_time', v2: 'clock.set_face' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'pause_clock_updates', v2: 'clock.pause' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'resume_clock_updates', v2: 'clock.resume' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=true)', v2: 'clock.enable' },
|
||||
{ v1_cmd: 'clock_setup', v1_action: 'set_enabled (enabled=false)', v2: 'clock.disable' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'report_status', v2: 'system.status' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'get_device_time', v2: 'system.get_time' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'get_clock_time', v2: 'clock.get_face' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'get_firmware_status', v2: 'firmware.status' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'network_info', v2: 'network.info' },
|
||||
{ v1_cmd: 'system_info', v1_action: 'get_full_settings', v2: 'system.get_settings' },
|
||||
{ v1_cmd: 'system', v1_action: 'status', v2: 'system.status' },
|
||||
{ v1_cmd: 'system', v1_action: 'reset_defaults', v2: 'system.factory_reset' },
|
||||
{ v1_cmd: 'system', v1_action: 'commit_firmware', v2: 'firmware.commit' },
|
||||
{ v1_cmd: 'system', v1_action: 'rollback_firmware', v2: 'firmware.rollback' },
|
||||
{ v1_cmd: 'system', v1_action: 'get_firmware_status', v2: 'firmware.status' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_network_config', v2: 'network.set_config' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_serial_log_level', v2: 'log.set_serial' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_sd_log_level', v2: 'log.set_sd' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_mqtt_log_level', v2: 'log.set_mqtt' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=true)', v2: 'mqtt.enable' },
|
||||
{ v1_cmd: 'system', v1_action: 'set_mqtt_enabled (enabled=false)', v2: 'mqtt.disable' },
|
||||
{ v1_cmd: 'system', v1_action: 'restart', v2: 'system.restart' },
|
||||
{ v1_cmd: 'system', v1_action: 'reboot', v2: 'system.restart' },
|
||||
{ v1_cmd: 'system', v1_action: 'force_update', v2: 'ota.update' },
|
||||
{ v1_cmd: 'system', v1_action: 'custom_update', v2: 'ota.custom' },
|
||||
]
|
||||
|
||||
// Transport badge styles
|
||||
const TRANSPORT_META = {
|
||||
MQTT: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
||||
WebSocket: { color: 'var(--color-primary)', bg: 'var(--color-primary-subtle)' },
|
||||
HTTP: { color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
|
||||
UART: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
|
||||
UDP: { color: 'var(--color-info)', bg: 'var(--color-info-bg)' },
|
||||
All: { color: 'var(--color-text-muted)', bg: 'var(--color-bg-island)' },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TransportPill({ name }) {
|
||||
const meta = TRANSPORT_META[name] || TRANSPORT_META.All
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center',
|
||||
padding: 'var(--space-1) var(--space-2)',
|
||||
borderRadius: 'var(--radius-full)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: meta.color,
|
||||
backgroundColor: meta.bg,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CodeBlock({ code }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div style={{ position: 'relative' }} className="api-code-wrap">
|
||||
<pre style={{
|
||||
margin: 0, padding: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-success)',
|
||||
overflowX: 'auto', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{code}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="api-copy-btn"
|
||||
aria-label="Copy code"
|
||||
style={{
|
||||
position: 'absolute', top: 'var(--space-2)', right: 'var(--space-2)',
|
||||
padding: '2px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid var(--color-border-strong)',
|
||||
backgroundColor: copied ? 'var(--color-success-bg)' : 'var(--color-bg-elevated)',
|
||||
color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
|
||||
transition: 'all 0.15s ease',
|
||||
opacity: 0,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReturnBlock({ code }) {
|
||||
return (
|
||||
<pre style={{
|
||||
margin: 0, padding: 'var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-bg-abyss)',
|
||||
border: '1px solid var(--color-border)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
color: 'var(--color-info)',
|
||||
overflowX: 'auto', lineHeight: '1.6',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||||
}}>
|
||||
{code}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandCard({ command }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isUartAllowed = UART_WHITELIST.includes(command.cmd)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`api-cmd-card${open ? ' api-cmd-card--open' : ''}`}
|
||||
style={{
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
transition: 'border-color 0.15s ease',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left',
|
||||
gap: 'var(--space-3)',
|
||||
}}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{/* Left */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', minWidth: 0, flex: 1 }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-base)',
|
||||
fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', flexShrink: 0,
|
||||
}}>
|
||||
{command.cmd}
|
||||
</span>
|
||||
{command.warning && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||||
padding: '2px var(--space-2)', borderRadius: 'var(--radius-full)',
|
||||
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-warning)', backgroundColor: 'var(--color-warning-bg)', flexShrink: 0,
|
||||
}}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
|
||||
caution
|
||||
</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{command.description}
|
||||
</span>
|
||||
</div>
|
||||
{/* Right */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
|
||||
{command.transports.map(t => <TransportPill key={t} name={t} />)}
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
|
||||
style={{ color: 'var(--color-text-muted)', transform: open ? 'rotate(90deg)' : 'none', transition: 'transform 0.2s ease', flexShrink: 0, marginLeft: 'var(--space-1)' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
padding: 'var(--space-4)',
|
||||
display: 'flex', flexDirection: 'column', gap: 'var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-base)', color: 'var(--color-text-secondary)', lineHeight: '1.6' }}>
|
||||
{command.description}
|
||||
</p>
|
||||
|
||||
{command.warning && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
|
||||
padding: 'var(--space-3)', borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-warning-bg)', border: '1px solid var(--color-warning)',
|
||||
fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)',
|
||||
}}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0, marginTop: '1px' }} aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /></svg>
|
||||
<span>{command.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="api-detail-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
|
||||
{/* Left: meta + contents + returns */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
{/* Meta */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-4)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>Handler</div>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-info)' }}>{command.handler}</span>
|
||||
</div>
|
||||
{isUartAllowed && (
|
||||
<div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-1)' }}>UART</div>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '4px', padding: '2px var(--space-2)', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', color: 'var(--color-success)', backgroundColor: 'var(--color-success-bg)' }}>
|
||||
<svg width="6" height="6" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true"><circle cx="6" cy="6" r="6" /></svg>
|
||||
whitelisted
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contents */}
|
||||
{command.contents ? (
|
||||
<div>
|
||||
<div 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)' }}>
|
||||
Contents
|
||||
</div>
|
||||
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
|
||||
{['Field', 'Type', 'Req', 'Notes'].map(h => (
|
||||
<th key={h} style={{ padding: 'var(--space-2) var(--space-3)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{command.contents.map((field, i) => (
|
||||
<tr key={field.field} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
|
||||
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{field.field}</td>
|
||||
<td style={{ padding: 'var(--space-2) var(--space-3)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-info)' }}>{field.type}</td>
|
||||
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{field.required ? <span style={{ color: 'var(--color-danger)', fontWeight: 'var(--font-weight-semibold)' }}>yes</span> : <span style={{ color: 'var(--color-text-muted)' }}>no</span>}
|
||||
</td>
|
||||
<td style={{ padding: 'var(--space-2) var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{field.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>No contents required.</p>
|
||||
)}
|
||||
|
||||
{/* Returns */}
|
||||
{command.returns && (
|
||||
<div>
|
||||
<div 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)' }}>Returns</div>
|
||||
<ReturnBlock code={command.returns} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: example */}
|
||||
<div>
|
||||
<div 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)' }}>Example</div>
|
||||
<CodeBlock code={command.example} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NamespaceSection({ ns, searchQuery }) {
|
||||
const filteredCommands = useMemo(() => {
|
||||
if (!searchQuery) return ns.commands
|
||||
const q = searchQuery.toLowerCase()
|
||||
return ns.commands.filter(c =>
|
||||
c.cmd.toLowerCase().includes(q) ||
|
||||
c.description.toLowerCase().includes(q) ||
|
||||
c.handler.toLowerCase().includes(q)
|
||||
)
|
||||
}, [ns.commands, searchQuery])
|
||||
|
||||
if (searchQuery && filteredCommands.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 'var(--space-3)', paddingBottom: 'var(--space-3)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<h2 style={{ margin: 0, fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-lg)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', letterSpacing: 'var(--tracking-tight)' }}>
|
||||
{ns.label === 'Root' ? 'root' : ns.label}
|
||||
</h2>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '20px', height: '20px', padding: '0 6px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)' }}>
|
||||
{filteredCommands.length}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{ns.description}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{filteredCommands.map(cmd => <CommandCard key={cmd.cmd} command={cmd} />)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Transports
|
||||
// ---------------------------------------------------------------------------
|
||||
function TransportsTab() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', paddingTop: 'var(--space-2)' }}>
|
||||
<Card title="Transport Channels" subtitle="All available communication interfaces">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
|
||||
{['Transport', 'Address / Topic', 'Direction', 'Notes'].map(h => (
|
||||
<th key={h} style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TRANSPORTS.map((row, i) => (
|
||||
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)' }}><TransportPill name={row.transport} /></td>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>{row.address}</td>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{row.direction}</td>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="UART Whitelist" subtitle="Commands permitted on the hardware serial interface">
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
||||
{UART_WHITELIST.map(cmd => (
|
||||
<span key={cmd} style={{ display: 'inline-flex', alignItems: 'center', padding: 'var(--space-1) var(--space-3)', borderRadius: 'var(--radius-md)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)', backgroundColor: 'var(--color-primary-subtle)', border: '1px solid var(--color-border)' }}>
|
||||
{cmd}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Message Format" subtitle="Standard envelope for all v2 commands">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<CodeBlock code={'{\n "v": 2,\n "cmd": "<namespace>.<command>",\n "contents": { ... } // optional\n}'} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-3)' }}>
|
||||
{[
|
||||
{ field: 'v', type: 'int', notes: 'Protocol version. Always 2 for v2 firmware.' },
|
||||
{ field: 'cmd', type: 'string', notes: 'Command name, e.g. "relay.set_durations"' },
|
||||
{ field: 'contents', type: 'object?', notes: 'Optional payload. Omit entirely if not needed.' },
|
||||
].map(row => (
|
||||
<div key={row.field} style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', marginBottom: 'var(--space-1)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.field}</span>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-info)' }}>{row.type}</span>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{row.notes}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Legacy migration
|
||||
// ---------------------------------------------------------------------------
|
||||
function LegacyTab() {
|
||||
return (
|
||||
<div style={{ paddingTop: 'var(--space-2)' }}>
|
||||
<Card title="v1 → v2 Command Migration" subtitle="Map from v1 cmd+action pairs to their v2 equivalents">
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ backgroundColor: 'var(--color-bg-abyss)' }}>
|
||||
{['v1 cmd', 'v1 action', 'v2 command'].map(h => (
|
||||
<th key={h} style={{ padding: 'var(--space-3) var(--space-4)', textAlign: 'left', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', borderBottom: '1px solid var(--color-border)' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{LEGACY_MAP.map((row, i) => (
|
||||
<tr key={i} style={{ borderTop: i > 0 ? '1px solid var(--color-border)' : 'none', backgroundColor: i % 2 === 1 ? 'rgba(192,193,255,0.015)' : 'transparent' }}>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-warning)' }}>{row.v1_cmd}</td>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>{row.v1_action}</td>
|
||||
<td style={{ padding: 'var(--space-3) var(--space-4)', fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-primary)' }}>{row.v2}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab: Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
function CommandsTab({ activeNamespace, setActiveNamespace, searchQuery, setSearchQuery }) {
|
||||
const totalCmds = NAMESPACES.reduce((acc, ns) => acc + ns.commands.length, 0)
|
||||
const nsTabs = [{ key: 'all', label: 'All', count: totalCmds }, ...NAMESPACES.map(ns => ({ key: ns.id, label: ns.label, count: ns.commands.length }))]
|
||||
|
||||
const visibleNamespaces = useMemo(() => {
|
||||
if (activeNamespace === 'all') return NAMESPACES
|
||||
return NAMESPACES.filter(ns => ns.id === activeNamespace)
|
||||
}, [activeNamespace])
|
||||
|
||||
const noResults = searchQuery && visibleNamespaces.every(ns => {
|
||||
const q = searchQuery.toLowerCase()
|
||||
return ns.commands.filter(c => c.cmd.toLowerCase().includes(q) || c.description.toLowerCase().includes(q) || c.handler.toLowerCase().includes(q)).length === 0
|
||||
})
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)', paddingTop: 'var(--space-2)' }}>
|
||||
{/* Filters row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 200px', maxWidth: '340px' }}>
|
||||
<SearchBar value={searchQuery} onChange={setSearchQuery} placeholder="Search commands…" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)' }}>
|
||||
{nsTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setActiveNamespace(tab.key)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 'var(--space-1)',
|
||||
padding: 'var(--space-1) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontWeight: activeNamespace === tab.key ? 'var(--font-weight-semibold)' : 'var(--font-weight-normal)',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid',
|
||||
borderColor: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-border-strong)',
|
||||
backgroundColor: activeNamespace === tab.key ? 'var(--color-primary-subtle)' : 'transparent',
|
||||
color: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-text-muted)',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', minWidth: '16px', height: '16px', padding: '0 4px', borderRadius: 'var(--radius-full)', fontSize: 'var(--font-size-xs)', backgroundColor: activeNamespace === tab.key ? 'var(--color-primary-subtle)' : 'var(--color-bg-island)', color: activeNamespace === tab.key ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||
{visibleNamespaces.map(ns => <NamespaceSection key={ns.id} ns={ns} searchQuery={searchQuery} />)}
|
||||
{noResults && (
|
||||
<div style={{ textAlign: 'center', padding: 'var(--space-12)', color: 'var(--color-text-muted)', fontSize: 'var(--font-size-base)' }}>
|
||||
No commands match "{searchQuery}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function ApiReferencePage() {
|
||||
const [activeTab, setActiveTab] = useState('commands')
|
||||
const [activeNamespace, setActiveNamespace] = useState('all')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const TABS = [
|
||||
{ key: 'commands', label: 'Commands' },
|
||||
{ key: 'transports', label: 'Transports' },
|
||||
{ key: 'legacy', label: 'v1 → v2 Migration' },
|
||||
]
|
||||
|
||||
const totalCmds = NAMESPACES.reduce((a, n) => a + n.commands.length, 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.api-code-wrap:hover .api-copy-btn { opacity: 1 !important; }
|
||||
.api-cmd-card:hover { border-color: var(--color-border-strong) !important; }
|
||||
.api-cmd-card--open { border-color: rgba(192,193,255,0.25) !important; }
|
||||
@media (max-width: 768px) {
|
||||
.api-detail-grid { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="page-wrapper">
|
||||
<PageHeader
|
||||
title="API Reference"
|
||||
subtitle="Vesper Firmware Command Protocol v2"
|
||||
/>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Namespaces', value: NAMESPACES.length },
|
||||
{ label: 'Commands', value: totalCmds },
|
||||
{ label: 'Transports', value: 5 },
|
||||
{ label: 'Protocol', value: 'v2' },
|
||||
].map(stat => (
|
||||
<div key={stat.label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}>
|
||||
<span style={{ fontFamily: 'var(--font-family-display)', fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-primary)', lineHeight: 1 }}>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} variant="line" />
|
||||
|
||||
{activeTab === 'commands' && (
|
||||
<CommandsTab
|
||||
activeNamespace={activeNamespace}
|
||||
setActiveNamespace={setActiveNamespace}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'transports' && <TransportsTab />}
|
||||
{activeTab === 'legacy' && <LegacyTab />}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// frontend/src/pages/manufacturing/firmware/FirmwareApiPage.jsx
|
||||
// Firmware OTA API reference — rendered as a tab panel inside FirmwareManagerPage
|
||||
|
||||
import { useState } from 'react'
|
||||
import Tabs from '@/components/ui/Tabs'
|
||||
import Card from '@/components/ui/Card'
|
||||
|
||||
// ─── Param colour system ──────────────────────────────────────────────────────
|
||||
|
||||
const PARAM = {
|
||||
hwType: { color: 'var(--color-danger)', bg: 'var(--color-danger-bg)', label: '{hw_type}', eg: 'vesper_plus · chronos · agnus' },
|
||||
channel: { color: 'var(--color-info)', bg: 'var(--color-info-bg)', label: '{channel}', eg: 'stable · beta · alpha · testing' },
|
||||
version: { color: 'var(--color-success)', bg: 'var(--color-success-bg)', label: '{version}', eg: '1.0 · 2.5.1 · 1.2.3' },
|
||||
identity: { color: 'var(--color-warning)', bg: 'var(--color-warning-bg)', label: 'device_uid / sha256', eg: 'BSVSPR-26C13X-… · a3f1c8…' },
|
||||
}
|
||||
|
||||
// An inline coloured segment inside a URL path
|
||||
function PathParam({ kind }) {
|
||||
const p = PARAM[kind]
|
||||
return (
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: p.color,
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
}}>
|
||||
{p.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// A standalone pill tag (query param or body field)
|
||||
function ParamPill({ kind, name }) {
|
||||
const p = PARAM[kind]
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: p.color,
|
||||
backgroundColor: p.bg,
|
||||
border: `1px solid ${p.color}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '1px var(--space-2)',
|
||||
opacity: 0.9,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Endpoint data ────────────────────────────────────────────────────────────
|
||||
|
||||
// pathParts: array of { text, plain } | { kind }
|
||||
// query / body: array of { name, kind }
|
||||
|
||||
const GET_ENDPOINTS = [
|
||||
{
|
||||
label: 'Check for latest version',
|
||||
pathParts: [
|
||||
{ text: '/api/firmware/', plain: true },
|
||||
{ kind: 'hwType' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'channel' },
|
||||
{ text: '/latest', plain: true },
|
||||
],
|
||||
query: [
|
||||
{ name: '?hw_version', kind: 'version' },
|
||||
{ name: '?current_version', kind: 'version' },
|
||||
],
|
||||
desc: 'Returns metadata for the correct next firmware hop. Pass hw_version and current_version so the server resolves upgrade chains correctly.',
|
||||
},
|
||||
{
|
||||
label: 'Get specific version info',
|
||||
pathParts: [
|
||||
{ text: '/api/firmware/', plain: true },
|
||||
{ kind: 'hwType' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'channel' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'version' },
|
||||
{ text: '/info', plain: true },
|
||||
],
|
||||
desc: 'Returns metadata for a specific version. Used when resolving upgrade chains with min_fw_version constraints.',
|
||||
},
|
||||
{
|
||||
label: 'Get latest changelog',
|
||||
pathParts: [
|
||||
{ text: '/api/firmware/', plain: true },
|
||||
{ kind: 'hwType' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'channel' },
|
||||
{ text: '/latest/changelog', plain: true },
|
||||
],
|
||||
desc: 'Returns the full changelog for the latest firmware as plain text. Not served in metadata — for admin/display use only.',
|
||||
},
|
||||
{
|
||||
label: 'Get specific version changelog',
|
||||
pathParts: [
|
||||
{ text: '/api/firmware/', plain: true },
|
||||
{ kind: 'hwType' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'channel' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'version' },
|
||||
{ text: '/info/changelog', plain: true },
|
||||
],
|
||||
desc: 'Returns the full changelog for a specific firmware version as plain text.',
|
||||
},
|
||||
{
|
||||
label: 'Download firmware binary',
|
||||
pathParts: [
|
||||
{ text: '/api/firmware/', plain: true },
|
||||
{ kind: 'hwType' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'channel' },
|
||||
{ text: '/', plain: true },
|
||||
{ kind: 'version' },
|
||||
{ text: '/firmware.bin', plain: true },
|
||||
],
|
||||
desc: 'Streams the raw .bin file. Devices fetch this after confirming the version via /latest or /info.',
|
||||
},
|
||||
]
|
||||
|
||||
const POST_ENDPOINTS = [
|
||||
{
|
||||
label: 'OTA download event',
|
||||
pathParts: [{ text: '/api/ota/events/download', plain: true }],
|
||||
body: [
|
||||
{ name: 'device_uid', kind: 'identity' },
|
||||
{ name: 'hw_type', kind: 'hwType' },
|
||||
{ name: 'hw_version', kind: 'version' },
|
||||
{ name: 'from_version', kind: 'version' },
|
||||
{ name: 'to_version', kind: 'version' },
|
||||
{ name: 'channel', kind: 'channel' },
|
||||
],
|
||||
desc: 'Posted when the binary is fully written to the staged partition (before Update.end()). Best-effort — no retry on failure.',
|
||||
},
|
||||
{
|
||||
label: 'OTA flash confirmed',
|
||||
pathParts: [{ text: '/api/ota/events/flash', plain: true }],
|
||||
body: [
|
||||
{ name: 'device_uid', kind: 'identity' },
|
||||
{ name: 'hw_type', kind: 'hwType' },
|
||||
{ name: 'hw_version', kind: 'version' },
|
||||
{ name: 'from_version', kind: 'version' },
|
||||
{ name: 'to_version', kind: 'version' },
|
||||
{ name: 'channel', kind: 'channel' },
|
||||
{ name: 'sha256', kind: 'identity' },
|
||||
],
|
||||
desc: 'Posted after Update.end() succeeds — partition committed, device about to reboot. This is ground truth for fleet version tracking.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Endpoint card ────────────────────────────────────────────────────────────
|
||||
|
||||
function EndpointCard({ method, label, pathParts, query, body, desc }) {
|
||||
const isGet = method === 'GET'
|
||||
const methodColor = isGet ? 'var(--color-info)' : 'var(--color-success)'
|
||||
const methodBg = isGet ? 'var(--color-info-bg)' : 'var(--color-success-bg)'
|
||||
|
||||
return (
|
||||
<Card variant="outlined" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
|
||||
{/* Method badge + label */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-bold)',
|
||||
color: methodColor,
|
||||
backgroundColor: methodBg,
|
||||
border: `1px solid ${methodColor}`,
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
padding: '2px var(--space-2)',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{method}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontWeight: 'var(--font-weight-medium)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* URL path */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 0,
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-base)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
marginRight: 1,
|
||||
}}>
|
||||
console.bellsystems.net
|
||||
</span>
|
||||
{pathParts.map((p, i) =>
|
||||
p.plain
|
||||
? <span key={i} style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-primary)' }}>{p.text}</span>
|
||||
: <PathParam key={i} kind={p.kind} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Query params */}
|
||||
{query && query.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)', marginRight: 'var(--space-1)' }}>
|
||||
QUERY
|
||||
</span>
|
||||
{query.map((q) => <ParamPill key={q.name} name={q.name} kind={q.kind} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body fields */}
|
||||
{body && body.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-2)', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)', marginRight: 'var(--space-1)' }}>
|
||||
BODY
|
||||
</span>
|
||||
{body.map((f) => <ParamPill key={f.name} name={f.name} kind={f.kind} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<p style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
paddingTop: 'var(--space-2)',
|
||||
}}>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Legend panel ─────────────────────────────────────────────────────────────
|
||||
|
||||
function LegendPanel() {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
{Object.entries(PARAM).map(([key, p]) => (
|
||||
<Card key={key} variant="outlined" style={{ borderLeft: `3px solid ${p.color}` }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: p.color,
|
||||
}}>
|
||||
{p.label}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-family-mono)',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{p.eg}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<p style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.6,
|
||||
margin: 0,
|
||||
marginTop: 'var(--space-1)',
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
}}>
|
||||
Colour coding is consistent across path segments, query params, and body fields.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
const METHOD_TABS = [
|
||||
{ key: 'get', label: 'GET' },
|
||||
{ key: 'post', label: 'POST' },
|
||||
]
|
||||
|
||||
export default function FirmwareApiPage() {
|
||||
const [activeMethod, setActiveMethod] = useState('get')
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
|
||||
{/* Notice banner */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-3)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
}}>
|
||||
{/* Lock-open icon */}
|
||||
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<rect x="3" y="7" width="10" height="7" rx="1.5" />
|
||||
<path d="M5 7V5a3 3 0 0 1 6 0" />
|
||||
</svg>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', margin: 0 }}>
|
||||
All endpoints are <strong style={{ color: 'var(--color-text-primary)' }}>unauthenticated</strong> — devices call them directly without any API key or session token.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout — grid spans both the header row and the content row */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '70fr 30fr', columnGap: 'var(--space-6)', rowGap: 'var(--space-4)', alignItems: 'start' }}>
|
||||
|
||||
{/* Row 1 left: GET / POST tabs */}
|
||||
<Tabs tabs={METHOD_TABS} active={activeMethod} onChange={setActiveMethod} variant="line" />
|
||||
|
||||
{/* Row 1 right: "Parameter Legend" label — visually sits in the tab bar row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
paddingBottom: 'var(--space-2)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 'var(--tracking-wide)',
|
||||
}}>
|
||||
Parameter Legend
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Row 2 left: endpoint cards */}
|
||||
<div>
|
||||
{activeMethod === 'get' && GET_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} method="GET" {...ep} />)}
|
||||
{activeMethod === 'post' && POST_ENDPOINTS.map((ep) => <EndpointCard key={ep.label} method="POST" {...ep} />)}
|
||||
</div>
|
||||
|
||||
{/* Row 2 right: legend items (sticky) */}
|
||||
<div style={{ position: 'sticky', top: 'var(--space-6)' }}>
|
||||
<LegendPanel />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
// frontend/src/pages/manufacturing/firmware/FirmwareManager.jsx
|
||||
// Firmware Files tab — uses DataTable with column reorder, RowActions, Select
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import Button from '@/components/ui/Button'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Spinner from '@/components/ui/Spinner'
|
||||
import Modal from '@/components/ui/Modal'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import Select from '@/components/ui/Select'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import { fmtDateTimeMedium } from '@/lib/formatters'
|
||||
import DataTable from '@/components/ui/DataTable'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOARD_TYPES = [
|
||||
{ value: 'vesper', label: 'Vesper' },
|
||||
{ value: 'vesper_plus', label: 'Vesper+' },
|
||||
{ value: 'vesper_pro', label: 'Vesper Pro' },
|
||||
{ value: 'chronos', label: 'Chronos' },
|
||||
{ value: 'chronos_pro', label: 'Chronos Pro' },
|
||||
{ value: 'agnus', label: 'Agnus' },
|
||||
{ value: 'agnus_mini', label: 'Agnus Mini' },
|
||||
{ value: 'bespoke', label: 'Bespoke' },
|
||||
]
|
||||
|
||||
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.label]))
|
||||
const CHANNELS = ['stable', 'beta', 'alpha', 'testing']
|
||||
|
||||
const CHANNEL_VARIANT = { stable: 'success', beta: 'info', alpha: 'warning', testing: 'neutral' }
|
||||
const UPDATE_TYPE_VARIANT = { mandatory: 'info', emergency: 'danger', optional: 'neutral' }
|
||||
|
||||
// ─── All columns definition ───────────────────────────────────────────────────
|
||||
|
||||
const ALL_COLUMNS = [
|
||||
{ key: 'hw_type', label: 'Type', sortable: true, alwaysOn: true },
|
||||
{ key: 'channel', label: 'Channel', sortable: true },
|
||||
{ key: 'version', label: 'Version', sortable: true },
|
||||
{ key: 'update_type', label: 'Update Type', sortable: true },
|
||||
{ key: 'min_fw', label: 'Min FW' },
|
||||
{ key: 'size', label: 'Size' },
|
||||
{ key: 'sha256', label: 'SHA-256' },
|
||||
{ key: 'notes', label: 'Release Note' },
|
||||
{ key: 'uploaded_at', label: 'Uploaded', sortable: true },
|
||||
{ key: 'is_latest', label: 'Latest' },
|
||||
{ key: '__actions', label: '', align: 'right' },
|
||||
]
|
||||
|
||||
const DEFAULT_VISIBLE = ['hw_type', 'channel', 'version', 'update_type', 'min_fw', 'size', 'notes', 'uploaded_at', 'is_latest', '__actions']
|
||||
const COLS_KEY = 'fw_manager_visible_v2'
|
||||
const ORDER_KEY = 'fw_manager_order_v2'
|
||||
|
||||
function loadColPrefs() {
|
||||
try {
|
||||
const vis = JSON.parse(localStorage.getItem(COLS_KEY))
|
||||
const order = JSON.parse(localStorage.getItem(ORDER_KEY))
|
||||
return {
|
||||
visible: Array.isArray(vis) ? vis : DEFAULT_VISIBLE,
|
||||
order: Array.isArray(order) ? order : DEFAULT_VISIBLE,
|
||||
}
|
||||
} catch {
|
||||
return { visible: DEFAULT_VISIBLE, order: DEFAULT_VISIBLE }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso) { return fmtDateTimeMedium(iso) }
|
||||
|
||||
// ─── Changelog modal ──────────────────────────────────────────────────────────
|
||||
|
||||
function ChangelogModal({ fw, onClose }) {
|
||||
return (
|
||||
<Modal open={!!fw} onClose={onClose} title={fw ? `Changelog — ${fw.hw_type} v${fw.version} (${fw.channel})` : ''} size="md">
|
||||
{fw?.changelog ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
|
||||
{fw.changelog}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
|
||||
No changelog for this version.
|
||||
</p>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Upload / Edit modal ──────────────────────────────────────────────────────
|
||||
|
||||
function FirmwareFormModal({ initial, onClose, onSaved }) {
|
||||
const isEdit = !!initial
|
||||
|
||||
const [hwType, setHwType] = useState(initial?.hw_type ?? 'vesper')
|
||||
const [bespokeUid, setBespokeUid] = useState(initial?.bespoke_uid ?? '')
|
||||
const [channel, setChannel] = useState(initial?.channel ?? 'stable')
|
||||
const [version, setVersion] = useState(initial?.version ?? '')
|
||||
const [updateType, setUpdateType] = useState(initial?.update_type ?? 'mandatory')
|
||||
const [minFw, setMinFw] = useState(initial?.min_fw_version ?? '')
|
||||
const [changelog, setChangelog] = useState(initial?.changelog ?? '')
|
||||
const [releaseNote, setReleaseNote] = useState(initial?.release_note ?? '')
|
||||
const [file, setFile] = useState(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!isEdit && !file) { setError('A .bin file is required.'); return }
|
||||
if (hwType === 'bespoke' && !bespokeUid.trim()) { setError('Bespoke UID is required.'); return }
|
||||
setError(''); setUploading(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
if (!isEdit) {
|
||||
fd.append('hw_type', hwType); fd.append('channel', channel)
|
||||
fd.append('version', version); fd.append('update_type', updateType)
|
||||
if (minFw) fd.append('min_fw_version', minFw)
|
||||
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
||||
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
||||
fd.append('file', file)
|
||||
} else {
|
||||
fd.append('channel', channel); fd.append('version', version)
|
||||
fd.append('update_type', updateType); fd.append('min_fw_version', minFw)
|
||||
fd.append('changelog', changelog); fd.append('release_note', releaseNote)
|
||||
if (hwType === 'bespoke') fd.append('bespoke_uid', bespokeUid.trim())
|
||||
if (file) fd.append('file', file)
|
||||
}
|
||||
const token = localStorage.getItem('access_token')
|
||||
const url = isEdit ? `/api/firmware/${initial.id}` : '/api/firmware/upload'
|
||||
const method = isEdit ? 'PUT' : 'POST'
|
||||
const res = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, body: fd })
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}))
|
||||
throw new Error(err.detail || (isEdit ? 'Update failed' : 'Upload failed'))
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const UPDATE_TYPES = [
|
||||
{ value: 'mandatory', label: 'Mandatory', desc: 'Auto on reboot', colorVar: 'info' },
|
||||
{ value: 'emergency', label: 'Emergency', desc: 'Immediate push', colorVar: 'danger' },
|
||||
{ value: 'optional', label: 'Optional', desc: 'User-initiated', colorVar: 'success' },
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={isEdit ? 'Edit Release' : 'Upload Firmware Release'}
|
||||
size="xl"
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', justifyContent: 'flex-end' }}>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSubmit} loading={uploading}>
|
||||
{isEdit ? 'Save Changes' : 'Upload Firmware'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-6)' }}>
|
||||
{/* Left: config */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<FormField label="Board Type" name="hw_type" type="select" value={hwType} onChange={(e) => setHwType(e.target.value)} disabled={isEdit} hint={isEdit ? 'Cannot be changed after upload.' : undefined}>
|
||||
{BOARD_TYPES.map((bt) => <option key={bt.value} value={bt.value}>{bt.label}</option>)}
|
||||
</FormField>
|
||||
{hwType === 'bespoke' && (
|
||||
<FormField label="Bespoke UID" name="bespoke_uid" value={bespokeUid} onChange={(e) => setBespokeUid(e.target.value)} placeholder="e.g. client-athens-v1" disabled={isEdit} required />
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2)' }}>
|
||||
<FormField label="Channel" name="channel" type="select" value={channel} onChange={(e) => setChannel(e.target.value)}>
|
||||
{CHANNELS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</FormField>
|
||||
<FormField label="Version" name="version" value={version} onChange={(e) => setVersion(e.target.value)} placeholder="1.5" required />
|
||||
<FormField label="Min FW" name="min_fw" value={minFw} onChange={(e) => setMinFw(e.target.value)} placeholder="e.g. 1.2" />
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-2)' }}>Update Type</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2)' }}>
|
||||
{UPDATE_TYPES.map((opt) => {
|
||||
const active = updateType === opt.value
|
||||
return (
|
||||
<button key={opt.value} type="button" onClick={() => setUpdateType(opt.value)} style={{
|
||||
padding: 'var(--space-2)', borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${active ? `var(--color-${opt.colorVar})` : 'var(--color-border)'}`,
|
||||
backgroundColor: active ? `var(--color-${opt.colorVar}-bg)` : 'var(--color-bg-elevated)',
|
||||
color: active ? `var(--color-${opt.colorVar})` : 'var(--color-text-muted)',
|
||||
cursor: 'pointer', textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)' }}>{opt.label}</div>
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', opacity: 0.7, marginTop: 2 }}>{opt.desc}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle: changelog */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<FormField label="Changelog" name="changelog" type="textarea" value={changelog} onChange={(e) => setChangelog(e.target.value)}
|
||||
placeholder={'Full changelog — describe what changed, bugs fixed, new features, etc.\n\nLike the body of a commit message.'}
|
||||
style={{ flex: 1, minHeight: '200px' }} />
|
||||
</div>
|
||||
|
||||
{/* Right: release note + file */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<FormField label="Release Note" name="release_note" value={releaseNote} onChange={(e) => setReleaseNote(e.target.value)}
|
||||
placeholder="e.g. Fixed bell timing bug" maxLength={120} hint="Shown in the table and served with OTA metadata." />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-2)' }}>
|
||||
File Upload {isEdit && <span style={{ opacity: 0.7 }}>(leave empty to keep existing)</span>}
|
||||
</p>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f && f.name.endsWith('.bin')) setFile(f)
|
||||
else if (f) setError('Only .bin files are accepted.')
|
||||
}}
|
||||
style={{
|
||||
flex: 1, display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center', gap: 'var(--space-2)',
|
||||
border: `2px dashed ${file ? 'var(--color-primary)' : 'var(--color-border)'}`,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
backgroundColor: file ? 'var(--color-info-bg)' : 'var(--color-bg-elevated)',
|
||||
cursor: 'pointer', padding: 'var(--space-4)', minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
<input ref={fileInputRef} type="file" accept=".bin"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files[0]
|
||||
if (f && !f.name.endsWith('.bin')) { setError('Only .bin files are accepted.'); return }
|
||||
setFile(f || null)
|
||||
}}
|
||||
style={{ display: 'none' }} />
|
||||
{file ? (
|
||||
<>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-info)', wordBreak: 'break-all', textAlign: 'center' }}>{file.name}</span>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{formatBytes(file.size)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textAlign: 'center' }}>Click or drop <strong>.bin</strong> file</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function FirmwareManager() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canAdd = hasPermission('manufacturing', 'add')
|
||||
const canDelete = hasPermission('manufacturing', 'delete')
|
||||
|
||||
const [firmware, setFirmware] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [hwTypeFilter, setHwTypeFilter] = useState('')
|
||||
const [channelFilter, setChannelFilter] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortKey, setSortKey] = useState('uploaded_at')
|
||||
const [sortDir, setSortDir] = useState('desc')
|
||||
|
||||
const [colPrefs, setColPrefs] = useState(loadColPrefs)
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [changelogFw, setChangelogFw] = useState(null)
|
||||
|
||||
const handleColChange = (nextVisible) => {
|
||||
// nextVisible is the new ordered+visible array from DataTable drag reorder
|
||||
const nextPrefs = { visible: nextVisible, order: nextVisible }
|
||||
setColPrefs(nextPrefs)
|
||||
localStorage.setItem(COLS_KEY, JSON.stringify(nextVisible))
|
||||
localStorage.setItem(ORDER_KEY, JSON.stringify(nextVisible))
|
||||
}
|
||||
|
||||
const fetchFirmware = async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (hwTypeFilter) params.set('hw_type', hwTypeFilter)
|
||||
if (channelFilter) params.set('channel', channelFilter)
|
||||
const qs = params.toString()
|
||||
const data = await api.get(`/firmware${qs ? `?${qs}` : ''}`)
|
||||
setFirmware(data.firmware)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { fetchFirmware() }, [hwTypeFilter, channelFilter])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.delete(`/firmware/${deleteTarget.id}`)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setDeleteTarget(null); setDeleting(false)
|
||||
await fetchFirmware()
|
||||
}
|
||||
}
|
||||
|
||||
// Filter + search + sort
|
||||
const filteredFirmware = firmware
|
||||
.filter((fw) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return (
|
||||
(fw.hw_type || '').toLowerCase().includes(q) ||
|
||||
(fw.channel || '').toLowerCase().includes(q) ||
|
||||
(fw.version || '').toLowerCase().includes(q) ||
|
||||
(fw.update_type || '').toLowerCase().includes(q) ||
|
||||
(fw.changelog || '').toLowerCase().includes(q) ||
|
||||
(fw.release_note || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1
|
||||
if (sortKey === 'hw_type') return dir * (a.hw_type || '').localeCompare(b.hw_type || '')
|
||||
if (sortKey === 'channel') return dir * (a.channel || '').localeCompare(b.channel || '')
|
||||
if (sortKey === 'version') return dir * (a.version || '').localeCompare(b.version || '')
|
||||
if (sortKey === 'update_type') return dir * (a.update_type || '').localeCompare(b.update_type || '')
|
||||
if (sortKey === 'uploaded_at') return dir * ((a.uploaded_at || '') < (b.uploaded_at || '') ? -1 : 1)
|
||||
return 0
|
||||
})
|
||||
|
||||
// Build columns for DataTable based on current visible order
|
||||
const { visible: visibleKeys } = colPrefs
|
||||
const columns = visibleKeys
|
||||
.map((k) => ALL_COLUMNS.find((c) => c.key === k))
|
||||
.filter(Boolean)
|
||||
.map((col) => ({ ...col, render: (fw) => renderCell(col, fw) }))
|
||||
|
||||
function renderCell(col, fw) {
|
||||
switch (col.key) {
|
||||
case 'hw_type': return (
|
||||
<div>
|
||||
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-semibold)' }}>
|
||||
{BOARD_TYPE_LABELS[fw.hw_type] || fw.hw_type}
|
||||
</span>
|
||||
{fw.bespoke_uid && (
|
||||
<div style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
{fw.bespoke_uid}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
case 'channel': return <StatusBadge variant={CHANNEL_VARIANT[fw.channel] || 'neutral'}>{fw.channel}</StatusBadge>
|
||||
case 'version': return <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-primary)' }}>{fw.version}</span>
|
||||
case 'update_type': return <StatusBadge variant={UPDATE_TYPE_VARIANT[fw.update_type] || 'neutral'}>{fw.update_type}</StatusBadge>
|
||||
case 'min_fw': return <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{fw.min_fw_version || '—'}</span>
|
||||
case 'size': return <span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{formatBytes(fw.size_bytes)}</span>
|
||||
case 'sha256': return (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fw.sha256}>
|
||||
{fw.sha256 ? fw.sha256.slice(0, 12) + '…' : '—'}
|
||||
</span>
|
||||
)
|
||||
case 'notes': return (
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-secondary)', maxWidth: 240, display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={fw.release_note || ''}>
|
||||
{fw.release_note || <span style={{ color: 'var(--color-text-muted)', fontStyle: 'italic' }}>—</span>}
|
||||
</span>
|
||||
)
|
||||
case 'uploaded_at': return <span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{formatDate(fw.uploaded_at)}</span>
|
||||
case 'is_latest': return fw.is_latest ? <StatusBadge variant="success">latest</StatusBadge> : null
|
||||
case '__actions': {
|
||||
const actions = [
|
||||
{ label: 'View Changelog', onClick: () => setChangelogFw(fw) },
|
||||
{ label: 'Copy .bin URL', onClick: () => {
|
||||
const url = `${window.location.origin}/api/firmware/${fw.hw_type}/${fw.channel}/${fw.version}/firmware.bin`
|
||||
navigator.clipboard.writeText(url).catch(() => {})
|
||||
}},
|
||||
...(canAdd ? [{ label: 'Edit Release', onClick: () => setEditTarget(fw), divider: true }] : []),
|
||||
...(canDelete ? [{ label: 'Delete Release', onClick: () => setDeleteTarget(fw), color: 'var(--color-danger)' }] : []),
|
||||
]
|
||||
return <RowActions actions={actions} />
|
||||
}
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<div style={{ width: 130, flexShrink: 0 }}>
|
||||
<Select value={hwTypeFilter} onChange={(e) => setHwTypeFilter(e.target.value)} placeholder="All Types">
|
||||
<option value="">All Types</option>
|
||||
{BOARD_TYPES.map((bt) => <option key={bt.value} value={bt.value}>{bt.label}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ width: 140, flexShrink: 0 }}>
|
||||
<Select value={channelFilter} onChange={(e) => setChannelFilter(e.target.value)} placeholder="All Channels">
|
||||
<option value="">All Channels</option>
|
||||
{CHANNELS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0, maxWidth: 320 }}>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="Search firmware…" />
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
{canAdd && (
|
||||
<Button variant="primary" onClick={() => setShowUpload(true)}>
|
||||
+ Upload
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredFirmware}
|
||||
loading={loading}
|
||||
keyField="id"
|
||||
emptyMessage="No firmware versions found."
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={(key, dir) => { setSortKey(key); setSortDir(dir) }}
|
||||
allColumns={ALL_COLUMNS.filter((c) => c.key !== '__actions')}
|
||||
visibleKeys={visibleKeys}
|
||||
onColumnChange={handleColChange}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<ChangelogModal fw={changelogFw} onClose={() => setChangelogFw(null)} />
|
||||
|
||||
{(showUpload || editTarget) && (
|
||||
<FirmwareFormModal
|
||||
initial={editTarget ?? null}
|
||||
onClose={() => { setShowUpload(false); setEditTarget(null) }}
|
||||
onSaved={async () => { setShowUpload(false); setEditTarget(null); await fetchFirmware() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
variant="danger"
|
||||
title="Delete Firmware Release?"
|
||||
message={deleteTarget ? `Delete ${BOARD_TYPE_LABELS[deleteTarget.hw_type] || deleteTarget.hw_type} v${deleteTarget.version} (${deleteTarget.channel})? This cannot be undone.` : ''}
|
||||
confirmLabel="Delete"
|
||||
loading={deleting}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// frontend/src/pages/manufacturing/firmware/FirmwareManagerPage.jsx
|
||||
// Firmware Manager — wrapper page with PageHeader and two tabs:
|
||||
// "Firmware Files" → FirmwareManager
|
||||
// "Flashing Assets" → FlashAssetManager
|
||||
|
||||
import { useState } from 'react'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Tabs from '@/components/ui/Tabs'
|
||||
import FirmwareManager from './FirmwareManager'
|
||||
import FlashAssetManager from '@/pages/engineering/manufacturing/FlashAssetManager'
|
||||
import FirmwareApiPage from './FirmwareApiPage'
|
||||
|
||||
const TABS = [
|
||||
{ key: 'firmware', label: 'Firmware Files' },
|
||||
{ key: 'assets', label: 'Flashing Assets' },
|
||||
{ key: 'api', label: 'Firmware API' },
|
||||
]
|
||||
|
||||
export default function FirmwareManagerPage() {
|
||||
const [activeTab, setActiveTab] = useState('firmware')
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader
|
||||
title="Firmware Manager"
|
||||
subtitle="Manage firmware releases and flash assets for all board types"
|
||||
/>
|
||||
|
||||
<Tabs tabs={TABS} active={activeTab} onChange={setActiveTab} variant="line" />
|
||||
|
||||
{activeTab === 'firmware' && <FirmwareManager />}
|
||||
{activeTab === 'assets' && <FlashAssetManager embedded />}
|
||||
{activeTab === 'api' && <FirmwareApiPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// frontend/src/pages/manufacturing/DeviceInventory.jsx
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import DataTable from '@/components/ui/DataTable'
|
||||
import StatusBadge from '@/components/ui/StatusBadge'
|
||||
import Pagination from '@/components/ui/Pagination'
|
||||
import SearchBar from '@/components/ui/SearchBar'
|
||||
import Select from '@/components/ui/Select'
|
||||
import RowActions from '@/components/ui/RowActions'
|
||||
import Icon from '@/components/ui/Icon'
|
||||
import NewBatchModal from '@/modals/engineering/manufacturing/NewBatchModal'
|
||||
import { fmtDateMedium } from '@/lib/formatters'
|
||||
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
||||
import DeleteUnprovisionedModal from '@/modals/engineering/manufacturing/DeleteUnprovisionedModal'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOARD_TYPE_LABELS = {
|
||||
vesper: 'Vesper', vesper_plus: 'Vesper+', vesper_pro: 'Vesper Pro',
|
||||
agnus: 'Agnus', agnus_mini: 'Agnus Mini',
|
||||
chronos: 'Chronos', chronos_pro: 'Chronos Pro',
|
||||
}
|
||||
|
||||
const BOARD_TYPE_OPTIONS = [
|
||||
{ value: '', label: 'All Types' },
|
||||
{ value: 'vesper_pro', label: 'Vesper Pro' },
|
||||
{ value: 'vesper_plus', label: 'Vesper+' },
|
||||
{ value: 'vesper', label: 'Vesper' },
|
||||
{ value: 'agnus', label: 'Agnus' },
|
||||
{ value: 'agnus_mini', label: 'Agnus Mini' },
|
||||
{ value: 'chronos_pro', label: 'Chronos Pro' },
|
||||
{ value: 'chronos', label: 'Chronos' },
|
||||
]
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
{ value: 'manufactured', label: 'Manufactured' },
|
||||
{ value: 'flashed', label: 'Flashed' },
|
||||
{ value: 'provisioned', label: 'Provisioned' },
|
||||
{ value: 'sold', label: 'Sold' },
|
||||
{ value: 'claimed', label: 'Claimed' },
|
||||
{ value: 'decommissioned', label: 'Decommissioned' },
|
||||
]
|
||||
|
||||
// Maps mfg_status → StatusBadge variant
|
||||
const STATUS_VARIANT = {
|
||||
manufactured: 'neutral',
|
||||
flashed: 'info',
|
||||
provisioned: 'warning',
|
||||
sold: 'success',
|
||||
claimed: 'success',
|
||||
decommissioned: 'danger',
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 25
|
||||
const PAGE_SIZE_OPTIONS = [25, 50, 100]
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'serial', label: 'Serial Number', defaultOn: true, alwaysOn: true },
|
||||
{ key: 'type', label: 'Type', defaultOn: true, sortable: true },
|
||||
{ key: 'version', label: 'Revision', defaultOn: true, sortable: true },
|
||||
{ key: 'status', label: 'Status', defaultOn: true },
|
||||
{ key: 'batch', label: 'Batch', defaultOn: true },
|
||||
{ key: 'created', label: 'Created', defaultOn: true, sortable: true },
|
||||
{ key: 'customer', label: 'Customer', defaultOn: true, sortable: true },
|
||||
{ key: 'name', label: 'Device Name', defaultOn: false, sortable: true },
|
||||
]
|
||||
|
||||
const COL_PREF_KEY = 'mfg_inventory_cols_v2'
|
||||
|
||||
function loadColPrefs() {
|
||||
try {
|
||||
const raw = localStorage.getItem(COL_PREF_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return COLUMNS.filter((c) => c.defaultOn).map((c) => c.key)
|
||||
}
|
||||
|
||||
function saveColPrefs(keys) {
|
||||
try { localStorage.setItem(COL_PREF_KEY, JSON.stringify(keys)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatHwVersion(v) {
|
||||
if (!v) return '—'
|
||||
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
|
||||
const n = parseInt(v, 10)
|
||||
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
|
||||
}
|
||||
|
||||
function formatDate(iso) { return fmtDateMedium(iso) }
|
||||
|
||||
// ─── Stat Card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({ label, value, accent }) {
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'rgba(28, 32, 38, 0.30)',
|
||||
backdropFilter: 'var(--blur-modal)',
|
||||
WebkitBackdropFilter: 'var(--blur-modal)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
padding: 'var(--space-4) var(--space-5)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--space-1)',
|
||||
}}>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', fontWeight: 'var(--font-weight-semibold)' }}>{label}</p>
|
||||
<p style={{ fontSize: 'var(--font-size-2xl)', fontFamily: 'var(--font-family-display)', fontWeight: 'var(--font-weight-bold)', color: accent || 'var(--color-text-primary)', letterSpacing: 'var(--tracking-display)', lineHeight: 1 }}>{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceInventory() {
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const canDelete = hasPermission('manufacturing', 'delete')
|
||||
|
||||
const [devices, setDevices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [customerMap, setCustomerMap] = useState({})
|
||||
|
||||
// Sorting
|
||||
const [sortKey, setSortKey] = useState('')
|
||||
const [sortDir, setSortDir] = useState('asc')
|
||||
|
||||
// Column visibility + order
|
||||
const [visibleCols, setVisibleCols] = useState(loadColPrefs)
|
||||
|
||||
// Modals
|
||||
const [showBatch, setShowBatch] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [showDeleteUnprovisioned, setShowDeleteUnprovisioned] = useState(false)
|
||||
|
||||
// ─── Fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set('search', search)
|
||||
if (statusFilter) params.set('status', statusFilter)
|
||||
if (typeFilter) params.set('hw_type', typeFilter)
|
||||
params.set('limit', '500')
|
||||
const data = await api.get(`/manufacturing/devices${params.toString() ? `?${params}` : ''}`)
|
||||
const devs = data.devices || []
|
||||
setDevices(devs)
|
||||
setPage(1)
|
||||
|
||||
// Fetch customer names for assigned devices
|
||||
const customerIds = [...new Set(devs.map((d) => d.customer_id).filter(Boolean))]
|
||||
if (customerIds.length) {
|
||||
const entries = await Promise.all(
|
||||
customerIds.map((id) =>
|
||||
api.get(`/manufacturing/customers/${id}`)
|
||||
.then((c) => [id, c])
|
||||
.catch(() => [id, null])
|
||||
)
|
||||
)
|
||||
setCustomerMap(Object.fromEntries(entries))
|
||||
} else {
|
||||
setCustomerMap({})
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [search, statusFilter, typeFilter])
|
||||
|
||||
useEffect(() => { fetchDevices() }, [fetchDevices])
|
||||
|
||||
// ─── Sort ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const handleSort = (key, dir) => {
|
||||
setSortKey(key)
|
||||
setSortDir(dir)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// ─── Derived ──────────────────────────────────────────────────────────────
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sortKey) return devices
|
||||
return [...devices].sort((a, b) => {
|
||||
let av, bv
|
||||
if (sortKey === 'type') { av = BOARD_TYPE_LABELS[a.hw_type] || a.hw_type || ''; bv = BOARD_TYPE_LABELS[b.hw_type] || b.hw_type || '' }
|
||||
else if (sortKey === 'version') { av = a.hw_version || ''; bv = b.hw_version || '' }
|
||||
else if (sortKey === 'created') { av = a.created_at || ''; bv = b.created_at || '' }
|
||||
else if (sortKey === 'customer') {
|
||||
const ca = customerMap[a.customer_id]; const cb = customerMap[b.customer_id]
|
||||
av = ca ? ([ca.name, ca.surname].filter(Boolean).join(' ') || ca.email || '') : ''
|
||||
bv = cb ? ([cb.name, cb.surname].filter(Boolean).join(' ') || cb.email || '') : ''
|
||||
}
|
||||
else if (sortKey === 'name') { av = a.name || ''; bv = b.name || '' }
|
||||
else { av = ''; bv = '' }
|
||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [devices, sortKey, sortDir, customerMap])
|
||||
|
||||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize)
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: devices.length,
|
||||
manufactured: devices.filter((d) => d.mfg_status === 'manufactured').length,
|
||||
provisioned: devices.filter((d) => d.mfg_status === 'provisioned').length,
|
||||
sold: devices.filter((d) => ['sold','claimed'].includes(d.mfg_status)).length,
|
||||
}
|
||||
|
||||
// ─── Column management ────────────────────────────────────────────────────
|
||||
// onColumnChange receives either a full ordered array (from drag-reorder)
|
||||
// or a single key string (from checkbox toggle via DataTable's internal toggle).
|
||||
// We normalise both cases here.
|
||||
|
||||
const handleColumnChange = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
// Drag reorder — payload is the new ordered array of visible keys
|
||||
saveColPrefs(payload)
|
||||
setVisibleCols(payload)
|
||||
} else {
|
||||
// Single key toggle
|
||||
const col = COLUMNS.find((c) => c.key === payload)
|
||||
if (col?.alwaysOn) return
|
||||
setVisibleCols((prev) => {
|
||||
const next = prev.includes(payload)
|
||||
? prev.filter((k) => k !== payload)
|
||||
: [...prev, payload]
|
||||
saveColPrefs(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Table columns definition ─────────────────────────────────────────────
|
||||
|
||||
const allCols = COLUMNS.map((c) => ({ ...c, pickerLabel: c.label }))
|
||||
|
||||
// Define all possible column renderers keyed by column key
|
||||
const COL_DEFS = {
|
||||
serial: {
|
||||
key: 'serial',
|
||||
label: 'Serial Number',
|
||||
render: (d) => (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
|
||||
{d.serial_number}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
type: {
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
sortable: true,
|
||||
render: (d) => (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||
{BOARD_TYPE_LABELS[d.hw_type] || d.hw_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
version: {
|
||||
key: 'version',
|
||||
label: 'Revision',
|
||||
sortable: true,
|
||||
render: (d) => (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>
|
||||
{formatHwVersion(d.hw_version)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
status: {
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (d) => (
|
||||
<StatusBadge variant={STATUS_VARIANT[d.mfg_status] || 'neutral'}>
|
||||
{d.mfg_status}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
batch: {
|
||||
key: 'batch',
|
||||
label: 'Batch',
|
||||
render: (d) => (
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{d.batch_id || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
created: {
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
sortable: true,
|
||||
render: (d) => (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
{formatDate(d.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
customer: {
|
||||
key: 'customer',
|
||||
label: 'Customer',
|
||||
sortable: true,
|
||||
render: (d) => {
|
||||
const c = customerMap[d.customer_id]
|
||||
if (!d.customer_id) return <span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>—</span>
|
||||
if (!c) return <span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{d.customer_id}</span>
|
||||
return (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||
{[c.name, c.surname].filter(Boolean).join(' ') || c.email || d.customer_id}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
name: {
|
||||
key: 'name',
|
||||
label: 'Device Name',
|
||||
sortable: true,
|
||||
render: (d) => (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)' }}>
|
||||
{d.name || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const ACTIONS_COL = {
|
||||
key: '_actions',
|
||||
label: '',
|
||||
width: '52px',
|
||||
render: (d) => (
|
||||
<RowActions
|
||||
actions={[
|
||||
{ label: 'View Detail', icon: <Icon name="eye" size={14} />, onClick: () => navigate(`/manufacturing/devices/${d.serial_number}`) },
|
||||
...(canDelete ? [{ label: 'Delete', icon: <Icon name="delete" size={14} />, color: 'var(--color-danger)', onClick: () => setDeleteTarget(d) }] : []),
|
||||
]}
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
// Build cols in visibleCols order so drag-reorder is reflected immediately
|
||||
const cols = [
|
||||
...visibleCols.map((key) => COL_DEFS[key]).filter(Boolean),
|
||||
ACTIONS_COL,
|
||||
]
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Device Inventory" subtitle="All manufactured Bell Systems units">
|
||||
<RowActions
|
||||
variant="secondary"
|
||||
size="md"
|
||||
actions={[
|
||||
{ label: 'New Batch', icon: <Icon name="plus" size={14} />, onClick: () => setShowBatch(true) },
|
||||
{ label: 'Provision New', icon: <Icon name="plus" size={14} />, divider: true, onClick: () => navigate('/manufacturing/provision') },
|
||||
{ label: 'Delete Not Provisioned', icon: <Icon name="delete" size={14} />, color: 'var(--color-danger)', divider: true, onClick: () => setShowDeleteUnprovisioned(true) },
|
||||
]}
|
||||
/>
|
||||
</PageHeader>
|
||||
|
||||
{/* Stats strip */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--space-4)' }}>
|
||||
<StatCard label="Total Devices" value={loading ? '…' : stats.total} />
|
||||
<StatCard label="Manufactured" value={loading ? '…' : stats.manufactured} accent="var(--color-text-muted)" />
|
||||
<StatCard label="Provisioned" value={loading ? '…' : stats.provisioned} accent="var(--color-warning)" />
|
||||
<StatCard label="Sold / Claimed" value={loading ? '…' : stats.sold} accent="var(--color-success)" />
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: '1 1 220px', minWidth: 0 }}>
|
||||
<SearchBar
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search serial, batch, name…"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={STATUS_OPTIONS}
|
||||
placeholder="All Statuses"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 150 }}>
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={setTypeFilter}
|
||||
options={BOARD_TYPE_OPTIONS}
|
||||
placeholder="All Types"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<DataTable
|
||||
columns={cols}
|
||||
allColumns={allCols}
|
||||
visibleKeys={visibleCols}
|
||||
onColumnChange={handleColumnChange}
|
||||
data={paged}
|
||||
keyField="serial_number"
|
||||
loading={loading}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
onRowClick={(d) => navigate(`/manufacturing/devices/${d.serial_number}`)}
|
||||
emptyMessage="No devices found"
|
||||
emptyDescription={search || statusFilter || typeFilter ? 'Try clearing your filters.' : 'Create your first batch to get started.'}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={sorted.length}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
pageSizes={PAGE_SIZE_OPTIONS}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<NewBatchModal
|
||||
open={showBatch}
|
||||
onClose={() => setShowBatch(false)}
|
||||
onCreated={() => fetchDevices()}
|
||||
/>
|
||||
<DeleteDeviceModal
|
||||
open={!!deleteTarget}
|
||||
device={deleteTarget}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onDeleted={() => { setDeleteTarget(null); fetchDevices() }}
|
||||
/>
|
||||
<DeleteUnprovisionedModal
|
||||
open={showDeleteUnprovisioned}
|
||||
devices={devices}
|
||||
onClose={() => setShowDeleteUnprovisioned(false)}
|
||||
onDeleted={() => fetchDevices()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
// frontend/src/pages/manufacturing/DeviceInventoryDetail.jsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
import Button from '@/components/ui/Button'
|
||||
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 Modal from '@/components/ui/Modal'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import { fmtDateTimeMedium, toDatetimeLocal as toDatetimeLocalFmt } from '@/lib/formatters'
|
||||
import DeleteDeviceModal from '@/modals/engineering/manufacturing/DeleteDeviceModal'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const BOARD_TYPE_LABELS = {
|
||||
vesper: 'Vesper', vesper_plus: 'Vesper+', vesper_pro: 'Vesper Pro',
|
||||
agnus: 'Agnus', agnus_mini: 'Agnus Mini',
|
||||
chronos: 'Chronos', chronos_pro: 'Chronos Pro',
|
||||
}
|
||||
|
||||
const STATUS_VARIANT = {
|
||||
manufactured: 'neutral', flashed: 'info', provisioned: 'warning',
|
||||
sold: 'success', claimed: 'success', decommissioned: 'danger',
|
||||
}
|
||||
|
||||
// Each step has CSS var refs for color + per-step hex accents for gradient connectors
|
||||
const LIFECYCLE = [
|
||||
{ key: 'manufactured', label: 'Manufactured', icon: '🔩', colorVar: 'var(--color-info)', bgVar: 'var(--color-info-bg)', accentHex: '#4da8c8', bgHex: '#1a2e3f' },
|
||||
{ key: 'flashed', label: 'Flashed', icon: '⚡', colorVar: 'var(--color-warning)', bgVar: 'var(--color-warning-bg)', accentHex: '#c9a83c', bgHex: '#2e2800' },
|
||||
{ key: 'provisioned', label: 'Provisioned', icon: '📡', colorVar: 'var(--color-primary)', bgVar: 'var(--color-primary-subtle)', accentHex: '#c97a28', bgHex: '#2e1a00' },
|
||||
{ key: 'sold', label: 'Sold', icon: '📦', colorVar: 'var(--color-success)', bgVar: 'var(--color-success-bg)', accentHex: '#3daa6a', bgHex: '#0e2a1a' },
|
||||
{ key: 'claimed', label: 'Claimed', icon: '✅', colorVar: 'var(--color-success)', bgVar: 'var(--color-success-bg)', accentHex: '#22c55e', bgHex: '#0a2416' },
|
||||
{ key: 'decommissioned', label: 'Decommissioned', icon: '🗑', colorVar: 'var(--color-danger)', bgVar: 'var(--color-danger-bg)', accentHex: '#ef4444', bgHex: '#2a0a0a' },
|
||||
]
|
||||
|
||||
const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i]))
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatHwVersion(v) {
|
||||
if (!v) return '—'
|
||||
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`
|
||||
const n = parseInt(v, 10)
|
||||
return isNaN(n) ? `Rev ${v}` : `Rev ${n}.0`
|
||||
}
|
||||
|
||||
function formatDate(iso) { return fmtDateTimeMedium(iso) }
|
||||
function toDatetimeLocal(iso) { return toDatetimeLocalFmt(iso) }
|
||||
|
||||
// ─── Field display ────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({ label, value, mono = false }) {
|
||||
return (
|
||||
<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-1)' }}>
|
||||
{label}
|
||||
</p>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontFamily: mono ? 'var(--font-family-mono)' : undefined, color: 'var(--color-text-primary)' }}>
|
||||
{value || '—'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Customer Search Modal ────────────────────────────────────────────────────
|
||||
|
||||
function CustomerSearchModal({ onSelect, onClose }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
|
||||
const search = useCallback(async (q) => {
|
||||
setSearching(true)
|
||||
try {
|
||||
const data = await api.get(`/manufacturing/customers/search?q=${encodeURIComponent(q)}`)
|
||||
setResults(data.results || [])
|
||||
} catch {
|
||||
setResults([])
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => search(query), 250)
|
||||
return () => clearTimeout(t)
|
||||
}, [query, search])
|
||||
|
||||
return (
|
||||
<Modal open onClose={onClose} title="Assign to Customer" size="sm">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<FormField
|
||||
label="Search"
|
||||
name="customer_search"
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Name, email, phone, org…"
|
||||
autoFocus
|
||||
/>
|
||||
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden', maxHeight: 260, overflowY: 'auto' }}>
|
||||
{results.length === 0 ? (
|
||||
<p style={{ padding: 'var(--space-3)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
{searching ? 'Searching…' : query ? 'No customers found.' : 'Type to search customers…'}
|
||||
</p>
|
||||
) : results.map((c) => {
|
||||
const fullName = [c.name, c.surname].filter(Boolean).join(' ') || c.email || c.id
|
||||
return (
|
||||
<button key={c.id} type="button" onClick={() => onSelect(c)}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'left',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
cursor: 'pointer', color: 'var(--color-text-primary)',
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-elevated)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'var(--color-bg-surface)'}
|
||||
>
|
||||
<span style={{ fontWeight: 'var(--font-weight-semibold)' }}>{fullName}</span>
|
||||
{c.organization && <span style={{ display: 'block', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{c.organization}</span>}
|
||||
{c.city && <span style={{ display: 'block', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{c.city}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lifecycle Edit Modal ─────────────────────────────────────────────────────
|
||||
|
||||
function LifecycleEditModal({ open, entry, stepMeta, isCurrent, onSave, onDelete, onClose }) {
|
||||
const isNew = !entry
|
||||
const [dateVal, setDateVal] = useState(() => toDatetimeLocal(isNew ? new Date().toISOString() : entry?.date))
|
||||
const [note, setNote] = useState(entry?.note || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDateVal(toDatetimeLocal(isNew ? new Date().toISOString() : entry?.date))
|
||||
setNote(entry?.note || '')
|
||||
setConfirmDel(false)
|
||||
}
|
||||
}, [open, entry, isNew])
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const isoDate = dateVal ? new Date(dateVal).toISOString() : new Date().toISOString()
|
||||
await onSave(isoDate, note)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await onDelete()
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setConfirmDel(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={isNew ? `Set ${stepMeta?.label}` : `Edit ${stepMeta?.label}`}
|
||||
size="sm"
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
{!isNew && !isCurrent && (
|
||||
<Button variant="danger" size="sm" onClick={() => setConfirmDel(true)}>
|
||||
Delete Step
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving}>
|
||||
{isNew ? 'Create' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<FormField
|
||||
label="Date & Time"
|
||||
name="lc_date"
|
||||
type="datetime-local"
|
||||
value={dateVal}
|
||||
onChange={(e) => setDateVal(e.target.value)}
|
||||
/>
|
||||
<FormField
|
||||
label="Note (optional)"
|
||||
name="lc_note"
|
||||
type="textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="Add a note about this step…"
|
||||
maxLength={150}
|
||||
/>
|
||||
{note.length > 0 && (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', textAlign: 'right', color: note.length > 130 ? 'var(--color-warning)' : 'var(--color-text-muted)', marginTop: 'calc(-1 * var(--space-3))' }}>
|
||||
{note.length}/150
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDel}
|
||||
variant="danger"
|
||||
title="Delete lifecycle step?"
|
||||
message={`This will permanently remove the ${stepMeta?.label} record.`}
|
||||
confirmLabel="Yes, Delete"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDel(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Lifecycle Tracker ────────────────────────────────────────────────────────
|
||||
|
||||
function LifecycleTracker({ device, canEdit, onStatusChange, onEditEntry, statusError }) {
|
||||
const currentIndex = STEP_INDEX[device.mfg_status] ?? 0
|
||||
const [hoveredIndex, setHoveredIndex] = useState(null)
|
||||
|
||||
const history = device.lifecycle_history || []
|
||||
const historyMap = {}
|
||||
history.forEach((entry) => { historyMap[entry.status_id || entry.status] = entry })
|
||||
|
||||
return (
|
||||
<div>
|
||||
{statusError && (
|
||||
<div style={{ marginBottom: 'var(--space-3)', padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{statusError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
|
||||
{LIFECYCLE.map((step, i) => {
|
||||
const isCurrent = i === currentIndex
|
||||
const isPast = i < currentIndex
|
||||
const isFuture = i > currentIndex
|
||||
const isLast = i === LIFECYCLE.length - 1
|
||||
const entry = historyMap[step.key]
|
||||
const hasData = !!entry
|
||||
const isHovered = hoveredIndex === i
|
||||
|
||||
return (
|
||||
<div key={step.key} style={{ display: 'flex', alignItems: 'stretch', gap: 0 }}>
|
||||
{/* Left rail */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', width: 44, flexShrink: 0 }}>
|
||||
{/* Step circle */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 3,
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${isCurrent ? step.accentHex : isPast ? `${step.accentHex}90` : isHovered && canEdit ? `${step.accentHex}60` : `${step.accentHex}30`}`,
|
||||
backgroundColor: isCurrent ? step.bgVar : isPast ? step.bgVar : isHovered && canEdit ? `${step.bgHex}99` : `${step.bgHex}55`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: isCurrent ? '1rem' : '0.85rem',
|
||||
flexShrink: 0,
|
||||
opacity: isFuture && !isHovered ? 0.35 : 1,
|
||||
boxShadow: isCurrent ? `0 0 0 3px ${step.accentHex}40, 0 0 14px ${step.accentHex}30` : 'none',
|
||||
position: 'relative',
|
||||
transition: 'all 0.2s',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{isPast && (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
|
||||
style={{ position: 'absolute', bottom: -2, right: -2, background: 'var(--color-bg-surface)', borderRadius: '50%' }}>
|
||||
<circle cx="7" cy="7" r="6" fill={step.accentHex} opacity="0.9"/>
|
||||
<path d="M4.5 7L6.2 8.7L9.5 5.3" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
<span style={{ fontSize: isCurrent ? '1rem' : '0.85rem' }}>{step.icon}</span>
|
||||
</div>
|
||||
|
||||
{/* Connector line */}
|
||||
{!isLast && (
|
||||
<div style={{
|
||||
width: 2,
|
||||
flexGrow: 1,
|
||||
minHeight: 8,
|
||||
background: isPast || isCurrent
|
||||
? `linear-gradient(to bottom, ${step.accentHex}90, ${LIFECYCLE[i+1].accentHex}50)`
|
||||
: `linear-gradient(to bottom, ${step.accentHex}30, ${LIFECYCLE[i+1].accentHex}20)`,
|
||||
margin: '2px 0',
|
||||
borderRadius: 1,
|
||||
transition: 'background 0.3s',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right clickable card */}
|
||||
<div
|
||||
role={canEdit ? 'button' : undefined}
|
||||
tabIndex={canEdit ? 0 : undefined}
|
||||
onMouseEnter={() => setHoveredIndex(i)}
|
||||
onMouseLeave={() => setHoveredIndex(null)}
|
||||
onClick={(e) => {
|
||||
if (e.target.closest('[data-edit-btn]')) return
|
||||
if (!canEdit) return
|
||||
onStatusChange(step.key)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && canEdit) onStatusChange(step.key)
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
marginLeft: 10,
|
||||
marginBottom: isLast ? 0 : 6,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${
|
||||
isCurrent
|
||||
? `${step.accentHex}cc`
|
||||
: isHovered && canEdit
|
||||
? `${step.accentHex}80`
|
||||
: isPast
|
||||
? `${step.accentHex}40`
|
||||
: `${step.accentHex}30`
|
||||
}`,
|
||||
backgroundColor: isCurrent
|
||||
? step.bgVar
|
||||
: isHovered && canEdit
|
||||
? `${step.bgHex}cc`
|
||||
: isPast
|
||||
? `${step.bgHex}66`
|
||||
: `${step.bgHex}55`,
|
||||
opacity: isFuture && !isHovered ? 0.5 : isPast && !isHovered ? 0.6 : 1,
|
||||
transition: 'border-color 0.18s, background-color 0.18s, opacity 0.18s',
|
||||
cursor: canEdit ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: hasData ? 'flex-start' : 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* Label row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: hasData ? 3 : 0 }}>
|
||||
<span style={{
|
||||
fontSize: '0.78rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
color: isCurrent ? step.colorVar : isPast ? `${step.accentHex}bb` : isHovered ? `${step.accentHex}90` : 'var(--color-text-muted)',
|
||||
}}>
|
||||
{step.label}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span style={{
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: step.colorVar,
|
||||
backgroundColor: `${step.accentHex}22`,
|
||||
border: `1px solid ${step.accentHex}45`,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 4,
|
||||
}}>CURRENT</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date + set_by */}
|
||||
{hasData && (
|
||||
<p style={{ fontSize: '0.7rem', color: isCurrent ? step.colorVar : 'var(--color-text-muted)', margin: 0, marginBottom: entry.note ? 2 : 0, opacity: isCurrent ? 0.7 : 1 }}>
|
||||
{formatDate(entry.date)}
|
||||
{entry.set_by && <span style={{ marginLeft: 6, opacity: 0.65 }}>· {entry.set_by}</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{entry?.note && (
|
||||
<p style={{ fontSize: '0.7rem', color: 'var(--color-text-secondary)', margin: 0, marginTop: 1, fontStyle: 'italic', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
"{entry.note}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit button — visible on hover */}
|
||||
{canEdit && (
|
||||
<button
|
||||
data-edit-btn
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditEntry(i, entry || null)
|
||||
}}
|
||||
style={{
|
||||
fontSize: '0.58rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
color: isHovered ? step.colorVar : 'transparent',
|
||||
backgroundColor: 'transparent',
|
||||
border: `1px solid ${isHovered ? `${step.accentHex}55` : 'transparent'}`,
|
||||
borderRadius: 5,
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
alignSelf: 'flex-start',
|
||||
transition: 'all 0.15s',
|
||||
pointerEvents: isHovered ? 'auto' : 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = step.bgVar
|
||||
e.currentTarget.style.borderColor = `${step.accentHex}80`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent'
|
||||
}}
|
||||
>
|
||||
EDIT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceInventoryDetail() {
|
||||
const { sn } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { hasPermission } = useAuth()
|
||||
const canEdit = hasPermission('manufacturing', 'edit')
|
||||
const canDelete = hasPermission('manufacturing', 'delete')
|
||||
|
||||
const [device, setDevice] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [assignedCustomer, setAssignedCustomer] = useState(null)
|
||||
const [resolvedUsers, setResolvedUsers] = useState([])
|
||||
|
||||
// Assignment
|
||||
const [showCustomerModal, setShowCustomerModal] = useState(false)
|
||||
const [assignSaving, setAssignSaving] = useState(false)
|
||||
const [assignError, setAssignError] = useState('')
|
||||
|
||||
// Status
|
||||
const [statusSaving, setStatusSaving] = useState(false)
|
||||
const [statusError, setStatusError] = useState('')
|
||||
|
||||
// Lifecycle edit modal
|
||||
const [editModalData, setEditModalData] = useState(null) // { stepIndex, entry|null }
|
||||
|
||||
// Delete modal
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
|
||||
// ─── Fetch ──────────────────────────────────────────────────────────────
|
||||
|
||||
const loadDevice = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.get(`/manufacturing/devices/${sn}`)
|
||||
setDevice(data)
|
||||
if (data.customer_id) {
|
||||
api.get(`/manufacturing/customers/${data.customer_id}`)
|
||||
.then((c) => setAssignedCustomer(c))
|
||||
.catch(() => setAssignedCustomer(null))
|
||||
} else {
|
||||
setAssignedCustomer(null)
|
||||
}
|
||||
if (data.user_list?.length) {
|
||||
Promise.all(
|
||||
data.user_list.map((uid) =>
|
||||
api.get(`/users/${uid}`)
|
||||
.then((u) => ({ uid, display_name: u.display_name || '', email: u.email || '' }))
|
||||
.catch(() => ({ uid, display_name: '', email: '' }))
|
||||
)
|
||||
).then(setResolvedUsers)
|
||||
} else {
|
||||
setResolvedUsers([])
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sn])
|
||||
|
||||
useEffect(() => { loadDevice() }, [loadDevice])
|
||||
|
||||
// ─── Status change ───────────────────────────────────────────────────────
|
||||
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
if (newStatus === device?.mfg_status) return
|
||||
setStatusError(''); setStatusSaving(true)
|
||||
try {
|
||||
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
})
|
||||
setDevice(updated)
|
||||
} catch (err) {
|
||||
setStatusError(err.message)
|
||||
} finally {
|
||||
setStatusSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Customer assignment ─────────────────────────────────────────────────
|
||||
|
||||
const handleSelectCustomer = async (customer) => {
|
||||
setShowCustomerModal(false)
|
||||
setAssignError(''); setAssignSaving(true)
|
||||
try {
|
||||
const updated = await api.request(`/manufacturing/devices/${sn}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ customer_id: customer.id }),
|
||||
})
|
||||
setDevice(updated)
|
||||
setAssignedCustomer(customer)
|
||||
} catch (err) {
|
||||
setAssignError(err.message)
|
||||
} finally {
|
||||
setAssignSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle edit ──────────────────────────────────────────────────────
|
||||
|
||||
const history = device?.lifecycle_history || []
|
||||
const historyIndexMap = {}
|
||||
history.forEach((entry, idx) => { historyIndexMap[entry.status_id || entry.status] = idx })
|
||||
|
||||
const handleSaveLifecycle = async (stepIndex, date, note) => {
|
||||
const stepKey = LIFECYCLE[stepIndex]?.key
|
||||
const arrayIndex = historyIndexMap[stepKey]
|
||||
if (arrayIndex === undefined) {
|
||||
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ status_id: stepKey, date, note }),
|
||||
})
|
||||
setDevice(updated)
|
||||
} else {
|
||||
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ index: arrayIndex, date, note }),
|
||||
})
|
||||
setDevice(updated)
|
||||
}
|
||||
setEditModalData(null)
|
||||
}
|
||||
|
||||
const handleDeleteLifecycleStep = async (stepIndex) => {
|
||||
const stepKey = LIFECYCLE[stepIndex]?.key
|
||||
const arrayIndex = historyIndexMap[stepKey]
|
||||
if (arrayIndex === undefined) { setEditModalData(null); return }
|
||||
const updated = await api.request(`/manufacturing/devices/${sn}/lifecycle/${arrayIndex}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
setDevice(updated)
|
||||
setEditModalData(null)
|
||||
}
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !device) {
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<div style={{ padding: 'var(--space-8)', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>{error || 'Device not found.'}</p>
|
||||
<Button variant="secondary" onClick={() => navigate('/manufacturing')} style={{ marginTop: 'var(--space-4)' }}>
|
||||
Back to Inventory
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const boardLabel = BOARD_TYPE_LABELS[device.hw_type] || device.hw_type
|
||||
const userList = device.user_list || []
|
||||
|
||||
const editStepMeta = editModalData ? LIFECYCLE[editModalData.stepIndex] : null
|
||||
const editIsCurrent = editModalData ? LIFECYCLE[editModalData.stepIndex]?.key === device.mfg_status : false
|
||||
|
||||
return (
|
||||
<div className="page-wrapper page-wrapper--capped">
|
||||
<PageHeader
|
||||
title={
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-3)' }}>
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)' }}>{device.serial_number}</span>
|
||||
<StatusBadge variant={STATUS_VARIANT[device.mfg_status] || 'neutral'}>
|
||||
{device.mfg_status}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
}
|
||||
subtitle={`${device.name || device.device_name || '—'} · ${boardLabel} · ${formatHwVersion(device.hw_version)}`}
|
||||
>
|
||||
{canEdit && (
|
||||
<Button variant="secondary" size="md" onClick={() => navigate(`/manufacturing/provision?sn=${sn}`)}>
|
||||
Flash
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button variant="danger" size="md" onClick={() => setShowDelete(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 'var(--space-6)', alignItems: 'start' }}>
|
||||
|
||||
{/* ── Left column ────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)' }}>
|
||||
|
||||
{/* Device Information */}
|
||||
<Card title="Device Information">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-5)' }}>
|
||||
<Field label="Serial Number" value={device.serial_number} mono />
|
||||
<Field label="Batch ID" value={device.batch_id || device.mfg_batch_id} mono />
|
||||
<Field label="Hardware Family" value={boardLabel} />
|
||||
<Field label="HW Version" value={formatHwVersion(device.hw_version)} mono />
|
||||
<Field label="Created At" value={formatDate(device.created_at)} />
|
||||
<Field label="Device Name" value={device.name || device.device_name} />
|
||||
<div style={{ gridColumn: '1 / -1' }}>
|
||||
<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-1)' }}>
|
||||
Document ID
|
||||
</p>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>
|
||||
{device.id || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Assignment */}
|
||||
<Card title="Assignment">
|
||||
{assignError && (
|
||||
<div style={{ marginBottom: 'var(--space-3)', padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-xs)' }}>
|
||||
{assignError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customer sub-section */}
|
||||
<div style={{ marginBottom: 'var(--space-5)' }}>
|
||||
<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)' }}>
|
||||
Customer
|
||||
</p>
|
||||
{device.customer_id ? (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { if (!e.target.closest('[data-reassign-btn]')) navigate(`/crm/customers/${device.customer_id}`) }}
|
||||
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.target.closest('[data-reassign-btn]')) navigate(`/crm/customers/${device.customer_id}`) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--color-border)'}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
||||
backgroundColor: 'var(--color-primary-subtle)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)',
|
||||
color: 'var(--color-primary)',
|
||||
}}>
|
||||
{(assignedCustomer?.name || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>
|
||||
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(' ') || 'Customer'}
|
||||
</p>
|
||||
{assignedCustomer?.organization && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{assignedCustomer.organization}</p>}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
data-reassign-btn
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={assignSaving}
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomerModal(true) }}
|
||||
>
|
||||
Reassign
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-3)' }}>
|
||||
No customer assigned.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<Button variant="secondary" size="sm" disabled={assignSaving} onClick={() => setShowCustomerModal(true)}>
|
||||
+ Assign to Customer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Assignment sub-section */}
|
||||
<div>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', marginBottom: 'var(--space-2)' }}>
|
||||
Assigned Users {userList.length > 0 && `(${userList.length})`}
|
||||
</p>
|
||||
{userList.length === 0 ? (
|
||||
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
|
||||
No users assigned to this device.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
{userList.map((uid) => {
|
||||
const resolved = resolvedUsers.find((u) => u.uid === uid)
|
||||
const displayName = resolved?.display_name || ''
|
||||
const email = resolved?.email || ''
|
||||
const initials = (displayName || email || uid)[0]?.toUpperCase() || 'U'
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/users/${uid}`)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate(`/users/${uid}`) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: '1px solid var(--color-border)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => e.currentTarget.style.borderColor = 'var(--color-border-strong)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.borderColor = 'var(--color-border)'}
|
||||
>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', flexShrink: 0,
|
||||
backgroundColor: 'var(--color-success-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-bold)',
|
||||
color: 'var(--color-success)',
|
||||
}}>
|
||||
{initials}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{displayName
|
||||
? <p style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)' }}>{displayName}</p>
|
||||
: <p style={{ fontSize: 'var(--font-size-sm)', fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-muted)' }}>{uid}</p>
|
||||
}
|
||||
{email && <p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>{email}</p>}
|
||||
</div>
|
||||
<Icon name="chevron-right" size={14} color="var(--color-text-muted)" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Right column: lifecycle ─────────────────────────────────────── */}
|
||||
<Card
|
||||
title="Product Lifecycle"
|
||||
subtitle={canEdit ? 'Click a step to edit' : undefined}
|
||||
>
|
||||
{(statusSaving) && (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', marginBottom: 'var(--space-3)' }}>Saving…</p>
|
||||
)}
|
||||
<LifecycleTracker
|
||||
device={device}
|
||||
canEdit={canEdit}
|
||||
onStatusChange={handleStatusChange}
|
||||
onEditEntry={(stepIndex, entry) => setEditModalData({ stepIndex, entry })}
|
||||
statusError={statusError}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── Modals ─────────────────────────────────────────────────────────── */}
|
||||
{showCustomerModal && (
|
||||
<CustomerSearchModal
|
||||
onSelect={handleSelectCustomer}
|
||||
onClose={() => setShowCustomerModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editModalData && editStepMeta && (
|
||||
<LifecycleEditModal
|
||||
open={editModalData !== null}
|
||||
entry={editModalData.entry}
|
||||
stepMeta={editStepMeta}
|
||||
isCurrent={editIsCurrent}
|
||||
onSave={(date, note) => handleSaveLifecycle(editModalData.stepIndex, date, note)}
|
||||
onDelete={() => handleDeleteLifecycleStep(editModalData.stepIndex)}
|
||||
onClose={() => setEditModalData(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteDeviceModal
|
||||
open={showDelete}
|
||||
device={device}
|
||||
onClose={() => setShowDelete(false)}
|
||||
onDeleted={() => navigate('/manufacturing')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
// frontend/src/pages/manufacturing/firmware/FlashAssetManager.jsx
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import api from '@/lib/api'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
import PageHeader from '@/components/ui/PageHeader'
|
||||
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 Modal from '@/components/ui/Modal'
|
||||
import FormField from '@/components/ui/FormField'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog'
|
||||
import UploadAssetModal from '@/modals/engineering/manufacturing/UploadAssetModal'
|
||||
import { fmtDateTimeMedium } from '@/lib/formatters'
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const KNOWN_BOARD_TYPES = [
|
||||
{ value: 'vesper', label: 'Vesper', family: 'vesper' },
|
||||
{ value: 'vesper_plus', label: 'Vesper+', family: 'vesper' },
|
||||
{ value: 'vesper_pro', label: 'Vesper Pro', family: 'vesper' },
|
||||
{ value: 'agnus', label: 'Agnus', family: 'agnus' },
|
||||
{ value: 'agnus_mini', label: 'Agnus Mini', family: 'agnus' },
|
||||
{ value: 'chronos', label: 'Chronos', family: 'chronos' },
|
||||
{ value: 'chronos_pro', label: 'Chronos Pro', family: 'chronos' },
|
||||
]
|
||||
|
||||
const FAMILY_ACCENT = {
|
||||
vesper: 'var(--color-info)',
|
||||
agnus: 'var(--color-warning)',
|
||||
chronos: 'var(--color-danger)',
|
||||
bespoke: 'var(--color-primary)',
|
||||
}
|
||||
|
||||
const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label]))
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`
|
||||
}
|
||||
|
||||
function formatDate(iso) { return fmtDateTimeMedium(iso) }
|
||||
|
||||
function boardLabel(hwType, isBespoke) {
|
||||
if (isBespoke) return hwType
|
||||
return BOARD_LABELS[hwType] || hwType
|
||||
}
|
||||
|
||||
function familyOf(hwType, isBespoke) {
|
||||
if (isBespoke) return 'bespoke'
|
||||
return KNOWN_BOARD_TYPES.find((b) => b.value === hwType)?.family ?? 'vesper'
|
||||
}
|
||||
|
||||
// ─── Note Modal ───────────────────────────────────────────────────────────────
|
||||
|
||||
function NoteModal({ open, hwType, currentNote, onClose, onSaved }) {
|
||||
const [note, setNote] = useState(currentNote || '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true); setError('')
|
||||
try {
|
||||
await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note })
|
||||
onSaved(note)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save note')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Asset Note"
|
||||
size="sm"
|
||||
footer={
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)' }}>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button variant="primary" onClick={handleSave} loading={saving}>Save Note</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
Board: <span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-secondary)' }}>{hwType}</span>
|
||||
{' '}— stored alongside binaries as{' '}
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', color: 'var(--color-text-secondary)' }}>note.txt</span>
|
||||
</p>
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-2)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
label="Note"
|
||||
name="asset_note"
|
||||
type="textarea"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234…"
|
||||
hint="Leave blank to clear the note."
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Asset Pill ───────────────────────────────────────────────────────────────
|
||||
|
||||
function AssetPill({ label, info, canDelete, onUpload, onDelete }) {
|
||||
const exists = info?.exists
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
border: `1px solid ${exists ? 'var(--color-border)' : 'var(--color-danger)'}`,
|
||||
backgroundColor: exists ? 'var(--color-bg-elevated)' : 'var(--color-danger-bg)',
|
||||
}}>
|
||||
<Icon
|
||||
name={exists ? 'check' : 'warning'}
|
||||
size={14}
|
||||
color={exists ? 'var(--color-success)' : 'var(--color-danger)'}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: exists ? 'var(--color-text-secondary)' : 'var(--color-danger)', margin: 0 }}>
|
||||
{label}
|
||||
</p>
|
||||
{exists ? (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', margin: 0 }}>
|
||||
{formatBytes(info.size_bytes)}{info.uploaded_at ? ` · ${formatDate(info.uploaded_at)}` : ''}
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-danger)', margin: 0, opacity: 0.8 }}>Not uploaded</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
|
||||
<Button variant="ghost" size="sm" onClick={onUpload} aria-label={exists ? 'Re-upload' : 'Upload'}>
|
||||
<Icon name="upload" size={12} />
|
||||
</Button>
|
||||
{exists && canDelete && (
|
||||
<Button variant="danger" size="sm" onClick={onDelete} aria-label="Delete">
|
||||
<Icon name="delete" size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Board Card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onDeleteNote }) {
|
||||
const { hw_type, bootloader, partitions, note, is_bespoke } = entry
|
||||
const family = familyOf(hw_type, is_bespoke)
|
||||
const accent = FAMILY_ACCENT[family] || 'var(--color-primary)'
|
||||
const label = boardLabel(hw_type, is_bespoke)
|
||||
|
||||
const hasBootloader = bootloader?.exists
|
||||
const hasPartitions = partitions?.exists
|
||||
const hasAll = hasBootloader && hasPartitions
|
||||
const hasNone = !hasBootloader && !hasPartitions
|
||||
const readyVariant = hasAll ? 'success' : hasNone ? 'danger' : 'warning'
|
||||
const readyLabel = hasAll ? 'Ready' : hasNone ? 'Missing' : 'Partial'
|
||||
|
||||
const [noteHovered, setNoteHovered] = useState(false)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: 'var(--space-3) var(--space-4)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)', borderBottom: '1px solid var(--color-border)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', minWidth: 0 }}>
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-display)', letterSpacing: 'var(--tracking-tight)' }}>
|
||||
{label}
|
||||
</span>
|
||||
{is_bespoke && <StatusBadge variant="info">bespoke</StatusBadge>}
|
||||
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
|
||||
{hw_type}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexShrink: 0 }}>
|
||||
<StatusBadge variant={readyVariant}>{readyLabel}</StatusBadge>
|
||||
{canEdit && (
|
||||
<Button variant="ghost" size="sm" onClick={() => onNote(entry)}>
|
||||
<Icon name="note" size={12} />
|
||||
{note ? 'Edit note' : 'Add note'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Asset pills */}
|
||||
<div style={{ padding: 'var(--space-3) var(--space-4)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
|
||||
<AssetPill label="bootloader.bin" info={bootloader} canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, 'bootloader.bin')}
|
||||
onDelete={() => onDelete(hw_type, 'bootloader.bin')} />
|
||||
<AssetPill label="partitions.bin" info={partitions} canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, 'partitions.bin')}
|
||||
onDelete={() => onDelete(hw_type, 'partitions.bin')} />
|
||||
</div>
|
||||
|
||||
{/* Note — shown below the files, hover-X to delete */}
|
||||
{note && (
|
||||
<div
|
||||
onMouseEnter={() => setNoteHovered(true)}
|
||||
onMouseLeave={() => setNoteHovered(false)}
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
backgroundColor: 'var(--color-bg-elevated)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
<p style={{ flex: 1, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontStyle: 'italic', lineHeight: 1.5, margin: 0 }}>
|
||||
{note}
|
||||
</p>
|
||||
{canEdit && noteHovered && (
|
||||
<button
|
||||
aria-label="Delete note"
|
||||
onClick={() => onDeleteNote(entry)}
|
||||
style={{
|
||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-muted)', padding: 'var(--space-1)',
|
||||
borderRadius: 'var(--radius-sm)', lineHeight: 1,
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--color-danger)' }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--color-text-muted)' }}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function FlashAssetManager({ embedded = false }) {
|
||||
const { hasPermission } = useAuth()
|
||||
const canDelete = hasPermission('manufacturing', 'delete')
|
||||
const canEdit = hasPermission('manufacturing', 'edit')
|
||||
|
||||
const [assets, setAssets] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
|
||||
const [uploadTarget, setUploadTarget] = useState(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [noteTarget, setNoteTarget] = useState(null)
|
||||
const [noteDelTarget, setNoteDelTarget] = useState(null)
|
||||
const [showConfirmDel, setShowConfirmDel] = useState(false)
|
||||
|
||||
const fetchAssets = useCallback(async () => {
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const data = await api.get('/manufacturing/flash-assets')
|
||||
setAssets(data.assets || [])
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load flash assets')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchAssets() }, [fetchAssets])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const res = await fetch(`/api/manufacturing/flash-assets/${deleteTarget.hwType}/${deleteTarget.assetName}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
setShowConfirmDel(false); setDeleteTarget(null)
|
||||
fetchAssets()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteUpdate = (hwType, newNote) => {
|
||||
setAssets((prev) => prev.map((a) => a.hw_type === hwType ? { ...a, note: newNote } : a))
|
||||
}
|
||||
|
||||
const handleDeleteNote = async (entry) => {
|
||||
try {
|
||||
await api.put(`/manufacturing/flash-assets/${entry.hw_type}/note`, { note: '' })
|
||||
handleNoteUpdate(entry.hw_type, '')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to delete note')
|
||||
} finally {
|
||||
setNoteDelTarget(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredAssets = assets.filter((e) => {
|
||||
const hasAll = e.bootloader?.exists && e.partitions?.exists
|
||||
const hasNone = !e.bootloader?.exists && !e.partitions?.exists
|
||||
if (filter === 'ready') return hasAll
|
||||
if (filter === 'missing') return hasNone
|
||||
if (filter === 'partial') return !hasAll && !hasNone
|
||||
return true
|
||||
})
|
||||
|
||||
const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length
|
||||
const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length
|
||||
const partialCount = assets.length - readyCount - missingCount
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'all', label: `All (${assets.length})` },
|
||||
{ value: 'ready', label: `Ready (${readyCount})` },
|
||||
{ value: 'partial', label: `Partial (${partialCount})` },
|
||||
{ value: 'missing', label: `Missing (${missingCount})` },
|
||||
]
|
||||
|
||||
const content = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ label: 'Total', value: assets.length, variant: 'neutral' },
|
||||
{ label: 'Ready', value: readyCount, variant: 'success' },
|
||||
{ label: 'Partial', value: partialCount, variant: 'warning' },
|
||||
{ label: 'Missing', value: missingCount, variant: 'danger' },
|
||||
].map(({ label, value, variant }) => (
|
||||
<div key={label} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 'var(--space-2)',
|
||||
padding: 'var(--space-2) var(--space-3)',
|
||||
backgroundColor: 'var(--color-bg-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-card)',
|
||||
}}>
|
||||
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-semibold)' }}>
|
||||
{label}
|
||||
</span>
|
||||
<StatusBadge variant={variant}>{value}</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={fetchAssets}>
|
||||
<Icon name="refresh" size={14} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
|
||||
{FILTER_OPTIONS.map(({ value, label }) => (
|
||||
<Button key={value} variant={filter === value ? 'secondary' : 'ghost'} size="sm" onClick={() => setFilter(value)}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: 'var(--space-3)', borderRadius: 'var(--radius-md)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div style={{ padding: 'var(--space-12)', textAlign: 'center' }}>
|
||||
<Icon name="box" size={40} color="var(--color-text-muted)" />
|
||||
<p style={{ marginTop: 'var(--space-3)', fontSize: 'var(--font-size-md)', color: 'var(--color-text-secondary)' }}>
|
||||
No assets match the current filter.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 'var(--space-4)' }}>
|
||||
{filteredAssets.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.hw_type}
|
||||
entry={entry}
|
||||
canDelete={canDelete}
|
||||
canEdit={canEdit}
|
||||
onUpload={(hwType, assetName) => setUploadTarget({ hwType, assetName })}
|
||||
onDelete={(hwType, assetName) => { setDeleteTarget({ hwType, assetName }); setShowConfirmDel(true) }}
|
||||
onNote={(e) => setNoteTarget(e)}
|
||||
onDeleteNote={(e) => setNoteDelTarget(e)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UploadAssetModal
|
||||
open={!!uploadTarget}
|
||||
hwType={uploadTarget?.hwType}
|
||||
assetName={uploadTarget?.assetName}
|
||||
onClose={() => setUploadTarget(null)}
|
||||
onSaved={() => { setUploadTarget(null); fetchAssets() }}
|
||||
/>
|
||||
<NoteModal
|
||||
open={!!noteTarget}
|
||||
hwType={noteTarget?.hw_type}
|
||||
currentNote={noteTarget?.note}
|
||||
onClose={() => setNoteTarget(null)}
|
||||
onSaved={(newNote) => { if (noteTarget) handleNoteUpdate(noteTarget.hw_type, newNote); setNoteTarget(null) }}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={!!noteDelTarget}
|
||||
variant="danger"
|
||||
title="Delete note?"
|
||||
message={noteDelTarget ? `Remove the note for ${boardLabel(noteDelTarget.hw_type, noteDelTarget.is_bespoke)}?` : ''}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteNote(noteDelTarget)}
|
||||
onCancel={() => setNoteDelTarget(null)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={showConfirmDel}
|
||||
variant="danger"
|
||||
title="Delete flash asset?"
|
||||
message={deleteTarget ? `Delete ${deleteTarget.assetName} for ${deleteTarget.hwType}? This cannot be undone. Flashing will fail until the file is re-uploaded.` : ''}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => { setShowConfirmDel(false); setDeleteTarget(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) return content
|
||||
|
||||
return (
|
||||
<div className="page-wrapper">
|
||||
<PageHeader title="Flash Asset Manager" subtitle="Manage bootloader and partition binaries for each board type" />
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user