feat(security): recovery alerts, gate-block sharing, rolling-window burst and admin breakdown for CrowdSec
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m50s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m3s

This commit is contained in:
openhands committed 2026-09-23 15:06:16 +02:00
1 parent 301edd2c9a
commit 3e1a3f92c8
8 files changed
+448 -65

No files matched your search

+71 -5
View File
@@ -45,7 +45,7 @@ import {
crowdsecReportEnabled,
getLastCrowdsecReport,
} from "@/lib/crowdsec-report";
import { getCrowdsecStats } from "@/lib/crowdsec-stats";
import { type CrowdsecDailyStat, getCrowdsecStats } from "@/lib/crowdsec-stats";
import { db, WebsiteSetting } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redis } from "@/lib/redis";
@@ -58,6 +58,40 @@ function seconds(ttlMs: number): string {
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
}
function BarSparkline({ values }: { values: number[] }) {
if (values.length === 0) return null;
const max = Math.max(...values, 1);
return (
<div className="flex items-end gap-[3px] h-10" aria-hidden="true">
{values.map((v, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: static timeline position is the bar's identity
key={i}
className="w-full rounded-sm bg-primary/60"
style={{
height: `${Math.max(v > 0 ? 6 : 2, (v / max) * 100)}%`,
opacity: v === 0 ? 0.15 : 0.6 + (v / max) * 0.4,
}}
/>
))}
</div>
);
}
/** Merge per-day breakdown maps (categories / reputations) into range totals. */
function mergeBreakdowns(
rows: CrowdsecDailyStat[],
kind: keyof Pick<CrowdsecDailyStat, "categories" | "reputations">,
): Record<string, number> {
const totals: Record<string, number> = {};
for (const row of rows) {
for (const [k, v] of Object.entries(row[kind])) {
totals[k] = (totals[k] ?? 0) + v;
}
}
return totals;
}
export default async function AdminAntiDdosPage() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.SETTINGS_VIEW, session.user.rank)) {
@@ -727,10 +761,42 @@ export default async function AdminAntiDdosPage() {
{crowdsecStats.length > 0 && (
<div className="rounded-md border p-3">
<p className="text-xs font-medium mb-2">
Daily activity (last {crowdsecStats.length} days)
</p>
<div className="max-h-40 overflow-y-auto">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-medium">
Daily activity (last {crowdsecStats.length} days)
</p>
<a
href="/admin/alerts"
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Ops alert history →
</a>
</div>
<div className="mt-2 grid gap-4 sm:grid-cols-3">
<BarSparkline values={crowdsecStats.map((row) => row.blocks)} />
<div className="col-span-2 flex flex-wrap items-center gap-1.5">
{Object.entries(
mergeBreakdowns(crowdsecStats, "categories"),
).map(([category, count]) => (
<Badge key={category} variant="secondary">
{category} · {count.toLocaleString()}
</Badge>
))}
{Object.entries(
mergeBreakdowns(crowdsecStats, "reputations"),
).map(([reputation, count]) => (
<Badge
key={reputation}
variant={
reputation === "malicious" ? "destructive" : "secondary"
}
>
{reputation} · {count.toLocaleString()}
</Badge>
))}
</div>
</div>
<div className="mt-3 max-h-40 overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground">