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:
@@ -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">
|
||||
"{searchText}"
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user