mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 12:53:40 +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;
|
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<
|
export async function getNotificationChannels(): Promise<
|
||||||
NotificationChannel[]
|
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[]> {
|
export async function getAlertRules(): Promise<AlertRule[]> {
|
||||||
const res = await rbacApi.get("/alert-rules");
|
const res = await rbacApi.get("/alert-rules");
|
||||||
return (res.data as Record<string, unknown>[]).map(mapRule);
|
return (res.data as Record<string, unknown>[]).map(mapRule);
|
||||||
@@ -121,7 +160,13 @@ export async function getAlertFirings(opts?: {
|
|||||||
acknowledged?: boolean;
|
acknowledged?: boolean;
|
||||||
}): Promise<AlertFiring[]> {
|
}): Promise<AlertFiring[]> {
|
||||||
const res = await rbacApi.get("/alert-firings", { params: opts });
|
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> {
|
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;
|
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(
|
export async function getAuditLogs(
|
||||||
filters: AuditLogFilters = {},
|
filters: AuditLogFilters = {},
|
||||||
): Promise<AuditLogResponse> {
|
): Promise<AuditLogResponse> {
|
||||||
@@ -50,7 +84,14 @@ export async function getAuditLogs(
|
|||||||
if (filters.endDate) params.set("endDate", filters.endDate);
|
if (filters.endDate) params.set("endDate", filters.endDate);
|
||||||
|
|
||||||
const response = await authApi.get(`/audit-logs?${params.toString()}`);
|
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) {
|
} catch (error) {
|
||||||
handleApiError(error, "fetch audit logs");
|
handleApiError(error, "fetch audit logs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export function AdminAuditLogSection({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
fetchLogs(buildFilters(), 1);
|
fetchLogs(buildFilters(), 1);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}, [open]);
|
}, [open, buildFilters, fetchLogs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -114,6 +114,9 @@ export function AdminAuditLogSection({
|
|||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
|
open,
|
||||||
|
buildFilters,
|
||||||
|
fetchLogs,
|
||||||
filterUserId,
|
filterUserId,
|
||||||
filterAction,
|
filterAction,
|
||||||
filterResourceType,
|
filterResourceType,
|
||||||
@@ -179,7 +182,7 @@ export function AdminAuditLogSection({
|
|||||||
onChange={(e) => setFilterUserId(e.target.value)}
|
onChange={(e) => setFilterUserId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t("admin.auditLogFilterAll")}</option>
|
<option value="">{t("admin.auditLogFilterAll")}</option>
|
||||||
{users.map((u) => (
|
{(Array.isArray(users) ? users : []).map((u) => (
|
||||||
<option key={u.id} value={u.id}>
|
<option key={u.id} value={u.id}>
|
||||||
{u.username}
|
{u.username}
|
||||||
</option>
|
</option>
|
||||||
@@ -318,7 +321,7 @@ export function AdminAuditLogSection({
|
|||||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-[10px] font-semibold text-foreground">
|
<span className="text-[10px] font-semibold text-foreground">
|
||||||
{log.action.replace(/_/g, " ")}
|
{(log.action || "unknown").replace(/_/g, " ")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
|
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
|
||||||
{log.resourceType}
|
{log.resourceType}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const SEVERITY_CLASS: Record<string, string> = {
|
|||||||
|
|
||||||
function timeAgo(isoStr: string): string {
|
function timeAgo(isoStr: string): string {
|
||||||
const ms = Date.now() - new Date(isoStr).getTime();
|
const ms = Date.now() - new Date(isoStr).getTime();
|
||||||
|
if (!Number.isFinite(ms)) return "";
|
||||||
const sec = Math.floor(ms / 1000);
|
const sec = Math.floor(ms / 1000);
|
||||||
if (sec < 60) return `${sec}s ago`;
|
if (sec < 60) return `${sec}s ago`;
|
||||||
const min = Math.floor(sec / 60);
|
const min = Math.floor(sec / 60);
|
||||||
@@ -321,7 +322,7 @@ export function AlertsPanel() {
|
|||||||
{rule.name}
|
{rule.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">
|
<div className="text-[10px] text-muted-foreground">
|
||||||
{rule.triggerType.replace(/_/g, " ")}
|
{(rule.triggerType || "").replace(/_/g, " ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user