92bed208e0
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>
365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
"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>
|
|
)
|
|
}
|