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
+364
View File
@@ -0,0 +1,364 @@
"use client"
import { useEffect, useState, useMemo } from "react"
import { useRouter } from "next/navigation"
import { api } from "@/lib/api"
import { Header } from "@/components/Header"
import { Card, CardContent } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { RefreshCw, X, Zap, Target, Wifi, Clock, FolderOpen } from "lucide-react"
type Unit = {
name: string
active: string
sub: string
description: string
}
type UnitType = "services" | "targets" | "sockets" | "timers" | "paths"
export default function Services() {
const router = useRouter()
const [units, setUnits] = useState<Record<UnitType, Unit[]>>({
services: [],
targets: [],
sockets: [],
timers: [],
paths: [],
})
const [loading, setLoading] = useState(true)
// Filter states
const [activeTab, setActiveTab] = useState<UnitType>("services")
const [searchText, setSearchText] = useState("")
const [activeStatus, setActiveStatus] = useState("all") // all, active, inactive
const [fileStatus, setFileStatus] = useState("all") // all, enabled, disabled, static
useEffect(() => {
const token = localStorage.getItem("access_token")
if (!token) {
router.push("/login")
return
}
loadUnits()
}, [router])
const loadUnits = async () => {
setLoading(true)
try {
const data = await api.getUnits()
setUnits(data)
} catch (error) {
console.error("Failed to load units:", error)
setUnits({
services: [],
targets: [],
sockets: [],
timers: [],
paths: [],
})
} finally {
setLoading(false)
}
}
// Filter current tab's units
const filteredUnits = useMemo(() => {
let filtered = units[activeTab] || []
// Search filter (name or description)
if (searchText) {
filtered = filtered.filter((unit) => {
const searchLower = searchText.toLowerCase()
return (
unit.name.toLowerCase().includes(searchLower) ||
unit.description.toLowerCase().includes(searchLower)
)
})
}
// Active status filter
if (activeStatus === "active") {
filtered = filtered.filter((unit) => unit.active === "active")
} else if (activeStatus === "inactive") {
filtered = filtered.filter((unit) => unit.active === "inactive")
}
// File status filter
if (fileStatus !== "all") {
filtered = filtered.filter((unit) => {
const sub = unit.sub.toLowerCase()
if (fileStatus === "enabled") {
return sub === "enabled"
} else if (fileStatus === "disabled") {
return sub === "disabled"
} else if (fileStatus === "static") {
return sub === "static"
}
return true
})
}
return filtered
}, [units, activeTab, searchText, activeStatus, fileStatus])
const tabConfig: Record<
UnitType,
{ label: string; icon: React.ReactNode; count: number }
> = {
services: {
label: "Dienste",
icon: <Zap className="w-4 h-4" />,
count: units.services.length,
},
targets: {
label: "Ziele",
icon: <Target className="w-4 h-4" />,
count: units.targets.length,
},
sockets: {
label: "Sockets",
icon: <Wifi className="w-4 h-4" />,
count: units.sockets.length,
},
timers: {
label: "Timer",
icon: <Clock className="w-4 h-4" />,
count: units.timers.length,
},
paths: {
label: "Pfade",
icon: <FolderOpen className="w-4 h-4" />,
count: units.paths.length,
},
}
const getStatusBadge = (status: string) => {
if (status === "active") {
return <Badge className="bg-green-600 hover:bg-green-700">Aktiv</Badge>
}
return <Badge variant="secondary">Inaktiv</Badge>
}
const getSubStatusBadge = (sub: string) => {
const subLower = sub.toLowerCase()
if (subLower === "running") {
return <Badge className="bg-blue-600 hover:bg-blue-700">Läuft</Badge>
} else if (subLower === "enabled") {
return <Badge className="bg-green-500 hover:bg-green-600">Aktiviert</Badge>
} else if (subLower === "disabled") {
return <Badge variant="secondary">Deaktiviert</Badge>
} else if (subLower === "static") {
return <Badge variant="outline">Statisch</Badge>
}
return <Badge variant="outline">{sub}</Badge>
}
const hasActiveFilters = searchText || activeStatus !== "all" || fileStatus !== "all"
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">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Systemd Einheiten</h1>
<p className="text-muted-foreground mt-1">
Dienste, Ziele, Sockets, Timer und Pfade
</p>
</div>
<Button onClick={loadUnits} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Aktualisieren
</Button>
</div>
{/* Tabs */}
<div className="mb-6 flex flex-wrap gap-2">
{(Object.keys(tabConfig) as UnitType[]).map((tab) => (
<button
key={tab}
onClick={() => {
setActiveTab(tab)
setSearchText("")
setActiveStatus("all")
setFileStatus("all")
}}
className={`flex items-center gap-2 px-4 py-2 rounded-md transition-colors ${
activeTab === tab
? "bg-primary text-primary-foreground"
: "border border-border hover:bg-muted"
}`}
>
{tabConfig[tab].icon}
{tabConfig[tab].label}
<span className="text-xs ml-1 opacity-75">
({tabConfig[tab].count})
</span>
</button>
))}
</div>
{/* Filter Section */}
<Card className="mb-6">
<CardContent className="p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Search Filter */}
<div>
<label className="block text-sm font-medium mb-2">
Nach Name oder Beschreibung
</label>
<input
type="text"
placeholder="z.B. ssh, apache..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
/>
</div>
{/* Active Status Filter */}
<div>
<label className="block text-sm font-medium mb-2">
Aktiver Status
</label>
<select
value={activeStatus}
onChange={(e) => setActiveStatus(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
>
<option value="all">Alle</option>
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
{/* File Status Filter */}
<div>
<label className="block text-sm font-medium mb-2">
Dateistatus
</label>
<select
value={fileStatus}
onChange={(e) => setFileStatus(e.target.value)}
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
>
<option value="all">Alle</option>
<option value="enabled">Aktiviert</option>
<option value="disabled">Deaktiviert</option>
<option value="static">Statisch</option>
</select>
</div>
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="mt-4 flex flex-wrap gap-2 items-center">
<span className="text-sm text-muted-foreground">Filter:</span>
{searchText && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-purple-500/10 text-purple-600 text-xs">
&quot;{searchText}&quot;
<button
onClick={() => setSearchText("")}
className="hover:text-purple-600/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{activeStatus !== "all" && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-green-500/10 text-green-600 text-xs">
{activeStatus === "active" ? "Aktiv" : "Inaktiv"}
<button
onClick={() => setActiveStatus("all")}
className="hover:text-green-600/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{fileStatus !== "all" && (
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
{fileStatus === "enabled"
? "Aktiviert"
: fileStatus === "disabled"
? "Deaktiviert"
: "Statisch"}
<button
onClick={() => setFileStatus("all")}
className="hover:text-blue-600/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
)}
</CardContent>
</Card>
{/* Units Table */}
<Card>
<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">Name</th>
<th className="text-left py-3 px-4 font-medium">
Aktiver Status
</th>
<th className="text-left py-3 px-4 font-medium">
Dateistatus
</th>
<th className="text-left py-3 px-4 font-medium">
Beschreibung
</th>
</tr>
</thead>
<tbody>
{filteredUnits.length === 0 ? (
<tr>
<td
colSpan={4}
className="py-8 px-4 text-center text-muted-foreground"
>
{loading ? "Lädt..." : "Keine Einheiten gefunden"}
</td>
</tr>
) : (
filteredUnits.map((unit, idx) => (
<tr
key={idx}
className="border-b border-border/50 hover:bg-muted/30"
>
<td className="py-3 px-4 font-mono text-xs">
{unit.name}
</td>
<td className="py-3 px-4">
{getStatusBadge(unit.active)}
</td>
<td className="py-3 px-4">
{getSubStatusBadge(unit.sub)}
</td>
<td className="py-3 px-4 text-xs text-muted-foreground truncate">
{unit.description || "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="px-4 py-2 text-xs text-muted-foreground border-t">
Zeige {filteredUnits.length} von {units[activeTab]?.length || 0}{" "}
{tabConfig[activeTab].label.toLowerCase()}
</div>
</CardContent>
</Card>
</main>
</div>
)
}