ZMB Webui: Complete Project – Rebrand & Initial Clean Commit

ARCHITECTURE
============
Backend: FastAPI + uvicorn (port 8000)
  - JWT authentication with PAM system users
  - ZFS CLI wrapper with caching (30-60s TTL)
  - WebSocket pool status broadcaster (30s interval)
  - Services: auth, zfs_runner, file_manager, shares, identities, system_info
  - Routers: pools, datasets, snapshots, shares, identities, navigator, system

Frontend: Next.js 15 + TypeScript (static export)
  - Incremental Static Regeneration (ISR) for weak hardware
  - Type-safe API client (lib/api.ts)
  - Dark mode + custom Tailwind theme
  - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc.

DEPLOYMENT
==========
Test Target: 192.168.1.179:8090 (Debian LXC)
Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64)
Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh)

FEATURES COMPLETED
==================
Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage)
  - Real-time stats with color-coded progress bars
  - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns)
  - ISR-optimized for fast loads on weak hardware

REBRANDING
==========
Renamed throughout:
  - Project: 'ZFS Manager' → 'ZMB Webui'
  - Services: 'zfs-manager' → 'zmb-webui'
  - Systemd units: zfs-manager-backend → zmb-webui-backend
  - Configuration files and documentation

Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
Claude Code
2026-04-22 00:26:23 +02:00
committed by Patrick
commit 92bed208e0
108 changed files with 29925 additions and 0 deletions
+386
View File
@@ -0,0 +1,386 @@
"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)
} 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.creation)}</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>
)
}