Fix alerts and audit log data normalization (#1010)

This commit is contained in:
ZacharyZcR
2026-07-10 08:02:47 +08:00
committed by GitHub
parent 38c5932aa9
commit 1b5baf2351
6 changed files with 188 additions and 6 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { mapAlertFiring } from "./alerts-api";
describe("mapAlertFiring", () => {
it("normalizes sqlite snake_case rows for the alerts UI", () => {
expect(
mapAlertFiring({
id: 9,
user_id: "u1",
rule_id: 4,
host_id: 12,
host_name: "db-01",
fired_at: "2026-06-30 12:00:00",
resolved_at: null,
value: 91,
message: "CPU threshold exceeded",
severity: "critical",
acknowledged: 0,
rule_name: "CPU",
}),
).toEqual({
id: 9,
userId: "u1",
ruleId: 4,
hostId: 12,
hostName: "db-01",
firedAt: "2026-06-30 12:00:00",
resolvedAt: null,
value: 91,
message: "CPU threshold exceeded",
severity: "critical",
acknowledged: false,
ruleName: "CPU",
});
});
it("uses safe defaults for malformed rows", () => {
const firing = mapAlertFiring({ acknowledged: 1, severity: "bad" });
expect(firing.id).toBe(0);
expect(firing.hostName).toBe("");
expect(firing.severity).toBe("warning");
expect(firing.acknowledged).toBe(true);
});
});
+46 -1
View File
@@ -40,6 +40,28 @@ export interface AlertFiring {
ruleName?: string;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableNumberValue(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function severityValue(value: unknown): AlertFiring["severity"] {
return value === "info" || value === "critical" || value === "warning"
? value
: "warning";
}
export async function getNotificationChannels(): Promise<
NotificationChannel[]
> {
@@ -91,6 +113,23 @@ function mapRule(r: Record<string, unknown>): AlertRule {
};
}
export function mapAlertFiring(r: Record<string, unknown>): AlertFiring {
return {
id: numberValue(r.id),
userId: stringValue(r.userId ?? r.user_id),
ruleId: numberValue(r.ruleId ?? r.rule_id),
hostId: numberValue(r.hostId ?? r.host_id),
hostName: stringValue(r.hostName ?? r.host_name),
firedAt: stringValue(r.firedAt ?? r.fired_at, new Date(0).toISOString()),
resolvedAt: stringValue(r.resolvedAt ?? r.resolved_at) || null,
value: nullableNumberValue(r.value),
message: stringValue(r.message),
severity: severityValue(r.severity),
acknowledged: boolValue(r.acknowledged),
ruleName: stringValue(r.ruleName ?? r.rule_name) || undefined,
};
}
export async function getAlertRules(): Promise<AlertRule[]> {
const res = await rbacApi.get("/alert-rules");
return (res.data as Record<string, unknown>[]).map(mapRule);
@@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: {
acknowledged?: boolean;
}): Promise<AlertFiring[]> {
const res = await rbacApi.get("/alert-firings", { params: opts });
return (res.data as { firings: AlertFiring[] }).firings ?? res.data;
const data = res.data as { firings?: unknown } | unknown[];
const rows = Array.isArray(data)
? data
: Array.isArray(data.firings)
? data.firings
: [];
return rows.map((row) => mapAlertFiring(row as Record<string, unknown>));
}
export async function acknowledgeAlertFiring(id: number): Promise<void> {
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { mapAuditLog } from "./audit-log-api";
describe("mapAuditLog", () => {
it("normalizes sqlite snake_case rows for the audit log UI", () => {
expect(
mapAuditLog({
id: 3,
user_id: "u1",
username: "admin",
action: "host_create",
resource_type: "host",
resource_id: "42",
resource_name: "prod",
details: "created",
ip_address: "127.0.0.1",
user_agent: "browser",
success: 1,
error_message: null,
timestamp: "2026-06-30 12:00:00",
}),
).toEqual({
id: 3,
userId: "u1",
username: "admin",
action: "host_create",
resourceType: "host",
resourceId: "42",
resourceName: "prod",
details: "created",
ipAddress: "127.0.0.1",
userAgent: "browser",
success: true,
errorMessage: null,
timestamp: "2026-06-30 12:00:00",
});
});
it("uses render-safe defaults for malformed rows", () => {
const log = mapAuditLog({ success: 0 });
expect(log.id).toBe(0);
expect(log.action).toBe("unknown");
expect(log.resourceType).toBe("unknown");
expect(log.success).toBe(false);
});
});
+42 -1
View File
@@ -34,6 +34,40 @@ export interface AuditLogResponse {
totalPages: number;
}
function stringValue(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function numberValue(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nullableStringValue(value: unknown): string | null {
return typeof value === "string" && value ? value : null;
}
function boolValue(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
export function mapAuditLog(row: Record<string, unknown>): AuditLog {
return {
id: numberValue(row.id),
userId: stringValue(row.userId ?? row.user_id),
username: stringValue(row.username),
action: stringValue(row.action, "unknown"),
resourceType: stringValue(row.resourceType ?? row.resource_type, "unknown"),
resourceId: nullableStringValue(row.resourceId ?? row.resource_id),
resourceName: nullableStringValue(row.resourceName ?? row.resource_name),
details: nullableStringValue(row.details),
ipAddress: nullableStringValue(row.ipAddress ?? row.ip_address),
userAgent: nullableStringValue(row.userAgent ?? row.user_agent),
success: boolValue(row.success),
errorMessage: nullableStringValue(row.errorMessage ?? row.error_message),
timestamp: stringValue(row.timestamp, new Date(0).toISOString()),
};
}
export async function getAuditLogs(
filters: AuditLogFilters = {},
): Promise<AuditLogResponse> {
@@ -50,7 +84,14 @@ export async function getAuditLogs(
if (filters.endDate) params.set("endDate", filters.endDate);
const response = await authApi.get(`/audit-logs?${params.toString()}`);
return response.data;
const data = response.data as Partial<AuditLogResponse>;
const rows = Array.isArray(data.logs) ? data.logs : [];
return {
logs: rows.map((row) => mapAuditLog(row as Record<string, unknown>)),
total: numberValue(data.total),
page: numberValue(data.page, filters.page ?? 1),
totalPages: numberValue(data.totalPages, 1),
};
} catch (error) {
handleApiError(error, "fetch audit logs");
}
+6 -3
View File
@@ -101,7 +101,7 @@ export function AdminAuditLogSection({
.catch(() => {});
fetchLogs(buildFilters(), 1);
setPage(1);
}, [open]);
}, [open, buildFilters, fetchLogs]);
useEffect(() => {
if (!open) return;
@@ -114,6 +114,9 @@ export function AdminAuditLogSection({
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
open,
buildFilters,
fetchLogs,
filterUserId,
filterAction,
filterResourceType,
@@ -179,7 +182,7 @@ export function AdminAuditLogSection({
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{users.map((u) => (
{(Array.isArray(users) ? users : []).map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
@@ -318,7 +321,7 @@ export function AdminAuditLogSection({
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-[10px] font-semibold text-foreground">
{log.action.replace(/_/g, " ")}
{(log.action || "unknown").replace(/_/g, " ")}
</span>
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
{log.resourceType}
+2 -1
View File
@@ -37,6 +37,7 @@ const SEVERITY_CLASS: Record<string, string> = {
function timeAgo(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
@@ -321,7 +322,7 @@ export function AlertsPanel() {
{rule.name}
</div>
<div className="text-[10px] text-muted-foreground">
{rule.triggerType.replace(/_/g, " ")}
{(rule.triggerType || "").replace(/_/g, " ")}
</div>
</div>
<div