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:
co-authored by
Claude Sonnet 4.6
parent
4f366a3634
commit
9298216ce0
@@ -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} />}
|
||||
|
||||
Reference in New Issue
Block a user