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}

); } export default function Mounts() { const [mounts, setMounts] = useState([]); const [remotes, setRemotes] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [formData, setFormData] = useState({ name: '', type: 'smb' as 'smb' | 'rclone', smbServer: '', smbShare: '', smbUsername: '', smbPassword: '', rcloneRemote: '', rclonePath: '', cacheMaxSize: '', cacheMaxAge: '', dirCacheTime: '', pollingEnabled: true, pollingIntervalMs: 30000, }); const [scanning, setScanning] = useState(null); // 1Hz ticker so the progress bar's elapsed/countdown updates between API // refetches (which run every 5s). const [now, setNow] = useState(() => Date.now()); const fetchMounts = async () => { try { const res = await fetch('/api/mounts'); if (res.ok) { const data = await res.json(); setMounts(data.mounts || []); } } catch (err) { console.error('Failed to fetch mounts:', err); } finally { setLoading(false); } }; const fetchRemotes = async () => { try { const res = await fetch('/api/mounts/rclone/remotes'); if (res.ok) { const data = await res.json(); setRemotes(data.remotes || []); } } catch (err) { console.error('Failed to fetch remotes:', err); } }; useEffect(() => { fetchMounts(); fetchRemotes(); const interval = setInterval(fetchMounts, 5000); const tick = setInterval(() => setNow(Date.now()), 1000); return () => { clearInterval(interval); clearInterval(tick); }; }, []); const handleMount = async (id: string) => { await fetch(`/api/mounts/${id}/mount`, { method: 'POST' }); fetchMounts(); }; const handleUnmount = async (id: string) => { await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' }); fetchMounts(); }; const handleDelete = async (id: string) => { if (!confirm('Are you sure you want to delete this mount?')) return; await fetch(`/api/mounts/${id}`, { method: 'DELETE' }); fetchMounts(); }; const handleScan = async (id: string) => { setScanning(id); try { await fetch(`/api/mounts/${id}/scan`, { method: 'POST' }); fetchMounts(); } finally { setScanning(null); } }; const handleUpdatePolling = async (id: string, pollingEnabled: boolean, pollingIntervalMs?: number) => { const body: Record = { pollingEnabled }; if (pollingIntervalMs !== undefined) { body.pollingIntervalMs = pollingIntervalMs; } await fetch(`/api/mounts/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); fetchMounts(); }; const handleCreate = async (e: React.FormEvent) => { e.preventDefault(); const body: Record = { name: formData.name, type: formData.type, }; if (formData.type === 'smb') { body.smbServer = formData.smbServer; body.smbShare = formData.smbShare; if (formData.smbUsername) body.smbUsername = formData.smbUsername; if (formData.smbPassword) body.smbPassword = formData.smbPassword; } else if (formData.type === 'rclone') { body.rcloneRemote = formData.rcloneRemote; body.rclonePath = formData.rclonePath; } // Cache knobs — only forward non-empty values; the server applies defaults // when fields are omitted. if (formData.cacheMaxSize) body.cacheMaxSize = formData.cacheMaxSize; if (formData.cacheMaxAge) body.cacheMaxAge = formData.cacheMaxAge; if (formData.dirCacheTime) body.dirCacheTime = formData.dirCacheTime; // Add polling configuration body.pollingEnabled = formData.pollingEnabled; if (formData.pollingEnabled && formData.pollingIntervalMs >= 5000) { body.pollingIntervalMs = formData.pollingIntervalMs; } await fetch('/api/mounts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); setShowForm(false); setFormData({ name: '', type: 'smb', smbServer: '', smbShare: '', smbUsername: '', smbPassword: '', rcloneRemote: '', rclonePath: '', cacheMaxSize: '', cacheMaxAge: '', dirCacheTime: '', pollingEnabled: true, pollingIntervalMs: 30000, }); fetchMounts(); }; const inputStyle: React.CSSProperties = { width: '100%', padding: '0.5rem', marginBottom: '0.5rem', background: '#1a1a2e', border: '1px solid #0f3460', borderRadius: '4px', color: '#fff', }; if (loading) return
Loading...
; return (

Mount Management

rclone UI
{showForm && (

Add New Mount

setFormData({ ...formData, name: e.target.value })} required /> {formData.type === 'smb' && ( <> setFormData({ ...formData, smbServer: e.target.value })} required /> setFormData({ ...formData, smbShare: e.target.value })} required /> setFormData({ ...formData, smbUsername: e.target.value })} /> setFormData({ ...formData, smbPassword: e.target.value })} /> )} {formData.type === 'rclone' && ( <> setFormData({ ...formData, rclonePath: e.target.value })} /> )}

VFS cache (leave blank for defaults: 50G / 24h / 5m)

setFormData({ ...formData, cacheMaxSize: e.target.value })} /> setFormData({ ...formData, cacheMaxAge: e.target.value })} /> setFormData({ ...formData, dirCacheTime: e.target.value })} />
{formData.pollingEnabled && ( setFormData({ ...formData, pollingIntervalMs: parseInt(e.target.value) || 30000 })} /> )}
)} {mounts.length === 0 ? (

No mounts configured. Click "Add Mount" to get started.

) : ( mounts.map((mount) => (

{mount.name}

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 && } {mount.error && (

Error: {mount.error}

)}
{mount.mounted ? ( ) : ( )}
{mount.mounted && (
)}
)) )}
); }