Files
zmb-webui/frontend/app/logs/page.tsx
T
Claude Code 6d74d874b6 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>
2026-04-22 00:43:05 +02:00

341 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 { 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">
&quot;{searchText}&quot;
<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>
)
}