meatloaf-config/src/app/components/StatusPage.tsx

475 lines
20 KiB
TypeScript

import { useEffect, useState } from 'react';
import { HardDrive, Activity, Wifi, Signal, Clock, RefreshCw, FolderOpen, Map, Loader2 } from 'lucide-react';
import DeviceDetailOverlay from './DeviceDetailOverlay';
import MediaSet from './MediaSet';
import { ImageWithFallback } from './figma/ImageWithFallback';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './ui/dialog';
import DirectoryListing from './DirectoryListing';
import { listDirectory, normalizePath, splitPath, type EntryInfo } from '../webdav';
interface StatusPageProps {
config: any;
setConfig: (config: any) => void;
}
export default function StatusPage({ config, setConfig }: StatusPageProps) {
// 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?.drive) {
for (const [num, device] of Object.entries(config.iec.devices.drive)) {
if (num !== 'vdrive' && num !== 'rom' && (device as any).enabled) {
return { number: num, ...device as any, type: 'drive' };
}
}
}
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?.drive?.[activeDevice!.number]) {
newConfig.iec.devices.drive[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'
// Overlay state for directory/disk map
const [showDirectory, setShowDirectory] = useState(false);
const [showDiskMap, setShowDiskMap] = useState(false);
// Real directory contents for the active device's mounted file.
// Pulled from the WebDAV server (parent folder of the mounted image).
const [dirEntries, setDirEntries] = useState<EntryInfo[]>([]);
const [dirLoading, setDirLoading] = useState(false);
const [dirError, setDirError] = useState<string | null>(null);
const directoryPath: string | null = (() => {
const url = activeDevice?.url;
if (!url) return null;
return splitPath(normalizePath(url)).parent;
})();
useEffect(() => {
if (!showDirectory) return;
if (!directoryPath) {
setDirEntries([]);
setDirError(null);
return;
}
let cancelled = false;
setDirLoading(true);
setDirError(null);
listDirectory(directoryPath)
.then((items) => {
if (cancelled) return;
setDirEntries(items);
})
.catch((e: any) => {
if (cancelled) return;
setDirError((e && e.message) || 'Failed to load directory');
setDirEntries([]);
})
.finally(() => {
if (cancelled) return;
setDirLoading(false);
});
return () => {
cancelled = true;
};
}, [showDirectory, directoryPath]);
return (
<div className="p-4 space-y-4">
{activeDevice && (
<>
<h2 className="text-sm text-neutral-500 pt-2">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>
)}
{/* Directory and Disk Map buttons at bottom */}
{/* 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 className="flex flex-col gap-2 mt-6">
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
onClick={() => setShowDirectory(true)}
>
<FolderOpen className="w-5 h-5" /> Show Directory
</button>
<button
className="flex items-center justify-center gap-2 px-3 py-3 rounded bg-neutral-200 text-neutral-700 hover:bg-blue-600 hover:text-white transition text-base font-medium w-full"
onClick={() => setShowDiskMap(true)}
>
<Map className="w-5 h-5" /> Show Disk Map
</button>
</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}
/>
)}
{/* Directory Overlay */}
{showDirectory && (
<>
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDirectory(false)} />
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<div className="min-w-0">
<h2 className="text-xl font-medium">Directory</h2>
<div className="text-xs text-neutral-500 truncate mt-0.5">
Device #{activeDevice.number} {activeDevice.url ? activeDevice.url.split('/').pop() : 'No file mounted'}
</div>
</div>
<button onClick={() => setShowDirectory(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{!activeDevice?.url && (
<div className="p-8 text-center text-neutral-500 text-sm">
No file mounted on this device.
</div>
)}
{activeDevice?.url && dirLoading && (
<div className="p-8 text-center text-neutral-500 text-sm flex flex-col items-center gap-2">
<Loader2 className="w-6 h-6 animate-spin" />
Loading directory from WebDAV
</div>
)}
{activeDevice?.url && !dirLoading && dirError && (
<div className="p-4 text-sm">
<div className="text-red-600 mb-2">Failed to load directory</div>
<div className="text-neutral-500 text-xs break-all">{dirError}</div>
</div>
)}
{activeDevice?.url && !dirLoading && !dirError && (
<DirectoryListing
entries={dirEntries.map((e) => ({
name: e.name,
type: e.type === 'folder' ? 'DIR' : (e.name.split('.').pop() || 'FILE').toUpperCase(),
blocks: e.type === 'file' ? Math.max(1, Math.ceil(e.size / 254)) : 0,
}))}
footerNote={`${dirEntries.length} ENTRIES · ${directoryPath ?? ''}`}
/>
)}
</div>
</div>
</>
)}
{/* Disk Map Overlay */}
{showDiskMap && (
<>
<div className="fixed inset-0 z-40 bg-black/40 backdrop-blur-md" onClick={() => setShowDiskMap(false)} />
<div className="fixed inset-0 z-50 bg-white shadow-2xl flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b">
<h2 className="text-xl font-medium">Disk Map</h2>
<button onClick={() => setShowDiskMap(false)} className="p-2 -m-2 hover:bg-neutral-100 rounded-lg"><svg xmlns='http://www.w3.org/2000/svg' className='w-6 h-6' fill='none' viewBox='0 0 24 24' stroke='currentColor'><path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' /></svg></button>
</div>
<div className="flex-1 min-h-0 overflow-auto flex items-center justify-center text-neutral-500 p-4">
<span>Disk map visualization goes here.</span>
</div>
</div>
</>
)}
{/* 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={() => {
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 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">System Status</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">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="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>
</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>
);
}