feat(PROJ-40,PROJ-41): Prometheus Metriken + Dashboard Zeitreihe

- PROJ-40: /api/health mit Version+Uptime, /metrics Prometheus-Format
  (mails_last_60min/24h/7d/30d, mails_total, storage_bytes, tenants_total,
   users_total, uptime_seconds) — Token-Schutz optional konfigurierbar
- PROJ-41: GET /api/admin/stats/timeseries (30-Tage tagesgenau, Tenant-scoped)
  + SVG-Balkendiagramm im Dashboard (Mail-Eingang letzte 30 Tage)
- storage.DBQueryRow() Helper für Metrics-Queries ohne Pool-Exposition
- config.MetricsConfig (enabled, token) in config.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-04-05 21:10:42 +02:00
parent 4f366a3634
commit 9298216ce0
11 changed files with 302 additions and 14 deletions
+7 -1
View File
@@ -17,6 +17,7 @@ import {
getServices,
serviceAction,
getSystemStats,
getMailTimeseries,
uploadMailFiles,
getUploadProgress,
getSecurityAudit,
@@ -39,6 +40,7 @@ import {
type StorageStats,
type ServiceStatus,
type SystemStats,
type TimeseriesPoint,
type UploadJob,
type SecurityAuditResult,
type Tenant,
@@ -82,6 +84,7 @@ export default function AdminPage() {
const [smtpStatus, setSmtpStatus] = useState<SMTPStatus | null>(null);
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
const [systemStats, setSystemStats] = useState<SystemStats | null>(null);
const [timeseries, setTimeseries] = useState<TimeseriesPoint[]>([]);
const [apiOnline, setApiOnline] = useState<boolean | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [dashRefreshed, setDashRefreshed] = useState<Date | null>(null);
@@ -248,16 +251,18 @@ export default function AdminPage() {
const loadDashboard = useCallback(async () => {
setDashLoading(true);
try {
const [smtp, health, storage, sysStats] = await Promise.allSettled([
const [smtp, health, storage, sysStats, ts] = await Promise.allSettled([
getSMTPStatus(),
getHealth(),
getStorageStats(),
getSystemStats(),
getMailTimeseries(30),
]);
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);
setTimeseries(ts.status === "fulfilled" ? ts.value.points : []);
setDashRefreshed(new Date());
} finally {
setDashLoading(false);
@@ -718,6 +723,7 @@ export default function AdminPage() {
smtpStatus={smtpStatus}
storageStats={storageStats}
systemStats={systemStats}
timeseries={timeseries}
apiOnline={apiOnline}
dashLoading={dashLoading}
dashRefreshed={dashRefreshed}
+57 -2
View File
@@ -6,6 +6,7 @@ import {
type SystemStats,
type SystemStatsActivity,
type SystemStatsEstimate,
type TimeseriesPoint,
} from "@/lib/api";
import { type User } from "@/lib/api";
import { Button } from "@/components/ui/button";
@@ -38,6 +39,55 @@ function formatDaysUntilFull(days: number): string {
return `${(days / 365).toFixed(1)} Jahre`;
}
function MailTimeseriesChart({ points }: { points: TimeseriesPoint[] }) {
if (!points || points.length === 0) return null;
const maxCount = Math.max(...points.map((p) => p.count), 1);
const BAR_H = 80; // px height of chart area
return (
<Card>
<CardContent className="pt-6 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Mail-Eingang (letzte 30 Tage)</span>
<span className="text-xs text-muted-foreground font-mono">max {maxCount.toLocaleString("de-DE")}/Tag</span>
</div>
<Separator />
<div className="flex items-end gap-px overflow-hidden" style={{ height: BAR_H }}>
{points.map((p) => {
const heightPct = maxCount > 0 ? (p.count / maxCount) * 100 : 0;
const date = new Date(p.day);
const label = date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" });
return (
<div
key={p.day}
className="flex-1 group relative flex flex-col justify-end"
style={{ height: BAR_H }}
title={`${label}: ${p.count.toLocaleString("de-DE")} Mails`}
>
<div
className="w-full rounded-t bg-primary/70 group-hover:bg-primary transition-colors"
style={{ height: `${Math.max(heightPct, p.count > 0 ? 2 : 0)}%` }}
/>
{/* tooltip */}
<div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-popover text-popover-foreground border rounded px-2 py-1 text-xs whitespace-nowrap shadow">
{label}: {p.count.toLocaleString("de-DE")}
</div>
</div>
</div>
);
})}
</div>
<div className="flex justify-between text-xs text-muted-foreground font-mono">
<span>{points[0]?.day.slice(5)}</span>
<span>{points[Math.floor(points.length / 2)]?.day.slice(5)}</span>
<span>{points[points.length - 1]?.day.slice(5)}</span>
</div>
</CardContent>
</Card>
);
}
function ActivityCard({ activity }: { activity: SystemStatsActivity }) {
return (
<Card>
@@ -94,6 +144,7 @@ interface DashboardTabProps {
smtpStatus: SMTPStatus | null;
storageStats: StorageStats | null;
systemStats: SystemStats | null;
timeseries: TimeseriesPoint[];
apiOnline: boolean | null;
dashLoading: boolean;
dashRefreshed: Date | null;
@@ -108,6 +159,7 @@ export function DashboardTab({
smtpStatus,
storageStats,
systemStats,
timeseries,
apiOnline,
dashLoading,
dashRefreshed,
@@ -368,10 +420,13 @@ export function DashboardTab({
</Card>
</div>
{/* Mail-Aktivität + Speicherprognose */}
{(systemStats.activity || systemStats.estimate) && (
{/* Mail-Aktivität + Speicherprognose + Zeitreihe */}
{(systemStats.activity || systemStats.estimate || timeseries.length > 0) && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Archiv-Statistik</h3>
{timeseries.length > 0 && (
<MailTimeseriesChart points={timeseries} />
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{systemStats.activity && <ActivityCard activity={systemStats.activity} />}
{systemStats.estimate && <EstimateCard estimate={systemStats.estimate} />}
+2
View File
@@ -133,6 +133,7 @@ export type {
SystemStatsActivity,
SystemStatsEstimate,
SystemStats,
TimeseriesPoint,
SecurityCheck,
SecurityAuditResult,
CertInfo,
@@ -144,6 +145,7 @@ export {
getSMTPStatus,
getStorageStats,
getSystemStats,
getMailTimeseries,
getServices,
serviceAction,
getAuditLog,
+9
View File
@@ -110,6 +110,11 @@ export interface SystemStats {
estimate: SystemStatsEstimate;
}
export interface TimeseriesPoint {
day: string; // "2026-04-05"
count: number;
}
export interface SecurityCheck {
name: string;
status: "ok" | "warning" | "error";
@@ -164,6 +169,10 @@ export async function getSystemStats(): Promise<SystemStats> {
return request<SystemStats>("/api/admin/system/stats");
}
export async function getMailTimeseries(days = 30): Promise<{ days: number; points: TimeseriesPoint[] }> {
return request<{ days: number; points: TimeseriesPoint[] }>(`/api/admin/stats/timeseries?days=${days}`);
}
// ── Services ──────────────────────────────────────────────────────────────────
export async function getServices(): Promise<ServiceStatus[]> {