ab75f462cc
Sub-Datasets (tank/share) waren initial eingeklappt, dadurch waren alle Zeilen unsichtbar. Beim Laden werden jetzt alle Top-Level-Datasets automatisch expandiert. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
390 lines
14 KiB
TypeScript
390 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useCallback } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { api, Snapshot, Dataset } from "@/lib/api"
|
|
import { Header } from "@/components/Header"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Dialog } from "@/components/ui/dialog"
|
|
import { RefreshCw, AlertCircle, Trash2, Plus, RotateCcw, ChevronRight, ChevronDown } from "lucide-react"
|
|
import { formatBytes } from "@/lib/utils"
|
|
|
|
function formatUnix(ts: number) {
|
|
return new Date(ts * 1000).toLocaleString()
|
|
}
|
|
|
|
export default function SnapshotsPage() {
|
|
const router = useRouter()
|
|
const [snapshots, setSnapshots] = useState<Snapshot[]>([])
|
|
const [datasets, setDatasets] = useState<Dataset[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [deleting, setDeleting] = useState<string | null>(null)
|
|
const [filterDataset, setFilterDataset] = useState("")
|
|
|
|
// Create dialog
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [createDataset, setCreateDataset] = useState("")
|
|
const [createName, setCreateName] = useState("")
|
|
const [creating, setCreating] = useState(false)
|
|
|
|
// Rollback dialog
|
|
const [rollbackTarget, setRollbackTarget] = useState<string | null>(null)
|
|
const [rollingBack, setRollingBack] = useState(false)
|
|
const [expandedDatasets, setExpandedDatasets] = useState<Set<string>>(new Set())
|
|
|
|
const fetchSnapshots = useCallback(async (dataset?: string) => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
const data = await api.getSnapshots(dataset || undefined)
|
|
setSnapshots(data)
|
|
// Auto-expand all top-level datasets so rows are visible by default
|
|
const topDs = new Set(data.map((s) => s.name.split("@")[0].split("/")[0]))
|
|
setExpandedDatasets(topDs)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to fetch snapshots")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("access_token")
|
|
if (!token) { router.push("/login"); return }
|
|
|
|
fetchSnapshots()
|
|
api.getDatasets().then(setDatasets).catch(() => {})
|
|
const iv = setInterval(() => fetchSnapshots(filterDataset || undefined), 60000)
|
|
return () => clearInterval(iv)
|
|
}, [router, fetchSnapshots, filterDataset])
|
|
|
|
const handleFilterChange = (ds: string) => {
|
|
setFilterDataset(ds)
|
|
fetchSnapshots(ds || undefined)
|
|
}
|
|
|
|
const handleDelete = async (name: string) => {
|
|
if (!confirm(`Delete snapshot "${name}"?\nThis cannot be undone.`)) return
|
|
try {
|
|
setDeleting(name)
|
|
await api.deleteSnapshot(name)
|
|
setSnapshots((prev) => prev.filter((s) => s.name !== name))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete snapshot")
|
|
} finally {
|
|
setDeleting(null)
|
|
}
|
|
}
|
|
|
|
const handleCreate = async () => {
|
|
if (!createDataset) return
|
|
setCreating(true)
|
|
try {
|
|
await api.createSnapshot(createDataset, createName || undefined)
|
|
setCreateOpen(false)
|
|
setCreateDataset("")
|
|
setCreateName("")
|
|
fetchSnapshots(filterDataset || undefined)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create snapshot")
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleRollback = async () => {
|
|
if (!rollbackTarget) return
|
|
setRollingBack(true)
|
|
try {
|
|
await api.rollbackSnapshot(rollbackTarget)
|
|
setRollbackTarget(null)
|
|
fetchSnapshots(filterDataset || undefined)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Rollback failed")
|
|
} finally {
|
|
setRollingBack(false)
|
|
}
|
|
}
|
|
|
|
// Unique dataset names for filter dropdown
|
|
const datasetNames = Array.from(
|
|
new Set(snapshots.map((s) => s.name.split("@")[0]).filter(Boolean))
|
|
).sort()
|
|
|
|
const getDatasetDepth = (name: string): number => {
|
|
return name.split("/").length - 1
|
|
}
|
|
|
|
const getSnapshotsByDataset = (dsName: string): Snapshot[] => {
|
|
return snapshots.filter((s) => s.name.split("@")[0] === dsName)
|
|
}
|
|
|
|
const getTopLevelDatasets = (): string[] => {
|
|
const topLevel = new Set<string>()
|
|
snapshots.forEach((snap) => {
|
|
const dsName = snap.name.split("@")[0]
|
|
const topDsName = dsName.split("/")[0]
|
|
topLevel.add(topDsName)
|
|
})
|
|
return Array.from(topLevel).sort()
|
|
}
|
|
|
|
const getAllDatasetsByPrefix = (prefix?: string): string[] => {
|
|
const allDs = new Set<string>()
|
|
snapshots.forEach((snap) => {
|
|
const dsName = snap.name.split("@")[0]
|
|
if (!prefix) {
|
|
if (dsName.split("/").length === 1) allDs.add(dsName)
|
|
} else {
|
|
const dsPrefix = prefix + "/"
|
|
if (dsName.startsWith(dsPrefix) && dsName !== prefix) {
|
|
const remaining = dsName.slice(dsPrefix.length)
|
|
if (remaining.split("/").length === 1) {
|
|
allDs.add(dsName)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
return Array.from(allDs).sort()
|
|
}
|
|
|
|
const toggleExpand = (name: string) => {
|
|
const newExpanded = new Set(expandedDatasets)
|
|
if (newExpanded.has(name)) {
|
|
newExpanded.delete(name)
|
|
} else {
|
|
newExpanded.add(name)
|
|
}
|
|
setExpandedDatasets(newExpanded)
|
|
}
|
|
|
|
const renderSnapshotTree = (parentDs?: string): React.ReactNode[] => {
|
|
const items: React.ReactNode[] = []
|
|
const datasets = parentDs ? getAllDatasetsByPrefix(parentDs) : getTopLevelDatasets()
|
|
|
|
datasets.forEach((dsName) => {
|
|
const childDatasets = getAllDatasetsByPrefix(dsName)
|
|
const isExpanded = expandedDatasets.has(dsName)
|
|
const depth = getDatasetDepth(dsName)
|
|
const snapshotsForDs = getSnapshotsByDataset(dsName)
|
|
|
|
snapshotsForDs.forEach((snap, idx) => {
|
|
const [, tag] = snap.name.split("@")
|
|
items.push(
|
|
<tr key={snap.name} className="border-b border-border/50 hover:bg-muted/30">
|
|
<td className="py-3 px-4 text-muted-foreground font-mono text-xs" style={{ paddingLeft: `${depth * 24 + 16}px` }}>
|
|
<div className="flex items-center gap-2">
|
|
{idx === 0 && childDatasets.length > 0 && (
|
|
<button
|
|
onClick={() => toggleExpand(dsName)}
|
|
className="p-0 hover:bg-muted rounded"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="w-4 h-4" />
|
|
) : (
|
|
<ChevronRight className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{(idx > 0 || childDatasets.length === 0) && <div className="w-4" />}
|
|
<span>{idx === 0 ? dsName.split("/").pop() : ""}</span>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-4 font-mono text-xs font-medium">{tag}</td>
|
|
<td className="py-3 px-4 text-muted-foreground">{formatUnix(snap.created)}</td>
|
|
<td className="py-3 px-4">{formatBytes(snap.used)}</td>
|
|
<td className="py-3 px-4">{formatBytes(snap.referenced)}</td>
|
|
<td className="py-3 px-4 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Rollback to this snapshot"
|
|
onClick={() => setRollbackTarget(snap.name)}
|
|
>
|
|
<RotateCcw className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Delete snapshot"
|
|
onClick={() => handleDelete(snap.name)}
|
|
disabled={deleting === snap.name}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})
|
|
|
|
if (isExpanded && childDatasets.length > 0) {
|
|
items.push(...renderSnapshotTree(dsName))
|
|
}
|
|
})
|
|
|
|
return items
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Snapshots</h1>
|
|
<p className="text-muted-foreground mt-1">Manage ZFS snapshots</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button variant="outline" size="sm" onClick={() => fetchSnapshots(filterDataset || undefined)} disabled={loading}>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
New Snapshot
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<div className="mb-6 flex items-center gap-3">
|
|
<label className="text-sm text-muted-foreground">Dataset:</label>
|
|
<select
|
|
value={filterDataset}
|
|
onChange={(e) => handleFilterChange(e.target.value)}
|
|
className="text-sm bg-background border border-border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<option value="">All datasets</option>
|
|
{datasetNames.map((ds) => (
|
|
<option key={ds} value={ds}>{ds}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-sm text-muted-foreground">{snapshots.length} snapshots</span>
|
|
</div>
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="mb-6 flex items-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading */}
|
|
{loading && snapshots.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<RefreshCw className="w-8 h-8 text-muted-foreground animate-spin mx-auto" />
|
|
<p className="mt-4 text-muted-foreground">Loading snapshots…</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
{snapshots.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Snapshots</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-border bg-muted/30">
|
|
<tr>
|
|
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Dataset</th>
|
|
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Snapshot</th>
|
|
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Created</th>
|
|
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Used</th>
|
|
<th className="text-left py-3 px-4 font-medium text-muted-foreground">Referenced</th>
|
|
<th className="text-right py-3 px-4 font-medium text-muted-foreground">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{renderSnapshotTree()}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{!loading && snapshots.length === 0 && !error && (
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
No snapshots found.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</main>
|
|
|
|
{/* Create Snapshot Dialog */}
|
|
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} title="Create Snapshot">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">Dataset *</label>
|
|
<select
|
|
value={createDataset}
|
|
onChange={(e) => setCreateDataset(e.target.value)}
|
|
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
>
|
|
<option value="">Select dataset…</option>
|
|
{datasets.filter((d) => d.type === "filesystem").map((d) => (
|
|
<option key={d.name} value={d.name}>{d.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-1">
|
|
Name <span className="text-muted-foreground font-normal">(optional, auto-generated if empty)</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={createName}
|
|
onChange={(e) => setCreateName(e.target.value)}
|
|
placeholder="e.g. before-upgrade"
|
|
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
|
|
<Button onClick={handleCreate} disabled={!createDataset || creating}>
|
|
{creating ? "Creating…" : "Create"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Rollback Dialog */}
|
|
<Dialog
|
|
open={!!rollbackTarget}
|
|
onClose={() => setRollbackTarget(null)}
|
|
title="Rollback to Snapshot"
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
|
<strong>Warning:</strong> Rollback will permanently destroy all data written after this snapshot.
|
|
This cannot be undone.
|
|
</div>
|
|
<p className="text-sm">
|
|
Roll back to: <span className="font-mono font-medium">{rollbackTarget}</span>?
|
|
</p>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="outline" onClick={() => setRollbackTarget(null)}>Cancel</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={handleRollback}
|
|
disabled={rollingBack}
|
|
>
|
|
{rollingBack ? "Rolling back…" : "Rollback"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|