feat(ui): implement modern NaxOS Web Dashboard with Perses analytics and ZFS migration wizard
CI & Build NaxOS Web Dashboard / build (push) Failing after 1m29s
CI & Build NaxOS Web Dashboard / build (push) Failing after 1m29s
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
HardDrive,
|
||||
Database,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
FolderTree,
|
||||
FileBox,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import type { ZfsPool, ZfsDataset, ZfsSnapshot, UnimportedPool } from '../types/index.js';
|
||||
|
||||
interface StorageViewProps {
|
||||
pools: ZfsPool[];
|
||||
datasets: ZfsDataset[];
|
||||
snapshots: ZfsSnapshot[];
|
||||
unimportedPools: UnimportedPool[];
|
||||
onRefresh: () => void;
|
||||
onImportPool: (poolName: string) => Promise<void>;
|
||||
onScrubPool: (poolName: string) => Promise<void>;
|
||||
onCreateSnapshot: (dataset: string, tag: string) => Promise<void>;
|
||||
onRollbackSnapshot: (snapshotName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const StorageView: React.FC<StorageViewProps> = ({
|
||||
pools,
|
||||
datasets,
|
||||
snapshots,
|
||||
unimportedPools,
|
||||
onRefresh,
|
||||
onImportPool,
|
||||
onScrubPool,
|
||||
onCreateSnapshot,
|
||||
onRollbackSnapshot,
|
||||
}) => {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'pools' | 'datasets' | 'snapshots' | 'migration'>('pools');
|
||||
const [selectedPool, setSelectedPool] = useState<string>(pools[0]?.name || 'tank');
|
||||
const [importingPoolName, setImportingPoolName] = useState<string | null>(null);
|
||||
const [importSuccess, setImportSuccess] = useState<string | null>(null);
|
||||
|
||||
const handleImport = async (poolName: string) => {
|
||||
setImportingPoolName(poolName);
|
||||
try {
|
||||
await onImportPool(poolName);
|
||||
setImportSuccess(`Pool '${poolName}' was successfully imported without data loss!`);
|
||||
setTimeout(() => setImportSuccess(null), 6000);
|
||||
} finally {
|
||||
setImportingPoolName(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sub-navigation Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">OpenZFS Storage Engine</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Enterprise storage layout, datasets, snapshot policies, and foreign pool migration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-900 border border-slate-800 rounded-xl">
|
||||
<button
|
||||
onClick={() => setActiveSubTab('pools')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||
activeSubTab === 'pools'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Storage Pools ({pools.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('datasets')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||
activeSubTab === 'datasets'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Datasets ({datasets.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('snapshots')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||
activeSubTab === 'snapshots'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Snapshots ({snapshots.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSubTab('migration')}
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||
activeSubTab === 'migration'
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'text-amber-400 hover:bg-amber-500/10'
|
||||
}`}
|
||||
>
|
||||
<span>Pool Migration</span>
|
||||
{unimportedPools.length > 0 && (
|
||||
<span className="h-2 w-2 rounded-full bg-amber-400 animate-ping" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importSuccess && (
|
||||
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 flex items-center gap-3 text-emerald-300 text-xs">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0" />
|
||||
<span>{importSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pools Tab */}
|
||||
{activeSubTab === 'pools' && (
|
||||
<div className="space-y-4">
|
||||
{pools.map((pool) => (
|
||||
<div key={pool.name} className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-slate-800 text-emerald-400">
|
||||
<Database className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<span>{pool.name}</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 font-semibold font-mono">
|
||||
{pool.health}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
OpenZFS Pool • ashift=12 • Compression=lz4/zstd
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onScrubPool(pool.name)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-medium border border-slate-700 transition"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span>Run Scrub</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pool Capacity Bar */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between text-xs text-slate-300">
|
||||
<span>Capacity Used: <strong>{pool.allocated}</strong> / {pool.size} ({pool.capacityPercent}%)</span>
|
||||
<span>Available Free: <strong>{pool.free}</strong></span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-800 h-2.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="bg-emerald-500 h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pool.capacityPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status summary */}
|
||||
<div className="pt-2 border-t border-slate-800/80 flex flex-wrap items-center justify-between text-xs text-slate-400 gap-2">
|
||||
<span>Last Scrub: {pool.scanStatus || 'Completed with 0 errors'}</span>
|
||||
<span>Fragmentation: <strong className="text-slate-200 font-mono">{pool.fragmentation}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Datasets Tab */}
|
||||
{activeSubTab === 'datasets' && (
|
||||
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<FolderTree className="h-4 w-4 text-emerald-400" />
|
||||
<span>ZFS Datasets & Mountpoints</span>
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">Total {datasets.length} datasets</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-950/60 text-slate-400 border-b border-slate-800 font-medium">
|
||||
<tr>
|
||||
<th className="px-5 py-3">Dataset Name</th>
|
||||
<th className="px-4 py-3">Mountpoint</th>
|
||||
<th className="px-4 py-3">Used</th>
|
||||
<th className="px-4 py-3">Available</th>
|
||||
<th className="px-4 py-3">Compression</th>
|
||||
<th className="px-4 py-3">Record Size</th>
|
||||
<th className="px-4 py-3">Quota</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||
{datasets.map((ds) => (
|
||||
<tr key={ds.name} className="hover:bg-slate-800/30 transition">
|
||||
<td className="px-5 py-3.5 font-medium text-white flex items-center gap-2 font-mono">
|
||||
<FileBox className="h-3.5 w-3.5 text-slate-400 shrink-0" />
|
||||
<span>{ds.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.mountpoint}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-emerald-400 font-semibold">{ds.used}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.available}</td>
|
||||
<td className="px-4 py-3.5 font-mono">
|
||||
<span className="px-2 py-0.5 rounded bg-slate-800 text-slate-300">
|
||||
{ds.compression}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-400">{ds.recordsize}</td>
|
||||
<td className="px-4 py-3.5 font-mono text-slate-400">
|
||||
{ds.quota === 'none' ? <span className="text-slate-600">None</span> : ds.quota}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Snapshots Tab */}
|
||||
{activeSubTab === 'snapshots' && (
|
||||
<div className="rounded-2xl bg-slate-900/80 border border-slate-800 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-emerald-400" />
|
||||
<span>ZFS Point-in-Time Snapshots</span>
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">Total {snapshots.length} snapshots</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-slate-950/60 text-slate-400 border-b border-slate-800 font-medium">
|
||||
<tr>
|
||||
<th className="px-5 py-3">Snapshot Name</th>
|
||||
<th className="px-4 py-3">Dataset</th>
|
||||
<th className="px-4 py-3">Created</th>
|
||||
<th className="px-4 py-3">Unique Used</th>
|
||||
<th className="px-4 py-3 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60 text-slate-300">
|
||||
{snapshots.map((snap) => (
|
||||
<tr key={snap.name} className="hover:bg-slate-800/30 transition">
|
||||
<td className="px-5 py-3 font-mono font-medium text-white">{snap.name}</td>
|
||||
<td className="px-4 py-3 font-mono text-slate-400">{snap.dataset}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{snap.creationTime}</td>
|
||||
<td className="px-4 py-3 font-mono text-emerald-400">{snap.usedBytes}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onRollbackSnapshot(snap.name)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded bg-slate-800 hover:bg-rose-500/20 text-slate-300 hover:text-rose-300 text-[11px] font-medium transition border border-slate-700"
|
||||
title="Rollback dataset to this exact snapshot state"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
<span>Rollback</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foreign Pool Migration Wizard */}
|
||||
{activeSubTab === 'migration' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-6 rounded-2xl bg-amber-500/5 border border-amber-500/20 space-y-3">
|
||||
<div className="flex items-center gap-2.5 text-amber-400 font-semibold text-sm">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
<span>Zero-Data-Loss Migration Engine</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-300 leading-relaxed">
|
||||
NaxOS provides first-class support for discovering and safely importing existing ZFS storage pools created on <strong>TrueNAS CORE/SCALE</strong> or raw <strong>NixOS systems (such as nixos-lukas)</strong>. The migration engine operates with safety-first guarantees: existing datasets, quotas, and critical application assets (such as your Immich photo library in <code>tank/media/photos</code>) are preserved untouched.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl bg-slate-900/80 border border-slate-800 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Detected Foreign ZFS Pools</h3>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Unimported pools ready for adoption</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-medium text-slate-200 border border-slate-700 transition"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span>Re-scan Disks</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{unimportedPools.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-slate-500">
|
||||
No foreign unimported pools detected. All connected pools are currently active.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{unimportedPools.map((pool) => (
|
||||
<div key={pool.name} className="p-4 rounded-xl bg-slate-950/70 border border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white font-mono">{pool.name}</span>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-400 font-semibold border border-emerald-500/20">
|
||||
{pool.state}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 font-mono">ID: {pool.id}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1">{pool.status}</p>
|
||||
<div className="flex items-center gap-2 mt-2 text-[11px] text-slate-500">
|
||||
<span>Member Disks:</span>
|
||||
{pool.disks.map((d) => (
|
||||
<span key={d} className="px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 font-mono">
|
||||
{d}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleImport(pool.name)}
|
||||
disabled={importingPoolName === pool.name}
|
||||
className="px-4 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-xs font-semibold shadow-sm transition shrink-0"
|
||||
>
|
||||
{importingPoolName === pool.name ? 'Importing Safely...' : 'Safe Import & Adopt'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user