mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
Fix alerts and audit log data normalization (#1010)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user