Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 177 additions & 0 deletions backend/src/api/routes/alertRoutingAdmin.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import type { FastifyInstance } from "fastify";
import { authMiddleware } from "../middleware/auth.js";
import { alertRoutingService } from "../../services/alertRouting.service.js";
import type { RoutingSeverity } from "../../services/alertRouting.service.js";
import {
createAlertRoutingRuleSchema,
listAlertRoutingAuditQuerySchema,
listAlertRoutingRulesQuerySchema,
updateAlertRoutingRuleSchema,
} from "../validations/alertRouting.schema.js";

const VALID_SEVERITIES = new Set<string>(["critical", "high", "medium", "low"]);

function parseSeverity(value: string): RoutingSeverity {
if (VALID_SEVERITIES.has(value)) return value as RoutingSeverity;
return "medium";
}

export async function alertRoutingAdminRoutes(server: FastifyInstance) {
const requireAdmin = authMiddleware({ requiredScopes: ["admin:api-keys"] });

Expand Down Expand Up @@ -205,4 +213,173 @@ export async function alertRoutingAdminRoutes(server: FastifyInstance) {
return { entries };
}
);

server.post(
"/simulate",
{
preHandler: requireAdmin,
schema: {
tags: ["Config"],
summary: "Dry-run alert routing simulation (no dispatches)",
description:
"Evaluates which active routing rules would match a simulated alert and which channels would fire, without dispatching anything to real endpoints.",
security: [{ ApiKeyAuth: [] }],
body: {
type: "object",
required: ["severity"],
additionalProperties: false,
properties: {
severity: {
type: "string",
enum: ["critical", "high", "medium", "low"],
},
assetCode: { type: "string", maxLength: 20 },
sourceType: { type: "string", maxLength: 80 },
ownerAddress: { type: "string" },
label: { type: "string", maxLength: 120 },
triggeredValue: { type: "number" },
threshold: { type: "number" },
metric: { type: "string", maxLength: 80 },
},
},
},
},
async (request, reply) => {
const body = request.body as {
severity: string;
assetCode?: string;
sourceType?: string;
ownerAddress?: string;
label?: string;
triggeredValue?: number;
threshold?: number;
metric?: string;
};

if (!VALID_SEVERITIES.has(body.severity)) {
return reply.code(400).send({ error: "Invalid severity value" });
}

const severity = parseSeverity(body.severity);
const assetCode = (body.assetCode ?? "").trim().toUpperCase();
const sourceType = (body.sourceType ?? "").trim();

// Load rules — scope by ownerAddress if provided
const allRules = await alertRoutingService.listRules(body.ownerAddress);
const activeRules = allRules.filter((rule) => rule.isActive);
const inactiveRules = allRules.filter((rule) => !rule.isActive);

// Evaluate each active rule in priority order
const ruleResults = activeRules
.slice()
.sort((a, b) => a.priorityOrder - b.priorityOrder)
.map((rule) => {
const severityMatch =
rule.severityLevels.length === 0 ||
rule.severityLevels.includes(severity);

const assetMatch =
rule.assetCodes.length === 0 ||
(assetCode !== "" &&
rule.assetCodes
.map((c) => c.toUpperCase())
.includes(assetCode));

const sourceMatch =
rule.sourceTypes.length === 0 ||
(sourceType !== "" && rule.sourceTypes.includes(sourceType));

const matched = severityMatch && assetMatch && sourceMatch;

const reasons: string[] = [];

if (rule.severityLevels.length === 0) {
reasons.push("Severity: matches any (no filter set)");
} else if (severityMatch) {
reasons.push(
`Severity: "${severity}" is in [${rule.severityLevels.join(", ")}]`
);
} else {
reasons.push(
`Severity: "${severity}" not in [${rule.severityLevels.join(", ")}] — no match`
);
}

if (rule.assetCodes.length === 0) {
reasons.push("Asset: matches any (no filter set)");
} else if (assetMatch) {
reasons.push(
`Asset: "${assetCode}" is in [${rule.assetCodes.join(", ")}]`
);
} else {
reasons.push(
`Asset: "${assetCode || "(empty)"}" not in [${rule.assetCodes.join(", ")}] — no match`
);
}

if (rule.sourceTypes.length === 0) {
reasons.push("Source type: matches any (no filter set)");
} else if (sourceMatch) {
reasons.push(
`Source type: "${sourceType}" is in [${rule.sourceTypes.join(", ")}]`
);
} else {
reasons.push(
`Source type: "${sourceType || "(empty)"}" not in [${rule.sourceTypes.join(", ")}] — no match`
);
}

return {
ruleId: rule.id,
ruleName: rule.name,
priorityOrder: rule.priorityOrder,
ownerAddress: rule.ownerAddress,
matched,
reasons,
channels: matched ? rule.channels : [],
fallbackChannels: matched ? rule.fallbackChannels : [],
suppressionWindowSeconds: rule.suppressionWindowSeconds,
};
});

const matchedResults = ruleResults.filter((r) => r.matched);
const firstMatch = matchedResults[0] ?? null;

const simulationId = `sim_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;

return reply.send({
simulationId,
timestamp: new Date().toISOString(),
input: {
severity,
assetCode,
sourceType,
ownerAddress: body.ownerAddress ?? null,
label: body.label ?? null,
triggeredValue: body.triggeredValue ?? null,
threshold: body.threshold ?? null,
metric: body.metric ?? null,
},
results: ruleResults,
skippedInactive: inactiveRules.map((r) => ({
ruleId: r.id,
ruleName: r.name,
priorityOrder: r.priorityOrder,
})),
summary: {
totalActiveRules: activeRules.length,
totalMatched: matchedResults.length,
firstMatchingRule: firstMatch
? { ruleId: firstMatch.ruleId, ruleName: firstMatch.ruleName }
: null,
wouldDispatch: firstMatch !== null,
effectiveChannels: firstMatch?.channels ?? [],
effectiveFallbackChannels: firstMatch?.fallbackChannels ?? [],
suppressionWindowSeconds: firstMatch?.suppressionWindowSeconds ?? 0,
},
});
}
);
}
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const RelationshipExplorer = lazy(() => import("./pages/RelationshipExplorer"));
const SearchResultsPage = lazy(() => import("./pages/SearchResultsPage"));
const Alerts = lazy(() => import("./pages/Alerts"));
const DataProvenanceGraph = lazy(() => import("./pages/DataProvenanceGraph"));
const AlertSimulationSandbox = lazy(() => import("./pages/AlertSimulationSandbox"));

function NotificationInitializer() {
useNotifications();
Expand Down Expand Up @@ -72,6 +73,7 @@ function App() {
<Route path="/relationship-explorer" element={<RelationshipExplorer />} />
<Route path="/search" element={<SearchResultsPage />} />
<Route path="/data-provenance" element={<DataProvenanceGraph />} />
<Route path="/alert-sandbox" element={<AlertSimulationSandbox />} />
</Route>
</Routes>
</Suspense>
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/MobileNav/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export const navGroups: NavGroup[] = [
label: "Alert Routing",
description: "Manage alert dispatch routing and audit",
},
{
to: "/alert-sandbox",
label: "Alert Sandbox",
description: "Dry-run alert rules against synthetic data before enabling in production",
},
{ to: "/settings", label: "Settings", description: "Notification and dashboard preferences" },
],
},
Expand Down
146 changes: 146 additions & 0 deletions frontend/src/hooks/useAlertSimulation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { useState, useCallback } from "react";

export type SimulationSeverity = "critical" | "high" | "medium" | "low";

export interface SimulationInput {
severity: SimulationSeverity;
assetCode: string;
sourceType: string;
ownerAddress: string;
label: string;
triggeredValue: number | null;
threshold: number | null;
metric: string;
}

export interface SimulationRuleResult {
ruleId: string;
ruleName: string;
priorityOrder: number;
ownerAddress: string | null;
matched: boolean;
reasons: string[];
channels: string[];
fallbackChannels: string[];
suppressionWindowSeconds: number;
}

export interface SimulationSummary {
totalActiveRules: number;
totalMatched: number;
firstMatchingRule: { ruleId: string; ruleName: string } | null;
wouldDispatch: boolean;
effectiveChannels: string[];
effectiveFallbackChannels: string[];
suppressionWindowSeconds: number;
}

export interface SimulationResult {
simulationId: string;
timestamp: string;
input: SimulationInput & {
ownerAddress: string | null;
label: string | null;
triggeredValue: number | null;
threshold: number | null;
metric: string | null;
};
results: SimulationRuleResult[];
skippedInactive: { ruleId: string; ruleName: string; priorityOrder: number }[];
summary: SimulationSummary;
}

const HISTORY_KEY = "bw_sim_history";
const MAX_HISTORY = 20;

function loadHistory(): SimulationResult[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
return raw ? (JSON.parse(raw) as SimulationResult[]) : [];
} catch {
return [];
}
}

function persistHistory(items: SimulationResult[]): void {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)));
} catch {
// ignore quota errors
}
}

export function useAlertSimulation(adminToken: string) {
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentResult, setCurrentResult] = useState<SimulationResult | null>(null);
const [history, setHistory] = useState<SimulationResult[]>(loadHistory);

const runSimulation = useCallback(
async (input: SimulationInput) => {
setIsRunning(true);
setError(null);

try {
const payload: Record<string, unknown> = { severity: input.severity };
if (input.assetCode.trim()) payload.assetCode = input.assetCode.trim();
if (input.sourceType.trim()) payload.sourceType = input.sourceType.trim();
if (input.ownerAddress.trim()) payload.ownerAddress = input.ownerAddress.trim();
if (input.label.trim()) payload.label = input.label.trim();
if (input.metric.trim()) payload.metric = input.metric.trim();
if (input.triggeredValue !== null) payload.triggeredValue = input.triggeredValue;
if (input.threshold !== null) payload.threshold = input.threshold;

const res = await fetch("/api/v1/admin/alert-routing/simulate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": adminToken,
},
body: JSON.stringify(payload),
});

if (!res.ok) {
const data = (await res.json()) as { error?: unknown };
throw new Error(
typeof data.error === "string"
? data.error
: `HTTP ${res.status}`
);
}

const result = (await res.json()) as SimulationResult;
setCurrentResult(result);
setHistory((prev) => {
const next = [result, ...prev].slice(0, MAX_HISTORY);
persistHistory(next);
return next;
});
} catch (err) {
setError(err instanceof Error ? err.message : "Simulation failed");
} finally {
setIsRunning(false);
}
},
[adminToken]
);

const restoreFromHistory = useCallback((result: SimulationResult) => {
setCurrentResult(result);
}, []);

const clearHistory = useCallback(() => {
setHistory([]);
persistHistory([]);
}, []);

return {
isRunning,
error,
currentResult,
history,
runSimulation,
restoreFromHistory,
clearHistory,
};
}
Loading
Loading