465 lines
19 KiB
TypeScript
465 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { HardDrive, Activity, Wifi, Radio, Clock, RefreshCw, Loader2, Printer, Power, Computer, Download, Trash2, Eye } from 'lucide-react';
|
|
import { listDirectory, deletePath, getFileContents, getWebDAVBaseUrl, humanFileSize, type EntryInfo } from '../webdav';
|
|
import { toast } from 'sonner';
|
|
import { MediaEntry } from './MediaEntry';
|
|
import { useWs } from '../ws';
|
|
import DeviceDetailOverlay from './DeviceDetailOverlay';
|
|
import MediaSet from './MediaSet';
|
|
import { ImageWithFallback } from './figma/ImageWithFallback';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
|
|
|
interface StatusPageProps {
|
|
config: any;
|
|
setConfig: (config: any) => void;
|
|
}
|
|
|
|
export default function StatusPage({ config, setConfig }: StatusPageProps) {
|
|
const { status: wsStatus, send: wsSend } = useWs();
|
|
|
|
// Mock memory stats
|
|
const memory = {
|
|
heap: { total: 4096, free: 1024 }, // in KB
|
|
psram: { total: 8192, free: 4096 },
|
|
};
|
|
|
|
// Overlay state for active device
|
|
const [showDeviceOverlay, setShowDeviceOverlay] = useState(false);
|
|
// Find the first enabled device as the active device
|
|
const findActiveDevice = () => {
|
|
if (config.iec?.devices) {
|
|
for (const [num, device] of Object.entries(config.iec.devices)) {
|
|
const d = device as any;
|
|
if (d.type === 'drive' && d.enabled) {
|
|
return { number: num, ...d };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const activeDevice = findActiveDevice();
|
|
|
|
const mediaSetFiles: string[] | null = (() => {
|
|
if (!activeDevice?.url) return null;
|
|
if (Array.isArray(activeDevice.media_set) && activeDevice.media_set.length > 0)
|
|
return activeDevice.media_set as string[];
|
|
const match = (activeDevice.url as string).match(/^(.+?)(\d+)(\.[^.]+)$/);
|
|
if (!match) return null;
|
|
const [, prefix, , ext] = match;
|
|
return Array.from({ length: 10 }, (_, i) => `${prefix}${i + 1}${ext}`);
|
|
})();
|
|
|
|
const switchActiveMedia = (file: string) => {
|
|
const newConfig = JSON.parse(JSON.stringify(config));
|
|
if (newConfig.iec?.devices?.[activeDevice!.number]) {
|
|
newConfig.iec.devices[activeDevice!.number].url = file;
|
|
setConfig(newConfig);
|
|
}
|
|
};
|
|
|
|
// Mock activity log - in a real app this would come from device monitoring
|
|
const activityLog = [
|
|
{ time: '14:32:15', event: 'File opened: game.d64', type: 'info' },
|
|
{ time: '14:32:14', event: 'Device mounted', type: 'success' },
|
|
{ time: '14:32:10', event: 'Read sector 18/0', type: 'info' },
|
|
{ time: '14:32:08', event: 'Directory listing requested', type: 'info' },
|
|
{ time: '14:32:05', event: 'Connection established', type: 'success' },
|
|
{ time: '14:31:58', event: 'Device reset', type: 'warning' }
|
|
];
|
|
|
|
|
|
// Mock loading/progress state
|
|
const [loading, setLoading] = useState(false);
|
|
const [progress, setProgress] = useState(0.0); // 0.0 to 1.0
|
|
|
|
// Mock file info (replace with real data if available)
|
|
const lastFile = 'MEATLOAF MANIACS.PRG';
|
|
const fileSize = '1.44 MB'; // Replace with real size if available
|
|
const transferSpeed = '250 KB/s'; // Replace with real speed if available
|
|
// Mock image association (replace with real logic if available)
|
|
const imageUrl = lastFile.endsWith('.d64') ? '/assets/floppy.png' : undefined;
|
|
|
|
// Dialog/modal state for reset actions
|
|
const [showResetModal, setShowResetModal] = useState<null | 'meatloaf' | 'host'>(null);
|
|
const [resetStatus, setResetStatus] = useState('idle'); // 'idle' | 'in-progress' | 'done'
|
|
|
|
// Print Log
|
|
const [printFiles, setPrintFiles] = useState<EntryInfo[]>([]);
|
|
const [printLoading, setPrintLoading] = useState(true);
|
|
const [printActionEntry, setPrintActionEntry] = useState<EntryInfo | null>(null);
|
|
|
|
const loadPrintFiles = () => {
|
|
setPrintLoading(true);
|
|
listDirectory('/sd/.print')
|
|
.then(entries => setPrintFiles(entries.filter(e => e.type === 'file')))
|
|
.catch(() => setPrintFiles([]))
|
|
.finally(() => setPrintLoading(false));
|
|
};
|
|
|
|
useEffect(() => { loadPrintFiles(); }, []);
|
|
|
|
const printFileUrl = (entry: EntryInfo) => getWebDAVBaseUrl() + entry.path;
|
|
|
|
const downloadPrintFile = async (entry: EntryInfo) => {
|
|
try {
|
|
const blob = await getFileContents(entry.path);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = entry.name; a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
|
|
};
|
|
|
|
const deletePrintFile = async (entry: EntryInfo) => {
|
|
if (!window.confirm(`Delete "${entry.name}"?`)) return;
|
|
try {
|
|
await deletePath(entry.path);
|
|
toast.success(`Deleted ${entry.name}`);
|
|
loadPrintFiles();
|
|
} catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="p-4 space-y-4">
|
|
|
|
{activeDevice && (
|
|
<>
|
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
|
<Power className="w-4 h-4" />
|
|
Active Device
|
|
</h2>
|
|
|
|
<div
|
|
className="bg-white border border-neutral-200 rounded-lg p-4 relative"
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowDeviceOverlay(true)}
|
|
className="flex items-center gap-3 text-left rounded-lg p-1 -m-1 hover:bg-neutral-50 transition cursor-pointer"
|
|
aria-label={`Open details for Device #${activeDevice.number}`}
|
|
>
|
|
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
|
<HardDrive className="w-5 h-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<div className="font-medium">Device #{activeDevice.number}</div>
|
|
<div className="text-sm text-neutral-500">
|
|
{[activeDevice.base_url, activeDevice.url].filter(Boolean).join('') || '—'}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
<div className="w-2 h-2 rounded-full bg-green-500" />
|
|
</div>
|
|
|
|
{mediaSetFiles && (
|
|
<div className="mt-2">
|
|
<MediaSet files={mediaSetFiles} activeUrl={activeDevice.url ?? ''} onSwitch={switchActiveMedia} />
|
|
</div>
|
|
)}
|
|
|
|
{/* New device info cards */}
|
|
<div className="mb-4">
|
|
<div className="bg-neutral-50 rounded-lg p-3 flex flex-col items-start justify-center w-full mb-2">
|
|
<div className="text-xs text-neutral-500 mb-1">Last File</div>
|
|
<div className="text-sm font-medium break-all w-full text-left">{lastFile}</div>
|
|
</div>
|
|
<div className="flex flex-row justify-between gap-4 w-full">
|
|
<div className="bg-neutral-50 rounded-lg p-3 flex-1 flex flex-col items-start justify-center">
|
|
<div className="text-xs text-neutral-500 mb-1">Size</div>
|
|
<div className="text-sm font-medium">{fileSize}</div>
|
|
</div>
|
|
<div className="bg-neutral-50 rounded-lg p-3 flex-1 flex flex-col items-end justify-center">
|
|
<div className="text-xs text-neutral-500 mb-1">Transfer Speed</div>
|
|
<div className="text-sm font-medium">{transferSpeed}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress bar (shows when loading) */}
|
|
{loading && (
|
|
<div className="w-full h-3 bg-neutral-200 rounded overflow-hidden mb-4">
|
|
<div
|
|
className="h-3 bg-blue-500 transition-all"
|
|
style={{ width: `${progress * 100}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image placeholder if associated */}
|
|
{imageUrl && (
|
|
<div className="w-full mb-4">
|
|
<ImageWithFallback src={imageUrl} alt="Media image" className="w-full h-32 object-contain rounded shadow bg-neutral-100" />
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{showDeviceOverlay && (
|
|
<DeviceDetailOverlay
|
|
device={{
|
|
id: `drive-${activeDevice.number}`,
|
|
number: activeDevice.number,
|
|
type: 'drive',
|
|
name: activeDevice.name,
|
|
enabled: activeDevice.enabled,
|
|
url: activeDevice.url,
|
|
mode: activeDevice.mode
|
|
}}
|
|
config={config}
|
|
setConfig={setConfig}
|
|
onClose={() => setShowDeviceOverlay(false)}
|
|
onNavigate={() => {}}
|
|
hasPrev={false}
|
|
hasNext={false}
|
|
/>
|
|
)}
|
|
|
|
{/* Reset Activity Modal */}
|
|
<Dialog open={!!showResetModal} onOpenChange={open => !open && setShowResetModal(null)}>
|
|
<DialogContent>
|
|
<DialogTitle>{showResetModal === 'meatloaf' ? 'Reset Meatloaf' : 'Reset Host'}</DialogTitle>
|
|
<DialogDescription>
|
|
{resetStatus === 'idle' && (
|
|
<>
|
|
Are you sure you want to reset {showResetModal === 'meatloaf' ? 'the Meatloaf device' : 'the Host'}?
|
|
<div className="flex gap-2 mt-4">
|
|
<button
|
|
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
|
onClick={() => {
|
|
wsSend(showResetModal === 'meatloaf' ? 'reset' : 'reset hard');
|
|
setResetStatus('in-progress');
|
|
setTimeout(() => setResetStatus('done'), 2000);
|
|
}}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
className="px-4 py-2 rounded bg-neutral-200 text-neutral-700 hover:bg-neutral-300 transition"
|
|
onClick={() => setShowResetModal(null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
{resetStatus === 'in-progress' && (
|
|
<div className="flex flex-col items-center gap-4 mt-4">
|
|
<span>Resetting...</span>
|
|
<div className="w-full h-2 bg-neutral-200 rounded overflow-hidden">
|
|
<div className="h-2 bg-blue-500 animate-pulse" style={{ width: '100%' }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{resetStatus === 'done' && (
|
|
<div className="flex flex-col items-center gap-4 mt-4">
|
|
<span className="text-green-600">Reset complete!</span>
|
|
<button
|
|
className="px-4 py-2 rounded bg-blue-600 text-white hover:bg-blue-700 transition"
|
|
onClick={() => setShowResetModal(null)}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)}
|
|
|
|
{!activeDevice && (
|
|
<div className="bg-white border border-neutral-200 rounded-lg p-8 text-center">
|
|
<div className="text-neutral-400 mb-2">
|
|
<HardDrive className="w-12 h-12 mx-auto" />
|
|
</div>
|
|
<div className="text-neutral-600">No active device</div>
|
|
<div className="text-sm text-neutral-500 mt-1">
|
|
Enable a device to see activity
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
|
<Printer className="w-4 h-4" />
|
|
Print Log
|
|
</h2>
|
|
|
|
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
|
|
{printLoading ? (
|
|
<div className="flex items-center gap-2 p-4 text-sm text-neutral-500">
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading…
|
|
</div>
|
|
) : printFiles.length === 0 ? (
|
|
<div className="p-4 text-sm text-neutral-400">No print files found in /sd/.print</div>
|
|
) : (
|
|
printFiles.map(entry => (
|
|
<MediaEntry
|
|
key={entry.path}
|
|
entry={entry}
|
|
onPrimaryClick={() => window.open(printFileUrl(entry), '_blank')}
|
|
onActionsClick={e => { e.stopPropagation(); setPrintActionEntry(entry); }}
|
|
className="last:border-b-0"
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Print Log action modal */}
|
|
<Dialog open={printActionEntry !== null} onOpenChange={open => !open && setPrintActionEntry(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle className="truncate">{printActionEntry?.name}</DialogTitle>
|
|
<DialogDescription>{humanFileSize(printActionEntry?.size ?? 0)}</DialogDescription>
|
|
</DialogHeader>
|
|
{printActionEntry && (
|
|
<div className="flex flex-col gap-2">
|
|
<button
|
|
onClick={() => { setPrintActionEntry(null); window.open(printFileUrl(printActionEntry), '_blank'); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
|
>
|
|
<Eye className="w-4 h-4 text-blue-600" /> <span>Open / View</span>
|
|
</button>
|
|
<button
|
|
onClick={() => { setPrintActionEntry(null); void downloadPrintFile(printActionEntry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
|
>
|
|
<Download className="w-4 h-4" /> <span>Download</span>
|
|
</button>
|
|
<div className="border-t border-neutral-100" />
|
|
<button
|
|
onClick={() => { setPrintActionEntry(null); void deletePrintFile(printActionEntry); }}
|
|
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
|
|
>
|
|
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<h2 className="text-sm text-neutral-500 pt-2 flex items-center gap-2">
|
|
<Activity className="w-4 h-4" />
|
|
Activity Log
|
|
</h2>
|
|
|
|
<div className="bg-white border border-neutral-200 rounded-lg overflow-hidden">
|
|
{activityLog.map((entry, index) => (
|
|
<div
|
|
key={index}
|
|
className="px-4 py-3 border-b border-neutral-100 last:border-b-0"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
<div className="text-xs text-neutral-500 font-mono mt-0.5 w-16 flex-shrink-0">
|
|
{entry.time}
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-1.5 h-1.5 rounded-full ${
|
|
entry.type === 'success'
|
|
? 'bg-green-500'
|
|
: entry.type === 'warning'
|
|
? 'bg-yellow-500'
|
|
: 'bg-blue-500'
|
|
}`}
|
|
/>
|
|
<span className="text-sm text-neutral-900">{entry.event}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<h2 className="text-sm text-neutral-500 flex items-center gap-2">
|
|
<Computer className="w-4 h-4" />
|
|
System Status
|
|
<div className="ml-auto flex items-center gap-3">
|
|
<div className="text-xs text-neutral-500">Uptime</div>
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Clock className="w-3 h-3" />
|
|
<span>3h 24m</span>
|
|
</div>
|
|
</div>
|
|
</h2>
|
|
|
|
<div className="bg-white border border-neutral-200 rounded-lg p-4">
|
|
{/* System Status Action Buttons at bottom */}
|
|
<div className="mb-4">
|
|
<div className="text-xs text-neutral-500 mb-1">Memory Utilization</div>
|
|
<div className="flex flex-col gap-2">
|
|
{/* Heap Graph */}
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-0.5">
|
|
<span>Heap</span>
|
|
<span>{memory.heap.free} KB free / {memory.heap.total} KB</span>
|
|
</div>
|
|
<div className="w-full h-3 bg-neutral-200 rounded overflow-hidden">
|
|
<div
|
|
className="h-3 bg-blue-500"
|
|
style={{ width: `${((memory.heap.total - memory.heap.free) / memory.heap.total) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{/* PSRAM Graph */}
|
|
<div>
|
|
<div className="flex justify-between text-xs mb-0.5">
|
|
<span>PSRAM</span>
|
|
<span>{memory.psram.free} KB free / {memory.psram.total} KB</span>
|
|
</div>
|
|
<div className="w-full h-3 bg-neutral-200 rounded overflow-hidden">
|
|
<div
|
|
className="h-3 bg-green-500"
|
|
style={{ width: `${((memory.psram.total - memory.psram.free) / memory.psram.total) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<div className="text-xs text-neutral-500">WiFi</div>
|
|
<div className="flex items-center gap-1 text-sm">
|
|
<Wifi className="w-3 h-3 text-green-600" />
|
|
<span>Connected</span>
|
|
</div>
|
|
<div className="text-xs text-neutral-500 mt-1">WebSocket</div>
|
|
<div className="flex items-center gap-1 text-sm">
|
|
{wsStatus === 'connecting' && <><Loader2 className="w-3 h-3 text-yellow-500 animate-spin" /><span className="text-yellow-600">Connecting</span></>}
|
|
{wsStatus === 'connected' && <><Radio className="w-3 h-3 text-green-600" /><span>Connected</span></>}
|
|
{wsStatus === 'disconnected' && <><Radio className="w-3 h-3 text-red-500" /><span className="text-red-600">Disconnected</span></>}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-neutral-500 mt-1">IP Address</div>
|
|
<div className="text-sm text-neutral-700">192.168.1.100</div>
|
|
<div className="text-xs text-neutral-500 mt-1">MAC Address</div>
|
|
<div className="text-sm text-neutral-700">AA:BB:CC:DD:EE:FF</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-2 mt-6">
|
|
<button
|
|
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
|
onClick={() => { setShowResetModal('meatloaf'); setResetStatus('idle'); }}
|
|
>
|
|
<RefreshCw className="w-5 h-5" /> Reset Meatloaf
|
|
</button>
|
|
<button
|
|
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-blue-600 text-white hover:bg-blue-700 transition text-base font-medium w-full"
|
|
onClick={() => { setShowResetModal('host'); setResetStatus('idle'); }}
|
|
>
|
|
<RefreshCw className="w-5 h-5" /> Reset Host
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
);
|
|
}
|