From 0192d230c99006d80feb33b8b1f8863d03ae1b68 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Thu, 11 Jun 2026 14:35:16 -0400 Subject: [PATCH] feat(DevicesPage): enhance device action handling and improve UI for physical devices --- src/app/components/DevicesPage.tsx | 311 ++++++++++++++++++++++------- 1 file changed, 237 insertions(+), 74 deletions(-) diff --git a/src/app/components/DevicesPage.tsx b/src/app/components/DevicesPage.tsx index 61245b2..2eca10a 100644 --- a/src/app/components/DevicesPage.tsx +++ b/src/app/components/DevicesPage.tsx @@ -1,5 +1,12 @@ import { useEffect, useState } from 'react'; -import { Printer, HardDrive, Network, Box, ChevronRight, RefreshCw, FolderOpen, Computer, Cable, CassetteTape, Plug } from 'lucide-react'; +import { + Printer, HardDrive, Network, Box, ChevronRight, RefreshCw, FolderOpen, + Computer, Cable, CassetteTape, Plug, Zap, MoreVertical, + Info, Tag, Database, CheckCircle, Activity, +} from 'lucide-react'; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, +} from './ui/dialog'; import DeviceDetailOverlay from './DeviceDetailOverlay'; import MediaBrowser from './MediaBrowser'; import { toast } from 'sonner'; @@ -16,6 +23,65 @@ interface Device { mode?: number; } +interface PhysicalDevice { + number: string; + model: string; + status: 'Ready' | 'Busy' | 'Not Responding'; +} + +type DeviceStatus = 'Virtual' | 'Disabled' | 'Ready' | 'Busy' | 'Not Responding'; + +interface DisplayDevice extends Device { + physical: boolean; + physicalModel?: string; + deviceStatus: DeviceStatus; +} + +interface DeviceAction { + label: string; + Icon: React.ComponentType<{ className?: string }>; + cmd: string; + variant?: 'primary' | 'danger'; +} + +function getDeviceActions(device: DisplayDevice): DeviceAction[] { + const actions: DeviceAction[] = [ + { label: 'Initialize', Icon: Zap, cmd: `iec init ${device.number}`, variant: 'primary' }, + ]; + if (device.type === 'drive') { + actions.push( + { label: 'Read Directory', Icon: FolderOpen, cmd: `iec dir ${device.number}`, variant: 'primary' }, + { label: 'Test Drive', Icon: CheckCircle, cmd: `iec test ${device.number}` }, + ); + if (device.physical) { + actions.push({ label: 'Identify', Icon: Tag, cmd: `iec id ${device.number}` }); + } + actions.push({ label: 'Format Disk', Icon: Database, cmd: `iec format ${device.number}`, variant: 'danger' }); + } + if (device.type === 'printer') { + actions.push( + { label: 'Test Print', Icon: Printer, cmd: `iec print ${device.number}` }, + { label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' }, + ); + } + if (device.type === 'network') { + actions.push( + { label: 'Ping', Icon: Activity, cmd: `iec ping ${device.number}` }, + { label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' }, + ); + } + if (device.type === 'meatloaf') { + actions.push( + { label: 'System Info', Icon: Info, cmd: `iec info ${device.number}` }, + { label: 'Reset Device', Icon: RefreshCw, cmd: `iec reset ${device.number}`, variant: 'danger' }, + ); + } + return actions; +} + +const PHYSICAL_MODELS = ['SD2IEC', '1541', '1541-II', '1571', '1581', 'RAMLINK', 'CMD HD40', 'PI1541']; +const PHYSICAL_STATUSES: PhysicalDevice['status'][] = ['Ready', 'Ready', 'Ready', 'Busy', 'Not Responding']; + interface DevicesPageProps { config: any; setConfig: (config: any) => void; @@ -24,7 +90,6 @@ interface DevicesPageProps { } export default function DevicesPage({ config, setConfig, openDeviceId, onClearOpenDevice }: DevicesPageProps) { - // Host Settings update function const updateSetting = (path: string[], value: any) => { const newConfig = JSON.parse(JSON.stringify(config)); let current = newConfig; @@ -34,17 +99,18 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp current[path[path.length - 1]] = value; setConfig(newConfig); }; + const [selectedDeviceIndex, setSelectedDeviceIndex] = useState(null); - const [isScanning, setIsScanning] = useState(false); + const [isScanning, setIsScanning] = useState(false); const [showCassetteUrlBrowser, setShowCassetteUrlBrowser] = useState(false); + const [physicalDevices, setPhysicalDevices] = useState([]); + const [actionDevice, setActionDevice] = useState(null); const hardware = config.hardware || {}; - const modem = config.modem || {}; const cassette = config.cassette || {}; - const boip = config.boip || {}; + // Virtual devices from config (used for detail overlay navigation) const devices: Device[] = []; - if (config.iec?.devices) { Object.entries(config.iec.devices).forEach(([num, device]: [string, any]) => { const type = device.type as Device['type']; @@ -67,7 +133,29 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp }); } - // Auto-open the overlay when the parent passes a device ID (e.g. from a toast action) + // Unified display list: physical devices shadow virtual ones at the same bus address + const physicalNums = new Set(physicalDevices.map(p => p.number)); + + const displayDevices: DisplayDevice[] = []; + devices.forEach(d => { + if (physicalNums.has(d.number)) return; + displayDevices.push({ ...d, physical: false, deviceStatus: d.enabled ? 'Virtual' : 'Disabled' }); + }); + physicalDevices.forEach(p => { + displayDevices.push({ + id: `physical-${p.number}`, + number: p.number, + type: 'drive', + name: p.model, + enabled: 1, + physical: true, + physicalModel: p.model, + deviceStatus: p.status, + }); + }); + displayDevices.sort((a, b) => parseInt(a.number) - parseInt(b.number)); + + // Auto-open the overlay when the parent passes a device ID useEffect(() => { if (!openDeviceId) return; const idx = devices.findIndex(d => d.id === openDeviceId); @@ -78,35 +166,48 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp const getDeviceIcon = (type: Device['type']) => { switch (type) { - case 'printer': - return ; - case 'drive': - return ; - case 'network': - return ; - case 'meatloaf': - return ; - default: - return ; + case 'printer': return ; + case 'drive': return ; + case 'network': return ; + case 'meatloaf': return ; + default: return ; } }; - const handleDeviceClick = (index: number) => { - setSelectedDeviceIndex(index); + const iconColor = (device: DisplayDevice) => { + if (device.physical) { + if (device.deviceStatus === 'Not Responding') return 'text-red-500'; + if (device.deviceStatus === 'Busy') return 'text-amber-500'; + return 'text-green-600'; + } + return device.enabled ? 'text-blue-600' : 'text-neutral-400'; }; - const handleCloseOverlay = () => { - setSelectedDeviceIndex(null); + const statusEl = (status: DeviceStatus) => { + const dot = (cls: string) => ; + switch (status) { + case 'Ready': return <>{dot('bg-green-500')}Ready; + case 'Busy': return <>{dot('bg-amber-400')}Busy; + case 'Not Responding': return <>{dot('bg-red-500')}Not Responding; + case 'Disabled': return Disabled; + default: return Virtual; + } }; + const handleDeviceClick = (dd: DisplayDevice) => { + if (dd.physical) return; + const idx = devices.findIndex(d => d.number === dd.number); + if (idx >= 0) setSelectedDeviceIndex(idx); + }; + + const handleCloseOverlay = () => setSelectedDeviceIndex(null); + const handleNavigate = (direction: 'prev' | 'next') => { if (selectedDeviceIndex === null) return; - - if (direction === 'prev' && selectedDeviceIndex > 0) { + if (direction === 'prev' && selectedDeviceIndex > 0) setSelectedDeviceIndex(selectedDeviceIndex - 1); - } else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) { + else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) setSelectedDeviceIndex(selectedDeviceIndex + 1); - } }; const { send: wsSend } = useWs(); @@ -114,17 +215,38 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp const rescanBus = async () => { setIsScanning(true); wsSend('iec scan'); - toast.loading('Scanning IEC bus...'); + const toastId = toast.loading('Scanning IEC bus...'); - // Simulate bus scan await new Promise(resolve => setTimeout(resolve, 2000)); + const count = 2 + Math.floor(Math.random() * 3); + const shuffled = [...PHYSICAL_MODELS].sort(() => Math.random() - 0.5).slice(0, count); + const usedIds = new Set(); + const found: PhysicalDevice[] = shuffled.map(model => { + let id: number; + do { id = 8 + Math.floor(Math.random() * 22); } while (usedIds.has(String(id))); + usedIds.add(String(id)); + return { + number: String(id), + model, + status: PHYSICAL_STATUSES[Math.floor(Math.random() * PHYSICAL_STATUSES.length)], + }; + }); + found.sort((a, b) => parseInt(a.number) - parseInt(b.number)); + + setPhysicalDevices(found); setIsScanning(false); - toast.dismiss(); - toast.success(`Found ${devices.length} devices on the bus`); + toast.dismiss(toastId); + toast.success(`Found ${found.length} physical device${found.length !== 1 ? 's' : ''} on the bus`); }; - const toggleDeviceEnabled = (device: Device, e: React.MouseEvent) => { + const handleAction = (action: DeviceAction) => { + wsSend(action.cmd); + toast.success(`${action.label} sent to device #${actionDevice?.number}`); + setActionDevice(null); + }; + + const toggleDeviceEnabled = (device: DisplayDevice, e: React.MouseEvent) => { e.stopPropagation(); const newConfig = JSON.parse(JSON.stringify(config)); newConfig.iec.devices[device.number].enabled = device.enabled ? 0 : 1; @@ -134,7 +256,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp return (
- {/* Host Settings section moved from GeneralPage */} + {/* ── Host Settings ── */}

Host Settings

@@ -195,6 +317,8 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
+ + {/* ── IEC Serial Devices ── */}

IEC Serial Devices

+
- {devices.map((device, index) => ( + {displayDevices.map((device) => (
-
+ {/* Icon */} +
{getDeviceIcon(device.type)}
+ + {/* Info — clickable for virtual devices */} -
+ + {/* Controls */} +
+ {!device.physical && ( + <> + + + + )} -
))}
+ {/* DeviceDetailOverlay — virtual devices only */} {selectedDeviceIndex !== null && ( )} + {/* Action Dialog */} + !open && setActionDevice(null)}> + + + {actionDevice?.name || `Device ${actionDevice?.number}`} + + #{actionDevice?.number} + {actionDevice?.physical + ? ` · ${actionDevice.physicalModel} · Physical` + : ' · Virtual'} + + + {actionDevice && ( +
+ {getDeviceActions(actionDevice).map(action => ( + + ))} +
+ )} +
+
{/* ── Cassette ── */}

Cassette

@@ -279,14 +464,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
- {/*
- - updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" /> -
-
- - updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" /> -
*/}
@@ -314,7 +491,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp /> )} - {/* ── Hardware ── */} + {/* ── User Port ── */}

User Port

@@ -340,20 +517,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
- - {/* ── Hardware ── */} - {/*

Other Hardware

-
-
- - -
-
*/}
); }