import { useEffect, useState } from 'react'; interface MountIOStats { source: 'rclone'; bytesRead: number; bytesWritten?: number; readBps: number; writeBps?: number; readOps?: number; writeOps?: number; readIops?: number; writeIops?: number; transfersPerSec?: number; lastSampleAt: number; intervalMs?: number; } interface Mount { id: string; name: string; type: 'smb' | 'rclone'; enabled: boolean; desiredMounted: boolean; mountPath: string; mounted: boolean; error?: string; lastChecked: number; // VFS cache configuration applied at mount time. Empty string means "use // server default" (50G / 24h / 5m). cacheMaxSize?: string; cacheMaxAge?: string; dirCacheTime?: string; pollingEnabled?: boolean; pollingIntervalMs?: number; pollingActive?: boolean; lastPolledScan?: number; // Adaptive scan progress (set by the meta-core poller) scanning?: boolean; currentScanStartedAt?: number; // ms; only while scanning lastScanDurationMs?: number; nextScanAt?: number; // ms; planned start of next adaptive scan // Live IO — populated by StatsPoller every ~5s. Daemon-global from rclone, // so the same numbers appear on every mount card. ioStats?: MountIOStats; } interface RcloneRemote { name: string; type: string; } const cardStyle: React.CSSProperties = { background: '#16213e', borderRadius: '8px', padding: '1.5rem', marginBottom: '1rem', }; const buttonStyle: React.CSSProperties = { padding: '0.5rem 1rem', background: '#0f3460', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', marginRight: '0.5rem', }; const dangerButtonStyle: React.CSSProperties = { ...buttonStyle, background: '#7f1d1d', }; // Format byte counts/rates with K/M/G/T prefix (decimal — matches what users // expect for network speeds; binary prefixes feel pedantic on a "MB/s" // readout). Returns e.g. "12.4 MB", "850 KB", "2.3 GB". function formatBytes(n: number): string { if (!isFinite(n) || n < 0) return '—'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; let v = n; while (v >= 1000 && i < units.length - 1) { v /= 1000; i++; } return v >= 100 || i === 0 ? `${v.toFixed(0)} ${units[i]}` : `${v.toFixed(1)} ${units[i]}`; } function formatRate(bps: number): string { return `${formatBytes(bps)}/s`; } // Render a humanised duration: 5s, 1m 23s, 2h 14m. Sub-second collapses to 0s // rather than "0ms" — at the second-tick cadence the UI uses, finer precision // is just noise. function formatDuration(ms: number): string { if (ms < 0 || !isFinite(ms)) return '—'; const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; const m = Math.floor(s / 60); const remS = s % 60; if (m < 60) return remS ? `${m}m ${remS}s` : `${m}m`; const h = Math.floor(m / 60); const remM = m % 60; return remM ? `${h}h ${remM}m` : `${h}h`; } // IOStatsLine renders a single line of live throughput info beneath each // mount card. Source is always rclone after the rclone-only consolidation, // so we report file-level transfers/s rather than kernel IOPS. function IOStatsLine({ stats }: { stats: MountIOStats }) { const opsLabel = 'transfers/s'; const opsValue = stats.transfersPerSec; const ageMs = Date.now() - stats.lastSampleAt; const stale = ageMs > 30000; // >30s old means sampler hasn't ticked recently return (
↓ {formatRate(stats.readBps)} {opsValue !== undefined && opsValue > 0 && ( <> · {opsValue.toFixed(opsValue >= 10 ? 0 : 1)} {opsLabel}> )} <> · {formatBytes(stats.bytesRead)} read total> {stats.bytesWritten !== undefined && stats.bytesWritten > 0 && ( <> · {formatBytes(stats.bytesWritten)} written> )}
); } interface ScanProgressProps { mount: Mount; now: number; } // Progress bar shows scan ETA when running, an idle row with a countdown to // the next adaptive scan when waiting. We can't show true % complete (we // don't know the file count up-front), so the running bar is filled by // elapsed/lastDuration capped at 95% — once we exceed the previous duration, // it pins at 95% to signal "taking longer than last time". function ScanProgress({ mount, now }: ScanProgressProps) { if (!mount.pollingEnabled) return null; const isScanning = !!mount.scanning && !!mount.currentScanStartedAt; const lastDuration = mount.lastScanDurationMs ?? 0; let fill = 0; let label = ''; if (isScanning) { const elapsed = Math.max(0, now - (mount.currentScanStartedAt ?? now)); if (lastDuration > 0) { fill = Math.min(0.95, elapsed / lastDuration); label = `Scanning… ${formatDuration(elapsed)} elapsed (last took ${formatDuration(lastDuration)})`; } else { fill = 0.4; // first scan ever — no ETA available, show indeterminate-ish label = `Scanning… ${formatDuration(elapsed)} elapsed (first scan, no ETA yet)`; } } else { const sinceLast = mount.lastPolledScan ? now - mount.lastPolledScan : 0; const untilNext = mount.nextScanAt ? Math.max(0, mount.nextScanAt - now) : 0; if (lastDuration > 0 && mount.lastPolledScan) { label = `Last scan ${formatDuration(sinceLast)} ago (took ${formatDuration(lastDuration)})`; if (mount.nextScanAt) label += ` • next in ${formatDuration(untilNext)}`; } else if (mount.nextScanAt) { label = `Waiting — next scan in ${formatDuration(untilNext)}`; } else { label = 'Polling enabled — waiting for first scan'; } } return ({label}
No mounts configured. Click "Add Mount" to get started.
Type: {mount.type} | Path: {mount.mountPath}
Status:{' '} {mount.mounted ? 'Mounted' : 'Not mounted'}
{mount.pollingEnabled && (Polling:{' '} {mount.pollingActive ? 'Active' : 'Inactive'} {' • Floor: '}{(mount.pollingIntervalMs || 30000) / 1000}s {' • Adaptive 10× idle'}
)} {mount.ioStats &&Error: {mount.error}
)}