diff --git a/src/ui/api/alerts-api.test.ts b/src/ui/api/alerts-api.test.ts new file mode 100644 index 00000000..cb04d4b8 --- /dev/null +++ b/src/ui/api/alerts-api.test.ts @@ -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); + }); +}); diff --git a/src/ui/api/alerts-api.ts b/src/ui/api/alerts-api.ts index 82cb342c..2b1ef500 100644 --- a/src/ui/api/alerts-api.ts +++ b/src/ui/api/alerts-api.ts @@ -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): AlertRule { }; } +export function mapAlertFiring(r: Record): 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 { const res = await rbacApi.get("/alert-rules"); return (res.data as Record[]).map(mapRule); @@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: { acknowledged?: boolean; }): Promise { 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)); } export async function acknowledgeAlertFiring(id: number): Promise { diff --git a/src/ui/api/audit-log-api.test.ts b/src/ui/api/audit-log-api.test.ts new file mode 100644 index 00000000..953baec2 --- /dev/null +++ b/src/ui/api/audit-log-api.test.ts @@ -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); + }); +}); diff --git a/src/ui/api/audit-log-api.ts b/src/ui/api/audit-log-api.ts index a9d3b88d..a22c48d1 100644 --- a/src/ui/api/audit-log-api.ts +++ b/src/ui/api/audit-log-api.ts @@ -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): 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 { @@ -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; + const rows = Array.isArray(data.logs) ? data.logs : []; + return { + logs: rows.map((row) => mapAuditLog(row as Record)), + total: numberValue(data.total), + page: numberValue(data.page, filters.page ?? 1), + totalPages: numberValue(data.totalPages, 1), + }; } catch (error) { handleApiError(error, "fetch audit logs"); } diff --git a/src/ui/sidebar/AdminAuditLogSection.tsx b/src/ui/sidebar/AdminAuditLogSection.tsx index cde60d7e..85fa2900 100644 --- a/src/ui/sidebar/AdminAuditLogSection.tsx +++ b/src/ui/sidebar/AdminAuditLogSection.tsx @@ -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)} > - {users.map((u) => ( + {(Array.isArray(users) ? users : []).map((u) => ( @@ -318,7 +321,7 @@ export function AdminAuditLogSection({
- {log.action.replace(/_/g, " ")} + {(log.action || "unknown").replace(/_/g, " ")} {log.resourceType} diff --git a/src/ui/sidebar/AlertsPanel.tsx b/src/ui/sidebar/AlertsPanel.tsx index 24d42f02..4c4b77fa 100644 --- a/src/ui/sidebar/AlertsPanel.tsx +++ b/src/ui/sidebar/AlertsPanel.tsx @@ -37,6 +37,7 @@ const SEVERITY_CLASS: Record = { 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}
- {rule.triggerType.replace(/_/g, " ")} + {(rule.triggerType || "").replace(/_/g, " ")}