349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
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>
|
|
);
|
|
};
|