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:
sysops
2026-03-14 11:43:19 +01:00
parent a893084a88
commit d360c9a5ba
68 changed files with 11938 additions and 435 deletions
+352
View File
@@ -0,0 +1,352 @@
"use client";
import { use, useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
getMail,
downloadMailAttachment,
downloadMailRaw,
type MailDetail,
type MailAttachment,
} from "@/lib/api";
import { useAuth } from "@/hooks/useAuth";
import { Navbar } from "@/components/navbar";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
// ── Helpers ────────────────────────────────────────────────────────────────
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function blockExternalSrcs(html: string): string {
// Replace src= in img/video/audio tags with data-src= to block loading
return html
.replace(/<(img|video|audio|source)(\s[^>]*?\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3")
.replace(/<(img|video|audio|source)(\s)src(\s*=\s*["']https?:)/gi,
"<$1$2data-src$3");
}
// ── Sub-components ─────────────────────────────────────────────────────────
function MailHeaderGrid({ mail }: { mail: MailDetail }) {
const [showRaw, setShowRaw] = useState(false);
return (
<div className="space-y-3">
<div className="grid grid-cols-[6rem_1fr] gap-x-4 gap-y-1.5 text-sm">
<span className="font-medium text-muted-foreground">Von:</span>
<span className="break-all">{mail.from || ""}</span>
<span className="font-medium text-muted-foreground">An:</span>
<span className="break-all">{mail.to || ""}</span>
{mail.cc && (
<>
<span className="font-medium text-muted-foreground">CC:</span>
<span className="break-all">{mail.cc}</span>
</>
)}
<span className="font-medium text-muted-foreground">Datum:</span>
<span>{formatDate(mail.date)}</span>
<span className="font-medium text-muted-foreground">Betreff:</span>
<span className="font-semibold">{mail.subject || "(kein Betreff)"}</span>
<span className="font-medium text-muted-foreground">Größe:</span>
<span>{formatBytes(mail.size)}</span>
</div>
<button
onClick={() => setShowRaw((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2"
>
{showRaw ? "Header ausblenden" : "Original-Header anzeigen"}
</button>
{showRaw && (
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs leading-relaxed whitespace-pre-wrap break-all">
{mail.raw_headers}
</pre>
)}
</div>
);
}
function MailBodyView({ mail }: { mail: MailDetail }) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [showExternal, setShowExternal] = useState(false);
const html = mail.body_html ?? null;
const plain = mail.body_plain ?? null;
// Adjust iframe height to content
function handleIframeLoad() {
const iframe = iframeRef.current;
if (!iframe) return;
try {
const body = iframe.contentDocument?.body;
if (body) {
iframe.style.height = `${body.scrollHeight + 32}px`;
}
} catch {
iframe.style.height = "600px";
}
}
if (!html && !plain) {
return (
<div className="rounded-md border border-dashed p-8 text-center text-sm text-muted-foreground">
Kein Inhalt vorhanden.
</div>
);
}
if (html) {
const srcdoc = showExternal ? html : blockExternalSrcs(html);
return (
<div className="space-y-2">
{!showExternal && (
<Alert>
<AlertDescription className="flex items-center justify-between gap-4 text-sm">
<span>Externe Inhalte (Bilder, Tracker) sind blockiert.</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowExternal(true)}
>
Externe Inhalte laden
</Button>
</AlertDescription>
</Alert>
)}
<div className="overflow-hidden rounded-md border">
<iframe
ref={iframeRef}
srcDoc={srcdoc}
sandbox="allow-same-origin"
title="E-Mail-Inhalt"
className="w-full"
style={{ minHeight: "200px", height: "600px", border: "none" }}
onLoad={handleIframeLoad}
/>
</div>
</div>
);
}
// Plain-text fallback
return (
<pre className="max-h-[600px] overflow-auto rounded-md border bg-muted p-4 text-sm whitespace-pre-wrap break-words leading-relaxed">
{plain}
</pre>
);
}
function AttachmentRow({
mailId,
attachment,
}: {
mailId: string;
attachment: MailAttachment;
}) {
const [downloading, setDownloading] = useState(false);
async function handleDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailAttachment(
mailId,
attachment.index
);
triggerDownload(blob, filename || attachment.filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
return (
<div className="flex items-center justify-between gap-4 rounded-md border px-4 py-2.5 text-sm">
<div className="min-w-0 flex-1">
<span className="block truncate font-medium">{attachment.filename}</span>
<span className="text-xs text-muted-foreground">
{attachment.content_type} · {formatBytes(attachment.size)}
</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloading}
>
{downloading ? "..." : "Herunterladen"}
</Button>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function MailViewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const { user, loading: authLoading } = useAuth();
const [mail, setMail] = useState<MailDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (!user) return;
getMail(id)
.then(setMail)
.catch((e) =>
setError(e instanceof Error ? e.message : "Unbekannter Fehler")
)
.finally(() => setLoading(false));
}, [id, user]);
async function handleEmlDownload() {
setDownloading(true);
try {
const { blob, filename } = await downloadMailRaw(id);
triggerDownload(blob, filename);
} catch (e) {
alert(`Download fehlgeschlagen: ${e instanceof Error ? e.message : e}`);
} finally {
setDownloading(false);
}
}
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 space-y-4">
{/* Back + Actions */}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="outline" size="sm" asChild>
<Link href="/search"> Zurück zur Suche</Link>
</Button>
{mail && (
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{id}
</Badge>
<Button
variant="outline"
size="sm"
onClick={handleEmlDownload}
disabled={downloading}
>
{downloading ? "..." : "Als .eml herunterladen"}
</Button>
</div>
)}
</div>
{/* Loading */}
{loading && (
<Card>
<CardHeader className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</CardHeader>
<CardContent>
<Skeleton className="h-48 w-full" />
</CardContent>
</Card>
)}
{/* Error */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* Mail content */}
{mail && (
<>
{/* Header */}
<Card>
<CardHeader>
<MailHeaderGrid mail={mail} />
</CardHeader>
</Card>
{/* Body */}
<Card>
<CardContent className="pt-6">
<MailBodyView mail={mail} />
</CardContent>
</Card>
{/* Attachments */}
{mail.attachments && mail.attachments.length > 0 && (
<Card>
<CardHeader className="pb-3">
<span className="text-sm font-medium">
Anhänge ({mail.attachments.length})
</span>
</CardHeader>
<Separator />
<CardContent className="pt-4 space-y-2">
{mail.attachments.map((att) => (
<AttachmentRow
key={att.index}
mailId={id}
attachment={att}
/>
))}
</CardContent>
</Card>
)}
</>
)}
</main>
</div>
);
}