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,340 @@
|
||||
"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 { RefreshCw, Filter, X } from "lucide-react"
|
||||
|
||||
type LogEntry = {
|
||||
text: string
|
||||
date?: Date
|
||||
unit?: string
|
||||
level?: "err" | "warning" | "info" | "debug"
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const router = useRouter()
|
||||
const [allLogs, setAllLogs] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [limit, setLimit] = useState(500)
|
||||
|
||||
// Filter states
|
||||
const [timeRange, setTimeRange] = useState("all") // all, 24h, 7d, 30d
|
||||
const [priority, setPriority] = useState("all") // all, err (error and higher)
|
||||
const [unit, setUnit] = useState("") // Unit/Service filter
|
||||
const [searchText, setSearchText] = useState("") // Free text search
|
||||
const [units, setUnits] = useState<string[]>([]) // Available units for dropdown
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (!token) {
|
||||
router.push("/login")
|
||||
return
|
||||
}
|
||||
|
||||
// Load logs
|
||||
loadLogs()
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
// Reload when limit changes
|
||||
loadLogs()
|
||||
}, [limit])
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api.getSystemLogs(limit)
|
||||
const logsList = data?.logs || []
|
||||
setAllLogs(logsList)
|
||||
|
||||
// Extract unique units for dropdown
|
||||
const uniqueUnits = new Set<string>()
|
||||
logsList.forEach((log: string) => {
|
||||
const match = log.match(/\s(\S+)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
||||
if (match) {
|
||||
const unitName = match[1] || match[3] || match[4] || ""
|
||||
if (unitName && unitName !== "kernel") {
|
||||
uniqueUnits.add(unitName)
|
||||
}
|
||||
}
|
||||
})
|
||||
setUnits(Array.from(uniqueUnits).sort())
|
||||
} catch (error) {
|
||||
console.error("Failed to load logs:", error)
|
||||
setAllLogs([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse log entry to extract metadata
|
||||
const parseLogEntry = (logText: string): LogEntry => {
|
||||
const entry: LogEntry = { text: logText }
|
||||
|
||||
// Try to parse date from log (format: "MMM DD HH:MM:SS")
|
||||
const dateMatch = logText.match(/^(\w+\s+\d+\s+\d{2}:\d{2}:\d{2})/)
|
||||
if (dateMatch) {
|
||||
try {
|
||||
const now = new Date()
|
||||
const dateStr = `${dateMatch[1]} ${now.getFullYear()}`
|
||||
const parsed = new Date(dateStr)
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
entry.date = parsed
|
||||
}
|
||||
} catch (e) {
|
||||
// Date parsing failed, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Extract unit/service name
|
||||
const unitMatch = logText.match(/\s(\S+?)\[(\d+)\]:|systemd(\[[\d.]+\])?:|(\S+):/)
|
||||
if (unitMatch) {
|
||||
entry.unit = unitMatch[1] || unitMatch[3] || unitMatch[4] || ""
|
||||
}
|
||||
|
||||
// Detect priority level
|
||||
if (logText.match(/ERROR|err|Err|ERR|error/i)) {
|
||||
entry.level = "err"
|
||||
} else if (logText.match(/WARN|warn|WARNING/i)) {
|
||||
entry.level = "warning"
|
||||
} else if (logText.match(/INFO|info|Notice|NOTICE/i)) {
|
||||
entry.level = "info"
|
||||
} else {
|
||||
entry.level = "debug"
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// Filter logs based on selected criteria
|
||||
const filteredLogs = useMemo(() => {
|
||||
return allLogs.filter((logText) => {
|
||||
const entry = parseLogEntry(logText)
|
||||
|
||||
// Time filter
|
||||
if (timeRange !== "all" && entry.date) {
|
||||
let cutoffDate = new Date()
|
||||
switch (timeRange) {
|
||||
case "24h":
|
||||
cutoffDate.setHours(cutoffDate.getHours() - 24)
|
||||
break
|
||||
case "7d":
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 7)
|
||||
break
|
||||
case "30d":
|
||||
cutoffDate.setDate(cutoffDate.getDate() - 30)
|
||||
break
|
||||
}
|
||||
if (entry.date < cutoffDate) return false
|
||||
}
|
||||
|
||||
// Priority filter
|
||||
if (priority === "err") {
|
||||
if (entry.level !== "err") return false
|
||||
}
|
||||
|
||||
// Unit filter
|
||||
if (unit && entry.unit) {
|
||||
if (!entry.unit.toLowerCase().includes(unit.toLowerCase())) return false
|
||||
}
|
||||
|
||||
// Text search filter
|
||||
if (searchText) {
|
||||
if (!logText.toLowerCase().includes(searchText.toLowerCase())) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [allLogs, timeRange, priority, unit, searchText])
|
||||
|
||||
const hasActiveFilters = timeRange !== "all" || priority !== "all" || unit || searchText
|
||||
|
||||
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="mb-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">System Logs</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Showing {filteredLogs.length} of {allLogs.length} entries
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
className="px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value={100}>Last 100</option>
|
||||
<option value={200}>Last 200</option>
|
||||
<option value={500}>Last 500</option>
|
||||
<option value={1000}>Last 1000</option>
|
||||
</select>
|
||||
<Button onClick={loadLogs} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Section */}
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Time Range Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Letzte</label>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(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="24h">24 Stunden</option>
|
||||
<option value="7d">7 Tage</option>
|
||||
<option value="30d">30 Tage</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Priorität</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(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="err">Fehler und höher</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Unit Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Kennung</label>
|
||||
<select
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md border border-border bg-background text-sm"
|
||||
>
|
||||
<option value="">Alle Services</option>
|
||||
{units.map((u) => (
|
||||
<option key={u} value={u}>
|
||||
{u}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Free Text Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Filter</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. priority:err"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* Active Filters Display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mt-4 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Filter className="w-4 h-4" /> Aktive Filter:
|
||||
</span>
|
||||
{timeRange !== "all" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-primary/10 text-primary text-xs">
|
||||
{timeRange === "24h"
|
||||
? "Letzte 24h"
|
||||
: timeRange === "7d"
|
||||
? "Letzte 7 Tage"
|
||||
: "Letzte 30 Tage"}
|
||||
<button
|
||||
onClick={() => setTimeRange("all")}
|
||||
className="hover:text-primary/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{priority === "err" && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-red-500/10 text-red-600 text-xs">
|
||||
Nur Fehler
|
||||
<button
|
||||
onClick={() => setPriority("all")}
|
||||
className="hover:text-red-600/70"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{unit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded bg-blue-500/10 text-blue-600 text-xs">
|
||||
{unit}
|
||||
<button onClick={() => setUnit("")} className="hover:text-blue-600/70">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Logs Display */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="bg-muted/30 rounded p-4 font-mono text-xs space-y-1 max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="text-muted-foreground text-center py-8">
|
||||
{loading ? "Loading logs..." : "No logs found matching filters"}
|
||||
</div>
|
||||
) : (
|
||||
filteredLogs.map((log: string, idx: number) => {
|
||||
const entry = parseLogEntry(log)
|
||||
const bgColor =
|
||||
entry.level === "err"
|
||||
? "bg-red-500/5 hover:bg-red-500/10"
|
||||
: entry.level === "warning"
|
||||
? "bg-yellow-500/5 hover:bg-yellow-500/10"
|
||||
: "hover:bg-muted/50"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`text-muted-foreground px-2 py-1 rounded transition-colors ${bgColor}`}
|
||||
>
|
||||
{log}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user