From 36193aa81beffd74dddbd3c065ebcaf55dbbee44 Mon Sep 17 00:00:00 2001 From: nfebe Date: Thu, 16 Jul 2026 22:29:13 +0100 Subject: [PATCH] feat(ui): Show the events behind a blocked IP The blocked-IP list showed only that an address was blocked and a one-line reason, with no way to see the requests that led there, and the security events table hid each event's message and user agent even though the backend recorded them. An operator could not tell a false positive from a real attacker. Any blocked IP, and any row in the events table, now opens a per-IP trace of its recorded events, each showing the path, status, user agent, and message. The events that drove an auto-block are visible where the block is. --- src/stores/security.test.ts | 39 ++++++++ src/stores/security.ts | 6 ++ src/views/SecurityView.vue | 186 +++++++++++++++++++++++++++++++++--- 3 files changed, 220 insertions(+), 11 deletions(-) diff --git a/src/stores/security.test.ts b/src/stores/security.test.ts index 502f9a6..66b82e6 100644 --- a/src/stores/security.test.ts +++ b/src/stores/security.test.ts @@ -7,6 +7,7 @@ vi.mock("@/services/api", () => ({ getStats: vi.fn(), getEvents: vi.fn(), getBlockedIPs: vi.fn(), + getEventsByIP: vi.fn(), getProtectedRoutes: vi.fn(), blockIP: vi.fn(), unblockIP: vi.fn(), @@ -141,6 +142,44 @@ describe("Security Store", () => { }); }); + describe("fetchEventsByIP", () => { + it("returns the events recorded for an IP", async () => { + const { securityApi } = await import("@/services/api"); + const mockEvents = [ + { + id: 1, + event_type: "not_found", + severity: "low", + source_ip: "1.2.3.4", + request_path: "/missing.php", + status_code: 404, + user_agent: "curl/8.4.0", + message: "Not found", + created_at: "2024-01-01", + }, + ]; + vi.mocked(securityApi.getEventsByIP).mockResolvedValue({ + data: { events: mockEvents, ip: "1.2.3.4" }, + } as any); + + const store = useSecurityStore(); + const events = await store.fetchEventsByIP("1.2.3.4"); + + expect(securityApi.getEventsByIP).toHaveBeenCalledWith("1.2.3.4"); + expect(events).toEqual(mockEvents); + }); + + it("returns an empty array when the response has no events", async () => { + const { securityApi } = await import("@/services/api"); + vi.mocked(securityApi.getEventsByIP).mockResolvedValue({ + data: { events: null, ip: "1.2.3.4" }, + } as any); + + const store = useSecurityStore(); + expect(await store.fetchEventsByIP("1.2.3.4")).toEqual([]); + }); + }); + describe("fetchProtectedRoutes - null safety", () => { it("handles null protected_routes response gracefully", async () => { const { securityApi } = await import("@/services/api"); diff --git a/src/stores/security.ts b/src/stores/security.ts index a05e9be..451ffb3 100644 --- a/src/stores/security.ts +++ b/src/stores/security.ts @@ -83,6 +83,11 @@ export const useSecurityStore = defineStore("security", () => { } } + async function fetchEventsByIP(ip: string): Promise { + const response = await securityApi.getEventsByIP(ip); + return response.data.events || []; + } + async function fetchWhitelist() { loading.value = true; error.value = null; @@ -232,6 +237,7 @@ export const useSecurityStore = defineStore("security", () => { fetchStats, fetchEvents, fetchBlockedIPs, + fetchEventsByIP, blockIP, unblockIP, fetchWhitelist, diff --git a/src/views/SecurityView.vue b/src/views/SecurityView.vue index 8a8d7d4..53b590e 100644 --- a/src/views/SecurityView.vue +++ b/src/views/SecurityView.vue @@ -308,6 +308,13 @@ {{ event.status_code || "-" }} + +
+ + +
@@ -655,6 +668,51 @@ + + + + +