feat(DevicesPage): enhance device action handling and improve UI for physical devices
This commit is contained in:
parent
44736750b6
commit
0192d230c9
|
|
@ -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<number | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [showCassetteUrlBrowser, setShowCassetteUrlBrowser] = useState(false);
|
||||
const [physicalDevices, setPhysicalDevices] = useState<PhysicalDevice[]>([]);
|
||||
const [actionDevice, setActionDevice] = useState<DisplayDevice | null>(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 <Printer className="w-5 h-5" />;
|
||||
case 'drive':
|
||||
return <HardDrive className="w-5 h-5" />;
|
||||
case 'network':
|
||||
return <Network className="w-5 h-5" />;
|
||||
case 'meatloaf':
|
||||
return <Computer className="w-5 h-5" />;
|
||||
default:
|
||||
return <Box className="w-5 h-5" />;
|
||||
case 'printer': return <Printer className="w-5 h-5" />;
|
||||
case 'drive': return <HardDrive className="w-5 h-5" />;
|
||||
case 'network': return <Network className="w-5 h-5" />;
|
||||
case 'meatloaf': return <Computer className="w-5 h-5" />;
|
||||
default: return <Box className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
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) => <span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${cls}`} />;
|
||||
switch (status) {
|
||||
case 'Ready': return <><span className="text-green-600">{dot('bg-green-500')}Ready</span></>;
|
||||
case 'Busy': return <><span className="text-amber-600">{dot('bg-amber-400')}Busy</span></>;
|
||||
case 'Not Responding': return <><span className="text-red-600">{dot('bg-red-500')}Not Responding</span></>;
|
||||
case 'Disabled': return <span className="text-neutral-400">Disabled</span>;
|
||||
default: return <span className="text-blue-600">Virtual</span>;
|
||||
}
|
||||
};
|
||||
|
||||
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<string>();
|
||||
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 (
|
||||
<div className="p-4">
|
||||
{/* Host Settings section moved from GeneralPage */}
|
||||
{/* ── Host Settings ── */}
|
||||
<h2 className="text-sm text-neutral-500 mb-2 flex items-center gap-2"><Computer className="w-4 h-4" /> Host Settings</h2>
|
||||
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200 mb-6">
|
||||
<div className="p-4">
|
||||
|
|
@ -195,6 +317,8 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── IEC Serial Devices ── */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm text-neutral-500 flex items-center gap-2"><Cable className="w-4 h-4" /> IEC Serial Devices</h2>
|
||||
<button
|
||||
|
|
@ -206,54 +330,79 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
Rescan Bus
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{devices.map((device, index) => (
|
||||
{displayDevices.map((device) => (
|
||||
<div
|
||||
key={device.id}
|
||||
className="w-full bg-white border border-neutral-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<div className={`${device.enabled ? 'text-blue-600' : 'text-neutral-400'}`}>
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 ${iconColor(device)}`}>
|
||||
{getDeviceIcon(device.type)}
|
||||
</div>
|
||||
|
||||
{/* Info — clickable for virtual devices */}
|
||||
<button
|
||||
onClick={() => handleDeviceClick(index)}
|
||||
className="flex-1 min-w-0 text-left"
|
||||
onClick={() => handleDeviceClick(device)}
|
||||
disabled={device.physical}
|
||||
className="flex-1 min-w-0 text-left disabled:cursor-default"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={device.enabled ? 'text-neutral-900' : 'text-neutral-400'}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={!device.physical && !device.enabled ? 'text-neutral-400' : 'text-neutral-900'}>
|
||||
{device.name || `Device ${device.number}`}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 px-2 py-0.5 bg-neutral-100 rounded">
|
||||
<span className="text-xs text-neutral-500 px-1.5 py-0.5 bg-neutral-100 rounded">
|
||||
#{device.number}
|
||||
</span>
|
||||
{device.physical && (
|
||||
<span className="text-xs text-green-700 px-1.5 py-0.5 bg-green-50 border border-green-200 rounded">
|
||||
Physical
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(device.base_url || device.url) && (
|
||||
<div className="text-sm text-neutral-500 truncate mt-0.5">
|
||||
{[device.base_url, device.url].filter(Boolean).join('')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs mt-1 flex items-center">
|
||||
{statusEl(device.deviceStatus)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{!device.physical && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => toggleDeviceEnabled(device, e)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
device.enabled ? 'bg-blue-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
device.enabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`} />
|
||||
</button>
|
||||
<button onClick={() => handleDeviceClick(device)}>
|
||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => toggleDeviceEnabled(device, e)}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
device.enabled ? 'bg-blue-600' : 'bg-neutral-300'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); setActionDevice(device); }}
|
||||
className="p-2 rounded hover:bg-neutral-200 flex-shrink-0"
|
||||
title="Actions"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${
|
||||
device.enabled ? 'translate-x-5' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button onClick={() => handleDeviceClick(index)}>
|
||||
<ChevronRight className="w-4 h-4 text-neutral-400" />
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DeviceDetailOverlay — virtual devices only */}
|
||||
{selectedDeviceIndex !== null && (
|
||||
<DeviceDetailOverlay
|
||||
device={devices[selectedDeviceIndex]}
|
||||
|
|
@ -266,6 +415,42 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Action Dialog */}
|
||||
<Dialog open={actionDevice !== null} onOpenChange={open => !open && setActionDevice(null)}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{actionDevice?.name || `Device ${actionDevice?.number}`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
#{actionDevice?.number}
|
||||
{actionDevice?.physical
|
||||
? ` · ${actionDevice.physicalModel} · Physical`
|
||||
: ' · Virtual'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{actionDevice && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{getDeviceActions(actionDevice).map(action => (
|
||||
<button
|
||||
key={action.label}
|
||||
onClick={() => handleAction(action)}
|
||||
className={`w-full text-left px-4 py-3 rounded border inline-flex items-center gap-3 ${
|
||||
action.variant === 'danger'
|
||||
? 'border-red-200 hover:bg-red-50 text-red-700'
|
||||
: action.variant === 'primary'
|
||||
? 'border-neutral-200 hover:bg-blue-50 hover:border-blue-300'
|
||||
: 'border-neutral-200 hover:bg-neutral-50'
|
||||
}`}
|
||||
>
|
||||
<action.Icon className={`w-4 h-4 flex-shrink-0 ${
|
||||
action.variant === 'danger' ? 'text-red-500' : 'text-neutral-500'
|
||||
}`} />
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Cassette ── */}
|
||||
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><CassetteTape className="w-4 h-4" /> Cassette</h2>
|
||||
|
|
@ -279,14 +464,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${cassette.enabled ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
{/* <div className="p-4">
|
||||
<label className="text-sm text-neutral-500 block mb-2">Play/Record</label>
|
||||
<input type="text" value={cassette.play_record || ''} onChange={(e) => updateSetting(['cassette', 'play_record'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-neutral-500 block mb-2">Pulldown</label>
|
||||
<input type="text" value={cassette.pulldown || ''} onChange={(e) => updateSetting(['cassette', 'pulldown'], e.target.value)} className="w-full px-3 py-2 border border-neutral-300 rounded-lg" />
|
||||
</div> */}
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-neutral-500 block mb-2">URL</label>
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -314,7 +491,7 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hardware ── */}
|
||||
{/* ── User Port ── */}
|
||||
<h2 className="text-sm text-neutral-500 pt-4 flex items-center gap-2"><Plug className="w-4 h-4" /> User Port</h2>
|
||||
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
|
|
@ -340,20 +517,6 @@ export default function DevicesPage({ config, setConfig, openDeviceId, onClearOp
|
|||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Hardware ── */}
|
||||
{/* <h2 className="text-sm text-neutral-500 pt-4">Other Hardware</h2>
|
||||
<div className="bg-white border border-neutral-200 rounded-lg divide-y divide-neutral-200">
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<label className="text-sm text-neutral-500">PS/2</label>
|
||||
<button
|
||||
onClick={() => updateSetting(['hardware', 'ps2'], hardware.ps2 ? 0 : 1)}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${hardware.ps2 ? 'bg-blue-600' : 'bg-neutral-300'}`}
|
||||
>
|
||||
<div className={`absolute top-0.5 w-5 h-5 bg-white rounded-full transition-transform ${hardware.ps2 ? 'translate-x-6' : 'translate-x-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user