improved kv metacore
This commit is contained in:
+220
-42
@@ -1,19 +1,47 @@
|
||||
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: 'nfs' | 'smb' | 'rclone';
|
||||
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 {
|
||||
@@ -43,6 +71,137 @@ const dangerButtonStyle: React.CSSProperties = {
|
||||
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 (
|
||||
<p
|
||||
style={{
|
||||
color: stale ? '#6b7280' : '#9ca3af',
|
||||
fontSize: '0.85rem',
|
||||
margin: '0.25rem 0 0',
|
||||
}}
|
||||
title={`source: ${stats.source} · sample age: ${Math.round(ageMs / 1000)}s`}
|
||||
>
|
||||
↓ {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</>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<div
|
||||
style={{
|
||||
height: '6px',
|
||||
background: '#0f3460',
|
||||
borderRadius: '3px',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${Math.round(fill * 100)}%`,
|
||||
height: '100%',
|
||||
background: isScanning ? '#4ade80' : '#374151',
|
||||
transition: 'width 0.5s linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ color: '#9ca3af', fontSize: '0.8rem', margin: '0.25rem 0 0' }}>{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Mounts() {
|
||||
const [mounts, setMounts] = useState<Mount[]>([]);
|
||||
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
|
||||
@@ -50,19 +209,23 @@ export default function Mounts() {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
type: 'nfs' as 'nfs' | 'smb' | 'rclone',
|
||||
nfsServer: '',
|
||||
nfsPath: '',
|
||||
type: 'smb' as 'smb' | 'rclone',
|
||||
smbServer: '',
|
||||
smbShare: '',
|
||||
smbUsername: '',
|
||||
smbPassword: '',
|
||||
rcloneRemote: '',
|
||||
rclonePath: '',
|
||||
pollingEnabled: false,
|
||||
cacheMaxSize: '',
|
||||
cacheMaxAge: '',
|
||||
dirCacheTime: '',
|
||||
pollingEnabled: true,
|
||||
pollingIntervalMs: 30000,
|
||||
});
|
||||
const [scanning, setScanning] = useState<string | null>(null);
|
||||
// 1Hz ticker so the progress bar's elapsed/countdown updates between API
|
||||
// refetches (which run every 5s).
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
|
||||
const fetchMounts = async () => {
|
||||
try {
|
||||
@@ -94,7 +257,11 @@ export default function Mounts() {
|
||||
fetchMounts();
|
||||
fetchRemotes();
|
||||
const interval = setInterval(fetchMounts, 5000);
|
||||
return () => clearInterval(interval);
|
||||
const tick = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
clearInterval(tick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMount = async (id: string) => {
|
||||
@@ -143,10 +310,7 @@ export default function Mounts() {
|
||||
type: formData.type,
|
||||
};
|
||||
|
||||
if (formData.type === 'nfs') {
|
||||
body.nfsServer = formData.nfsServer;
|
||||
body.nfsPath = formData.nfsPath;
|
||||
} else if (formData.type === 'smb') {
|
||||
if (formData.type === 'smb') {
|
||||
body.smbServer = formData.smbServer;
|
||||
body.smbShare = formData.smbShare;
|
||||
if (formData.smbUsername) body.smbUsername = formData.smbUsername;
|
||||
@@ -156,6 +320,12 @@ export default function Mounts() {
|
||||
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) {
|
||||
@@ -171,16 +341,17 @@ export default function Mounts() {
|
||||
setShowForm(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
type: 'nfs',
|
||||
nfsServer: '',
|
||||
nfsPath: '',
|
||||
type: 'smb',
|
||||
smbServer: '',
|
||||
smbShare: '',
|
||||
smbUsername: '',
|
||||
smbPassword: '',
|
||||
rcloneRemote: '',
|
||||
rclonePath: '',
|
||||
pollingEnabled: false,
|
||||
cacheMaxSize: '',
|
||||
cacheMaxAge: '',
|
||||
dirCacheTime: '',
|
||||
pollingEnabled: true,
|
||||
pollingIntervalMs: 30000,
|
||||
});
|
||||
fetchMounts();
|
||||
@@ -231,32 +402,15 @@ export default function Mounts() {
|
||||
<select
|
||||
style={inputStyle}
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'nfs' | 'smb' | 'rclone' })}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value as 'smb' | 'rclone';
|
||||
setFormData({ ...formData, type });
|
||||
}}
|
||||
>
|
||||
<option value="nfs">NFS</option>
|
||||
<option value="smb">SMB/CIFS</option>
|
||||
<option value="rclone">rclone</option>
|
||||
<option value="smb">SMB (via rclone)</option>
|
||||
<option value="rclone">rclone (pre-defined remote)</option>
|
||||
</select>
|
||||
|
||||
{formData.type === 'nfs' && (
|
||||
<>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="NFS Server (e.g., 192.168.1.100)"
|
||||
value={formData.nfsServer}
|
||||
onChange={(e) => setFormData({ ...formData, nfsServer: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="NFS Path (e.g., /export/media)"
|
||||
value={formData.nfsPath}
|
||||
onChange={(e) => setFormData({ ...formData, nfsPath: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{formData.type === 'smb' && (
|
||||
<>
|
||||
<input
|
||||
@@ -313,6 +467,30 @@ export default function Mounts() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '1rem', borderTop: '1px solid #0f3460', paddingTop: '1rem' }}>
|
||||
<p style={{ color: '#9ca3af', fontSize: '0.85rem', marginTop: 0, marginBottom: '0.5rem' }}>
|
||||
VFS cache (leave blank for defaults: 50G / 24h / 5m)
|
||||
</p>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Cache max size (e.g., 50G)"
|
||||
value={formData.cacheMaxSize}
|
||||
onChange={(e) => setFormData({ ...formData, cacheMaxSize: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Cache max age (e.g., 24h)"
|
||||
value={formData.cacheMaxAge}
|
||||
onChange={(e) => setFormData({ ...formData, cacheMaxAge: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
style={inputStyle}
|
||||
placeholder="Dir cache time (e.g., 5m)"
|
||||
value={formData.dirCacheTime}
|
||||
onChange={(e) => setFormData({ ...formData, dirCacheTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem', borderTop: '1px solid #0f3460', paddingTop: '1rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', marginBottom: '0.5rem', cursor: 'pointer' }}>
|
||||
<input
|
||||
@@ -362,17 +540,17 @@ export default function Mounts() {
|
||||
</span>
|
||||
</p>
|
||||
{mount.pollingEnabled && (
|
||||
<p style={{ color: '#888', fontSize: '0.9rem' }}>
|
||||
<p style={{ color: '#888', fontSize: '0.9rem', margin: '0.25rem 0 0' }}>
|
||||
Polling:{' '}
|
||||
<span style={{ color: mount.pollingActive ? '#4ade80' : '#f59e0b' }}>
|
||||
{mount.pollingActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
{' | Interval: '}{(mount.pollingIntervalMs || 30000) / 1000}s
|
||||
{mount.lastPolledScan && (
|
||||
<> | Last scan: {new Date(mount.lastPolledScan).toLocaleTimeString()}</>
|
||||
)}
|
||||
{' • Floor: '}{(mount.pollingIntervalMs || 30000) / 1000}s
|
||||
{' • Adaptive 10× idle'}
|
||||
</p>
|
||||
)}
|
||||
{mount.ioStats && <IOStatsLine stats={mount.ioStats} />}
|
||||
<ScanProgress mount={mount} now={now} />
|
||||
{mount.error && (
|
||||
<p style={{ color: '#f87171', marginTop: '0.5rem' }}>Error: {mount.error}</p>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user