Files
naxos-ui/src/views/StorageView.tsx
T
2026-09-03 23:59:06 +02:00

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>
);
};