import "server-only"; import { redis } from "@/lib/redis"; // === CrowdSec daily counters =============================================== // // Small Redis counters so ops can see whether the reputation pipeline is // actually doing anything: lookups executed, blocks created, signals pushed, // push failures, and per-block breakdowns (request category / CrowdSec // reputation) — all bucketed per UTC calendar day // (crowdsec:stat:{metric}:{YYYY-MM-DD}). Both the CTI client and the signal // pusher feed them; the admin panel renders the last N days. Cheap INCRs on // non-hot paths only, so they never tax the request path. export type CrowdsecStatMetric = | "lookups" | "blocks" | "reports" | "report_fail"; export type CrowdsecBreakdownKind = "category" | "reputation"; const STAT_PREFIX = "crowdsec:stat:"; const STAT_KEY_TTL_SECONDS = 16 * 24 * 3_600; function statDate(): string { return new Date().toISOString().slice(0, 10); } function statKey(metric: CrowdsecStatMetric, date: string): string { return `${STAT_PREFIX}${metric}:${date}`; } function breakdownKey(kind: CrowdsecBreakdownKind, date: string): string { return `${STAT_PREFIX}${kind}:${date}`; } /** Count one occurrence of a pipeline event for today. Best effort. */ export async function bumpCrowdsecStat( metric: CrowdsecStatMetric, ): Promise { if (!redis) return; const key = statKey(metric, statDate()); try { await redis.incr(key); await redis.expire(key, STAT_KEY_TTL_SECONDS); } catch { // tracking is best-effort — a miss only loses a day's histogram } } /** * Count one block into today's per-category / per-reputation breakdown. Stored * as a JSON object per day and merged by the admin reader; read-modify-write * is fine because blocks are rare and the panel is display-only. */ export async function bumpCrowdsecBreakdownStat( kind: CrowdsecBreakdownKind, value: string, ): Promise { if (!redis) return; const key = breakdownKey(kind, statDate()); try { const raw = await redis.get(key); const counts: Record = raw ? (JSON.parse(raw) as Record) : {}; const label = String(value).slice(0, 64); counts[label] = (counts[label] ?? 0) + 1; await redis.set(key, JSON.stringify(counts), "EX", STAT_KEY_TTL_SECONDS); } catch { // best effort — a lost breakdown entry only hides a histogram bucket } } export interface CrowdsecDailyStat { /** UTC calendar day (YYYY-MM-DD). */ date: string; lookups: number; blocks: number; reports: number; reportFailures: number; /** Blocks per request category (api/pages/auth/global), today-to-date. */ categories: Record; /** Blocks per CrowdSec reputation (only community-sourced ones). */ reputations: Record; } function blankRow(date: string): CrowdsecDailyStat { return { date, lookups: 0, blocks: 0, reports: 0, reportFailures: 0, categories: {}, reputations: {}, }; } function toCount(raw: string | null): number { const n = Number(raw ?? 0); return Number.isFinite(n) ? n : 0; } function toMap(raw: string | null): Record { if (!raw) return {}; try { const parsed = JSON.parse(raw) as unknown; if (parsed && typeof parsed === "object") { return Object.fromEntries( Object.entries(parsed as Record) .map(([k, v]) => [k, typeof v === "number" ? v : Number(v) || 0]) .filter(([, v]) => Number.isFinite(v)), ); } } catch { // corrupt counter — treat as empty } return {}; } /** * Read the per-day counters for the last `days` days (oldest first, ending * with today). Every key is fetched in one parallel burst (6 GETs per day), * then assembled client-side; never throws. */ export async function getCrowdsecStats( days = 14, ): Promise { const dates: string[] = []; for (let i = days - 1; i >= 0; i -= 1) { dates.push( new Date(Date.now() - i * 86_400_000).toISOString().slice(0, 10), ); } if (!redis) { // No shared store — still return a blank timeline for the UI. return dates.map(blankRow); } const store = redis; return Promise.all( dates.map(async (date) => { const [ lookups, blocks, reports, reportFailures, categories, reputations, ] = await Promise.all([ store.get(statKey("lookups", date)).catch(() => null), store.get(statKey("blocks", date)).catch(() => null), store.get(statKey("reports", date)).catch(() => null), store.get(statKey("report_fail", date)).catch(() => null), store.get(breakdownKey("category", date)).catch(() => null), store.get(breakdownKey("reputation", date)).catch(() => null), ]); return { date, lookups: toCount(lookups), blocks: toCount(blocks), reports: toCount(reports), reportFailures: toCount(reportFailures), categories: toMap(categories), reputations: toMap(reputations), }; }), ); }