import { useEffect, useState } from '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'; import { useWs } from '../ws'; interface Device { id: string; number: string; type: 'printer' | 'drive' | 'network' | 'other' | 'meatloaf'; name?: string; enabled: boolean | number; base_url?: string; url?: string; 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; openDeviceId?: string | null; onClearOpenDevice?: () => void; } export default function DevicesPage({ config, setConfig, openDeviceId, onClearOpenDevice }: DevicesPageProps) { const updateSetting = (path: string[], value: any) => { const newConfig = JSON.parse(JSON.stringify(config)); let current = newConfig; for (let i = 0; i < path.length - 1; i++) { current = current[path[i]]; } current[path[path.length - 1]] = value; setConfig(newConfig); }; const [selectedDeviceIndex, setSelectedDeviceIndex] = useState(null); const [isScanning, setIsScanning] = useState(false); const [showCassetteUrlBrowser, setShowCassetteUrlBrowser] = useState(false); const [physicalDevices, setPhysicalDevices] = useState([]); const [actionDevice, setActionDevice] = useState(null); const hardware = config.hardware || {}; const cassette = config.cassette || {}; // 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']; const name = device.name || ( type === 'drive' ? `Drive ${num}` : type === 'network' ? `Network ${num}` : type === 'meatloaf' ? `Meatloaf ${num}` : undefined ); devices.push({ id: `${type}-${num}`, number: num, type, name, enabled: device.enabled, base_url: device.base_url, url: device.url, mode: device.mode, }); }); } // 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); if (idx >= 0) setSelectedDeviceIndex(idx); onClearOpenDevice?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [openDeviceId]); const getDeviceIcon = (type: Device['type']) => { switch (type) { case 'printer': return ; case 'drive': return ; case 'network': return ; case 'meatloaf': return ; default: return ; } }; 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 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) setSelectedDeviceIndex(selectedDeviceIndex - 1); else if (direction === 'next' && selectedDeviceIndex < devices.length - 1) setSelectedDeviceIndex(selectedDeviceIndex + 1); }; const { send: wsSend } = useWs(); const rescanBus = async () => { setIsScanning(true); wsSend('iec scan'); const toastId = toast.loading('Scanning IEC bus...'); 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(toastId); toast.success(`Found ${found.length} physical device${found.length !== 1 ? 's' : ''} on the bus`); }; 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; setConfig(newConfig); toast.success(`Device #${device.number} ${device.enabled ? 'disabled' : 'enabled'}`); }; return (
{/* ── Host Settings ── */}

Host Settings

{/* ── IEC Serial Devices ── */}

IEC Serial Devices

{displayDevices.map((device) => (
{/* Icon */}
{getDeviceIcon(device.type)}
{/* Info — clickable for virtual devices */} {/* Controls */}
{!device.physical && ( <> )}
))}
{/* DeviceDetailOverlay — virtual devices only */} {selectedDeviceIndex !== null && ( 0} hasNext={selectedDeviceIndex < devices.length - 1} /> )} {/* 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

updateSetting(['cassette', 'url'], e.target.value)} className="flex-1 px-3 py-2 border border-neutral-300 rounded-lg" />
{showCassetteUrlBrowser && ( updateSetting(['cassette', 'url'], p)} onClose={() => setShowCassetteUrlBrowser(false)} /> )} {/* ── User Port ── */}

User Port

); }