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:
co-authored by
Claude Sonnet 4.6
parent
a893084a88
commit
d360c9a5ba
@@ -0,0 +1,518 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Navbar } from "@/components/navbar";
|
||||
import {
|
||||
getImapAccounts,
|
||||
createImapAccount,
|
||||
deleteImapAccount,
|
||||
testImapConnection,
|
||||
startImapImport,
|
||||
getImapProgress,
|
||||
type ImapAccount,
|
||||
type ImapFolder,
|
||||
} from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function ImapPage() {
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [accounts, setAccounts] = useState<ImapAccount[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
|
||||
|
||||
// Form state
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formHost, setFormHost] = useState("");
|
||||
const [formPort, setFormPort] = useState("993");
|
||||
const [formTls, setFormTls] = useState("ssl");
|
||||
const [formUsername, setFormUsername] = useState("");
|
||||
const [formPassword, setFormPassword] = useState("");
|
||||
|
||||
// Test state
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testError, setTestError] = useState("");
|
||||
const [testFolders, setTestFolders] = useState<ImapFolder[] | null>(null);
|
||||
const [excludedFolders, setExcludedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
// Saving state
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Polling refs
|
||||
const pollingRefs = useRef<Map<number, ReturnType<typeof setInterval>>>(new Map());
|
||||
|
||||
const loadAccounts = useCallback(async () => {
|
||||
try {
|
||||
const data = await getImapAccounts();
|
||||
setAccounts(data);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) loadAccounts();
|
||||
}, [user, loadAccounts]);
|
||||
|
||||
// Start polling for running accounts
|
||||
useEffect(() => {
|
||||
for (const acc of accounts) {
|
||||
if (acc.status === "running" && !pollingRefs.current.has(acc.id)) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await getImapProgress(acc.id);
|
||||
setAccounts((prev) =>
|
||||
prev.map((a) => (a.id === updated.id ? updated : a))
|
||||
);
|
||||
if (updated.status !== "running") {
|
||||
clearInterval(pollingRefs.current.get(acc.id)!);
|
||||
pollingRefs.current.delete(acc.id);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(pollingRefs.current.get(acc.id)!);
|
||||
pollingRefs.current.delete(acc.id);
|
||||
}
|
||||
}, 2000);
|
||||
pollingRefs.current.set(acc.id, interval);
|
||||
}
|
||||
}
|
||||
// Cleanup intervals for accounts that are no longer running
|
||||
for (const [id, interval] of pollingRefs.current) {
|
||||
const acc = accounts.find((a) => a.id === id);
|
||||
if (!acc || acc.status !== "running") {
|
||||
clearInterval(interval);
|
||||
pollingRefs.current.delete(id);
|
||||
}
|
||||
}
|
||||
}, [accounts]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const interval of pollingRefs.current.values()) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
function resetForm() {
|
||||
setFormName("");
|
||||
setFormHost("");
|
||||
setFormPort("993");
|
||||
setFormTls("ssl");
|
||||
setFormUsername("");
|
||||
setFormPassword("");
|
||||
setTestFolders(null);
|
||||
setTestError("");
|
||||
setExcludedFolders(new Set());
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestError("");
|
||||
setTestFolders(null);
|
||||
try {
|
||||
const result = await testImapConnection({
|
||||
host: formHost,
|
||||
port: parseInt(formPort, 10) || 993,
|
||||
tls: formTls,
|
||||
username: formUsername,
|
||||
password: formPassword,
|
||||
});
|
||||
if (result.ok && result.folders) {
|
||||
setTestFolders(result.folders);
|
||||
const excluded = new Set<string>();
|
||||
for (const f of result.folders) {
|
||||
if (f.excluded) excluded.add(f.name);
|
||||
}
|
||||
setExcludedFolders(excluded);
|
||||
} else {
|
||||
setTestError(result.error || "Verbindungstest fehlgeschlagen");
|
||||
}
|
||||
} catch (err) {
|
||||
setTestError(err instanceof Error ? err.message : "Verbindungstest fehlgeschlagen");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await createImapAccount({
|
||||
name: formName,
|
||||
host: formHost,
|
||||
port: parseInt(formPort, 10) || 993,
|
||||
tls: formTls,
|
||||
username: formUsername,
|
||||
password: formPassword,
|
||||
excluded_folders: Array.from(excludedFolders),
|
||||
});
|
||||
setDialogOpen(false);
|
||||
resetForm();
|
||||
await loadAccounts();
|
||||
} catch (err) {
|
||||
setTestError(err instanceof Error ? err.message : "Speichern fehlgeschlagen");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStartImport(id: number) {
|
||||
try {
|
||||
const updated = await startImapImport(id);
|
||||
setAccounts((prev) => prev.map((a) => (a.id === updated.id ? updated : a)));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteImapAccount(id);
|
||||
setAccounts((prev) => prev.filter((a) => a.id !== id));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
|
||||
function toggleExcluded(folderName: string) {
|
||||
setExcludedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderName)) {
|
||||
next.delete(folderName);
|
||||
} else {
|
||||
next.add(folderName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return <Badge className="bg-blue-600 text-white">Importiert...</Badge>;
|
||||
case "error":
|
||||
return <Badge variant="destructive">Fehler</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">Bereit</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
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-4xl px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">IMAP Import</h1>
|
||||
<Button
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Konto hinzufuegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-32 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-muted-foreground">
|
||||
Noch keine IMAP-Konten konfiguriert. Klicken Sie auf "Konto
|
||||
hinzufuegen", um zu beginnen.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{accounts.map((acc) => (
|
||||
<Card key={acc.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{acc.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{acc.host}:{acc.port} ({acc.tls.toUpperCase()}) · {acc.username}
|
||||
</p>
|
||||
</div>
|
||||
{statusBadge(acc.status)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{acc.status === "running" && acc.progress_total > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<Progress
|
||||
value={
|
||||
(acc.progress_current / acc.progress_total) * 100
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{acc.progress_current} von {acc.progress_total} E-Mails
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{acc.status === "error" && acc.error_msg && (
|
||||
<p className="mb-3 text-sm text-destructive">
|
||||
{acc.error_msg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{acc.last_import_at && (
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Letzter Import:{" "}
|
||||
{new Date(acc.last_import_at).toLocaleString("de-DE")} (
|
||||
{acc.last_import_count} E-Mails)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{acc.excluded_folders && acc.excluded_folders.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Ausgeschlossene Ordner: {acc.excluded_folders.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={acc.status === "running"}
|
||||
onClick={() => handleStartImport(acc.id)}
|
||||
>
|
||||
Import starten
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={acc.status === "running"}
|
||||
onClick={() => setDeleteConfirm(acc.id)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Account Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>IMAP-Konto hinzufuegen</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imap-name">Name</Label>
|
||||
<Input
|
||||
id="imap-name"
|
||||
placeholder="z.B. Firmen-Mail"
|
||||
value={formName}
|
||||
onChange={(e) => setFormName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imap-host">Host</Label>
|
||||
<Input
|
||||
id="imap-host"
|
||||
placeholder="imap.example.com"
|
||||
value={formHost}
|
||||
onChange={(e) => setFormHost(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imap-port">Port</Label>
|
||||
<Input
|
||||
id="imap-port"
|
||||
type="number"
|
||||
value={formPort}
|
||||
onChange={(e) => setFormPort(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Verschluesselung</Label>
|
||||
<Select value={formTls} onValueChange={setFormTls}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ssl">SSL/TLS</SelectItem>
|
||||
<SelectItem value="starttls">STARTTLS</SelectItem>
|
||||
<SelectItem value="none">Unverschluesselt</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imap-user">Benutzername</Label>
|
||||
<Input
|
||||
id="imap-user"
|
||||
placeholder="user@example.com"
|
||||
value={formUsername}
|
||||
onChange={(e) => setFormUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="imap-pass">Passwort</Label>
|
||||
<Input
|
||||
id="imap-pass"
|
||||
type="password"
|
||||
value={formPassword}
|
||||
onChange={(e) => setFormPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !formHost || !formUsername || !formPassword}
|
||||
className="w-full"
|
||||
>
|
||||
{testing ? "Teste Verbindung..." : "Verbindung testen"}
|
||||
</Button>
|
||||
|
||||
{testError && (
|
||||
<p className="text-sm text-destructive">{testError}</p>
|
||||
)}
|
||||
|
||||
{testFolders && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">
|
||||
Erkannte Ordner
|
||||
</h4>
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{testFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.name}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={`folder-${folder.name}`}
|
||||
checked={!excludedFolders.has(folder.name)}
|
||||
onCheckedChange={() =>
|
||||
toggleExcluded(folder.name)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`folder-${folder.name}`}
|
||||
className="text-sm flex-1 cursor-pointer"
|
||||
>
|
||||
{folder.name}
|
||||
</Label>
|
||||
{folder.excluded && folder.reason && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({folder.reason === "special_use"
|
||||
? "IMAP-Flag"
|
||||
: "Namens-Erkennung"})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Deaktivierte Ordner werden nicht importiert.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDialogOpen(false);
|
||||
resetForm();
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
saving ||
|
||||
!formName ||
|
||||
!formHost ||
|
||||
!formUsername ||
|
||||
!formPassword
|
||||
}
|
||||
>
|
||||
{saving ? "Speichert..." : "Speichern"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteConfirm !== null}
|
||||
onOpenChange={() => setDeleteConfirm(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Konto loeschen?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Soll dieses IMAP-Konto wirklich entfernt werden? Bereits
|
||||
importierte E-Mails bleiben im Archiv erhalten.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteConfirm !== null && handleDelete(deleteConfirm)}
|
||||
>
|
||||
Loeschen
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user