feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,946 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
getAuditLog,
|
||||
getSMTPStatus,
|
||||
getHealth,
|
||||
getStorageStats,
|
||||
getServices,
|
||||
serviceAction,
|
||||
getSystemStats,
|
||||
type User,
|
||||
type AuditEntry,
|
||||
type SMTPStatus,
|
||||
type StorageStats,
|
||||
type ServiceStatus,
|
||||
type SystemStats,
|
||||
} from "@/lib/api";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
const AUDIT_PAGE_SIZE = 25;
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, loading: authLoading } = useAuth(true);
|
||||
|
||||
// Dashboard state
|
||||
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
|
||||
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
|
||||
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
|
||||
const [dashLoading, setDashLoading] = useState(true);
|
||||
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
// Services state
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [servicesLoading, setServicesLoading] = useState(false);
|
||||
const [serviceActionLoading, setServiceActionLoading] = useState<string | null>(null);
|
||||
const [serviceError, setServiceError] = useState("");
|
||||
|
||||
// Users state
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [usersLoading, setUsersLoading] = useState(true);
|
||||
const [usersError, setUsersError] = useState("");
|
||||
|
||||
// Create user dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
||||
// Audit state
|
||||
const [auditEntries, setAuditEntries] = useState<AuditEntry[]>([]);
|
||||
const [auditTotal, setAuditTotal] = useState(0);
|
||||
const [auditPage, setAuditPage] = useState(1);
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
setDashLoading(true);
|
||||
try {
|
||||
const [smtp, health, storage, sysStats] = await Promise.allSettled([
|
||||
getSMTPStatus(),
|
||||
getHealth(),
|
||||
getStorageStats(),
|
||||
getSystemStats(),
|
||||
]);
|
||||
setSmtpStatus(smtp.status === "fulfilled" ? smtp.value : null);
|
||||
setApiOnline(health.status === "fulfilled" && health.value.status === "ok");
|
||||
setStorageStats(storage.status === "fulfilled" ? storage.value : null);
|
||||
setSystemStats(sysStats.status === "fulfilled" ? sysStats.value : null);
|
||||
setDashRefreshed(new Date());
|
||||
} finally {
|
||||
setDashLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = useCallback(async () => {
|
||||
setUsersLoading(true);
|
||||
setUsersError("");
|
||||
try {
|
||||
const data = await getUsers();
|
||||
setUsers(data || []);
|
||||
} catch {
|
||||
setUsersError("Benutzer konnten nicht geladen werden.");
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAudit = useCallback(async (p: number) => {
|
||||
setAuditLoading(true);
|
||||
try {
|
||||
const data = await getAuditLog({ page: p, page_size: AUDIT_PAGE_SIZE });
|
||||
setAuditEntries(data.entries || []);
|
||||
setAuditTotal(data.total);
|
||||
setAuditPage(p);
|
||||
} catch {
|
||||
setAuditEntries([]);
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadServices = useCallback(async () => {
|
||||
setServicesLoading(true);
|
||||
setServiceError("");
|
||||
try {
|
||||
const data = await getServices();
|
||||
setServices(data || []);
|
||||
} catch {
|
||||
setServiceError("Dienste konnten nicht abgerufen werden.");
|
||||
} finally {
|
||||
setServicesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handleServiceAction(name: string, action: "start" | "stop" | "restart" | "enable" | "disable" | "block_external" | "allow_external") {
|
||||
setServiceActionLoading(`${name}:${action}`);
|
||||
setServiceError("");
|
||||
try {
|
||||
const updated = await serviceAction(name, action);
|
||||
setServices((prev) => prev.map((s) => (s.name === updated.name ? updated : s)));
|
||||
} catch (e: unknown) {
|
||||
setServiceError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
|
||||
} finally {
|
||||
setServiceActionLoading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const dashIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
loadDashboard();
|
||||
loadUsers();
|
||||
loadAudit(1);
|
||||
loadServices();
|
||||
|
||||
// Auto-Refresh Dashboard alle 30 Sekunden
|
||||
setCountdown(30);
|
||||
dashIntervalRef.current = setInterval(() => {
|
||||
loadDashboard();
|
||||
setCountdown(30);
|
||||
}, 30_000);
|
||||
|
||||
// Countdown-Ticker
|
||||
const ticker = setInterval(() => {
|
||||
setCountdown((c) => (c > 0 ? c - 1 : 0));
|
||||
}, 1_000);
|
||||
|
||||
return () => {
|
||||
if (dashIntervalRef.current) clearInterval(dashIntervalRef.current);
|
||||
clearInterval(ticker);
|
||||
};
|
||||
}, [user, loadDashboard, loadUsers, loadAudit, loadServices]);
|
||||
|
||||
async function handleCreateUser(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreateLoading(true);
|
||||
setCreateError("");
|
||||
try {
|
||||
await createUser({
|
||||
username: newUsername,
|
||||
email: newEmail,
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
});
|
||||
setDialogOpen(false);
|
||||
setNewUsername("");
|
||||
setNewEmail("");
|
||||
setNewPassword("");
|
||||
setNewRole("user");
|
||||
loadUsers();
|
||||
} catch {
|
||||
setCreateError("Benutzer konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE);
|
||||
|
||||
if (authLoading || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar username={user.username} role={user.role} />
|
||||
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||
<h1 className="mb-6 text-2xl font-bold">Administration</h1>
|
||||
|
||||
<Tabs defaultValue="dashboard">
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">Dashboard</TabsTrigger>
|
||||
<TabsTrigger value="services">Dienste</TabsTrigger>
|
||||
<TabsTrigger value="users">Benutzer</TabsTrigger>
|
||||
<TabsTrigger value="audit">Audit-Log</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Dashboard ── */}
|
||||
<TabsContent value="dashboard" className="mt-4 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Systemstatus</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
{dashRefreshed && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{dashRefreshed.toLocaleTimeString("de-DE")} · nächste Aktualisierung in {countdown}s
|
||||
</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => { loadDashboard(); setCountdown(30); }} disabled={dashLoading}>
|
||||
{dashLoading ? "..." : "Jetzt aktualisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dashLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-36 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Status-Kacheln */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
{/* API */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">REST API</span>
|
||||
<Badge variant={apiOnline ? "default" : "destructive"}>
|
||||
{apiOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Adresse</span>
|
||||
<span className="font-mono">:8080</span>
|
||||
<span className="text-muted-foreground">Protokoll</span>
|
||||
<span>HTTP</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP-Daemon</span>
|
||||
<Badge variant={smtpStatus?.running ? "default" : "destructive"}>
|
||||
{smtpStatus?.running ? "Aktiv" : smtpStatus?.enabled === false ? "Deaktiviert" : "Gestoppt"}
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
{smtpStatus ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Adresse</span>
|
||||
<span className="font-mono">{smtpStatus.bind}</span>
|
||||
<span className="text-muted-foreground">Domain</span>
|
||||
<span className="font-mono">{smtpStatus.domain || "–"}</span>
|
||||
<span className="text-muted-foreground">TLS</span>
|
||||
<span>{smtpStatus.tls ? "Ja" : "Nein"}</span>
|
||||
<span className="text-muted-foreground">Max. Größe</span>
|
||||
<span>{smtpStatus.max_size_mb > 0 ? `${smtpStatus.max_size_mb} MB` : "50 MB"}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Nicht erreichbar</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SMTP Statistik (nur live via SMTP-Daemon) */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP Statistik</span>
|
||||
<span className="text-xs text-muted-foreground">seit letztem Start</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{smtpStatus ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Empfangen</span>
|
||||
<span className="font-semibold text-green-600">{smtpStatus.received}</span>
|
||||
<span className="text-muted-foreground">Abgelehnt</span>
|
||||
<span className="font-semibold text-red-500">{smtpStatus.rejected}</span>
|
||||
<span className="text-muted-foreground">Letzte Mail</span>
|
||||
<span className="text-xs">
|
||||
{smtpStatus.last_mail_at
|
||||
? new Date(smtpStatus.last_mail_at).toLocaleString("de-DE")
|
||||
: "–"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Keine Daten</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archiv-Speicher */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Archiv gesamt</span>
|
||||
{storageStats && (
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{storageStats.total_mails} Mails
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
{storageStats ? (
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">E-Mails</span>
|
||||
<span className="font-semibold">{storageStats.total_mails.toLocaleString("de-DE")}</span>
|
||||
<span className="text-muted-foreground">Speicher</span>
|
||||
<span>{formatBytes(storageStats.total_bytes)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Keine Daten</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* System Stats: CPU, RAM, Disks, Archivzeitraum */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Systemauslastung</h3>
|
||||
{!systemStats ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Systemdaten konnten nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft und der Endpunkt <code className="font-mono">/api/admin/system/stats</code> erreichbar ist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
{/* CPU */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">CPU Load Average</span>
|
||||
<Badge variant="secondary">{systemStats.cpu.num_cpu} CPU(s)</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">1 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load1.toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">5 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load5.toFixed(2)}</span>
|
||||
<span className="text-muted-foreground">15 min</span>
|
||||
<span className="font-semibold">{systemStats.cpu.load15.toFixed(2)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* RAM */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Arbeitsspeicher</span>
|
||||
<Badge variant={systemStats.ram.used_pct > 90 ? "destructive" : systemStats.ram.used_pct > 70 ? "secondary" : "default"}>
|
||||
{systemStats.ram.used_pct.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="h-2 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-2 rounded-full ${systemStats.ram.used_pct > 90 ? "bg-destructive" : systemStats.ram.used_pct > 70 ? "bg-yellow-500" : "bg-primary"}`}
|
||||
style={{ width: `${Math.min(systemStats.ram.used_pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Belegt</span>
|
||||
<span>{formatBytes(systemStats.ram.used_bytes)}</span>
|
||||
<span className="text-muted-foreground">Gesamt</span>
|
||||
<span>{formatBytes(systemStats.ram.total_bytes)}</span>
|
||||
<span className="text-muted-foreground">Frei</span>
|
||||
<span>{formatBytes(systemStats.ram.free_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Archivzeitraum */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">Archivzeitraum</span>
|
||||
</div>
|
||||
<Separator />
|
||||
{systemStats.archive.first_mail || systemStats.archive.last_mail ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
{systemStats.archive.first_mail && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground block">Älteste Mail</span>
|
||||
<span className="font-semibold">{new Date(systemStats.archive.first_mail.date).toLocaleDateString("de-DE")}</span>
|
||||
<span className="block text-muted-foreground truncate">{systemStats.archive.first_mail.from || "–"}</span>
|
||||
<span className="block text-xs truncate">{systemStats.archive.first_mail.subject || "(kein Betreff)"}</span>
|
||||
</div>
|
||||
)}
|
||||
{systemStats.archive.last_mail && (
|
||||
<div className="pt-1 border-t">
|
||||
<span className="text-xs text-muted-foreground block">Neueste Mail</span>
|
||||
<span className="font-semibold">{new Date(systemStats.archive.last_mail.date).toLocaleDateString("de-DE")}</span>
|
||||
<span className="block text-muted-foreground truncate">{systemStats.archive.last_mail.from || "–"}</span>
|
||||
<span className="block text-xs truncate">{systemStats.archive.last_mail.subject || "(kein Betreff)"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Archiv leer</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Festplatten */}
|
||||
{systemStats.disks.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Festplatten</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{systemStats.disks.map((disk) => (
|
||||
<Card key={disk.mount}>
|
||||
<CardContent className="pt-6 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium font-mono">{disk.mount}</span>
|
||||
<Badge variant={disk.used_pct > 90 ? "destructive" : disk.used_pct > 75 ? "secondary" : "outline"}>
|
||||
{disk.used_pct.toFixed(1)}%
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-secondary">
|
||||
<div
|
||||
className={`h-2 rounded-full ${disk.used_pct > 90 ? "bg-destructive" : disk.used_pct > 75 ? "bg-yellow-500" : "bg-primary"}`}
|
||||
style={{ width: `${Math.min(disk.used_pct, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1 text-sm">
|
||||
<span className="text-muted-foreground">Belegt</span>
|
||||
<span>{formatBytes(disk.used_bytes)}</span>
|
||||
<span className="text-muted-foreground">Gesamt</span>
|
||||
<span>{formatBytes(disk.total_bytes)}</span>
|
||||
<span className="text-muted-foreground">Frei</span>
|
||||
<span>{formatBytes(disk.free_bytes)}</span>
|
||||
<span className="text-muted-foreground">Dateisystem</span>
|
||||
<span className="font-mono text-xs">{disk.fstype}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* IP-Allowlist */}
|
||||
{smtpStatus && smtpStatus.allowed_ips?.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">SMTP IP-Allowlist</span>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{smtpStatus.allowed_ips.map((ip) => (
|
||||
<Badge key={ip} variant="outline" className="font-mono">
|
||||
{ip}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Benutzerübersicht */}
|
||||
<Card>
|
||||
<CardContent className="pt-6 space-y-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">Benutzer</span>
|
||||
<Separator />
|
||||
{usersLoading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4 pt-1 text-sm">
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.active).length}</span>
|
||||
<span className="text-muted-foreground ml-1">aktiv</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "admin").length}</span>
|
||||
<span className="text-muted-foreground ml-1">Admin</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "auditor").length}</span>
|
||||
<span className="text-muted-foreground ml-1">Auditor</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold">{users.filter(u => u.role === "user").length}</span>
|
||||
<span className="text-muted-foreground ml-1">User</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{!smtpStatus && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
SMTP-Status konnte nicht abgerufen werden. Prüfe ob der Backend-Dienst läuft.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Dienste ── */}
|
||||
<TabsContent value="services" className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Systemdienste</h2>
|
||||
<Button variant="outline" size="sm" onClick={loadServices} disabled={servicesLoading}>
|
||||
{servicesLoading ? "..." : "Aktualisieren"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{serviceError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{serviceError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{servicesLoading && services.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-44">Dienst</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-24">Autostart</TableHead>
|
||||
<TableHead className="w-28">Externer Zugriff</TableHead>
|
||||
<TableHead>Beschreibung</TableHead>
|
||||
<TableHead className="w-72 text-right">Aktionen</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.map((svc) => {
|
||||
const isActive = svc.active === "active";
|
||||
const isFailed = svc.active === "failed";
|
||||
const isEnabled = svc.enabled === "enabled" || svc.enabled === "static";
|
||||
const busy = (key: string) => serviceActionLoading === `${svc.name}:${key}`;
|
||||
const anyBusy = serviceActionLoading?.startsWith(`${svc.name}:`) ?? false;
|
||||
return (
|
||||
<TableRow key={svc.name}>
|
||||
<TableCell className="font-mono text-sm font-medium">
|
||||
{svc.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={isActive ? "default" : isFailed ? "destructive" : "secondary"}
|
||||
>
|
||||
{svc.active === "active"
|
||||
? `Aktiv (${svc.sub})`
|
||||
: svc.active === "failed"
|
||||
? "Fehler"
|
||||
: svc.active === "inactive"
|
||||
? "Gestoppt"
|
||||
: svc.active}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={isEnabled ? "default" : "outline"}>
|
||||
{svc.enabled === "enabled"
|
||||
? "Aktiviert"
|
||||
: svc.enabled === "disabled"
|
||||
? "Deaktiviert"
|
||||
: svc.enabled === "static"
|
||||
? "Statisch"
|
||||
: svc.enabled}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{svc.external_blocked !== undefined ? (
|
||||
<Badge variant={svc.external_blocked ? "destructive" : "default"}>
|
||||
{svc.external_blocked ? "Gesperrt" : "Offen"}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">–</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground truncate max-w-xs">
|
||||
{svc.description || "–"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1 flex-wrap">
|
||||
{isActive ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "restart")}
|
||||
>
|
||||
{busy("restart") ? "..." : "Neustart"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "stop")}
|
||||
>
|
||||
{busy("stop") ? "..." : "Stop"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "start")}
|
||||
>
|
||||
{busy("start") ? "..." : "Start"}
|
||||
</Button>
|
||||
)}
|
||||
{svc.enabled === "enabled" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "disable")}
|
||||
>
|
||||
{busy("disable") ? "..." : "Deaktivieren"}
|
||||
</Button>
|
||||
) : svc.enabled === "disabled" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "enable")}
|
||||
>
|
||||
{busy("enable") ? "..." : "Aktivieren"}
|
||||
</Button>
|
||||
) : null}
|
||||
{svc.external_blocked !== undefined && (
|
||||
svc.external_blocked ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "allow_external")}
|
||||
>
|
||||
{busy("allow_external") ? "..." : "Extern freigeben"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={anyBusy}
|
||||
onClick={() => handleServiceAction(svc.name, "block_external")}
|
||||
>
|
||||
{busy("block_external") ? "..." : "Extern sperren"}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users" className="mt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Benutzerverwaltung</h2>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Benutzer anlegen</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Erstellen Sie einen neuen Benutzer-Account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-username">Benutzername</Label>
|
||||
<Input
|
||||
id="new-username"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
required
|
||||
aria-label="Neuer Benutzername"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-email">E-Mail</Label>
|
||||
<Input
|
||||
id="new-email"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
required
|
||||
aria-label="Neue E-Mail-Adresse"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">Passwort</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
aria-label="Neues Passwort"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-role">Rolle</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger id="new-role" aria-label="Rolle auswaehlen">
|
||||
<SelectValue placeholder="Rolle waehlen" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">User</SelectItem>
|
||||
<SelectItem value="auditor">Auditor</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{createError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createLoading}>
|
||||
{createLoading ? "Erstellen..." : "Erstellen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{usersLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : usersError ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-destructive">
|
||||
{usersError}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : users.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Keine Benutzer vorhanden.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Benutzername</TableHead>
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Rolle</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.username}>
|
||||
<TableCell className="font-medium">
|
||||
{u.username}
|
||||
</TableCell>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{u.role}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={u.active ? "default" : "destructive"}
|
||||
>
|
||||
{u.active ? "Aktiv" : "Inaktiv"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="mt-4">
|
||||
<h2 className="mb-4 text-lg font-semibold">Audit-Log</h2>
|
||||
|
||||
{auditLoading ? (
|
||||
<Card>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : auditEntries.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Keine Audit-Eintraege vorhanden.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zeitstempel</TableHead>
|
||||
<TableHead>Ereignis</TableHead>
|
||||
<TableHead>Benutzer</TableHead>
|
||||
<TableHead>Details</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{auditEntries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{new Date(entry.timestamp).toLocaleString("de-DE")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{entry.event_type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{entry.username}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{entry.detail}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{auditTotalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={auditPage <= 1}
|
||||
onClick={() => loadAudit(auditPage - 1)}
|
||||
>
|
||||
Zurueck
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Seite {auditPage} von {auditTotalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={auditPage >= auditTotalPages}
|
||||
onClick={() => loadAudit(auditPage + 1)}
|
||||
>
|
||||
Weiter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user