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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user