From 712da2cf98f8489f9d6a73621efa7cff871c5b20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Mart=C3=ADn?= Date: Tue, 17 Mar 2026 09:45:29 +0100 Subject: [PATCH] feat(ui): Add CloudTrail Events tab to detail cards (#10320) Co-authored-by: alejandrobailo --- ui/CHANGELOG.md | 1 + ui/actions/resources/index.ts | 1 + ui/actions/resources/resources.test.ts | 171 +++++++ ui/actions/resources/resources.ts | 58 +++ .../findings/table/finding-detail.test.tsx | 4 + .../findings/table/finding-detail.tsx | 20 +- .../table/resource-detail-content.tsx | 15 + .../events-timeline/events-timeline.test.tsx | 293 +++++++++++ .../events-timeline/events-timeline.tsx | 458 ++++++++++++++++++ ui/types/resources.ts | 21 + 10 files changed, 1040 insertions(+), 2 deletions(-) create mode 100644 ui/actions/resources/resources.test.ts create mode 100644 ui/components/shared/events-timeline/events-timeline.test.tsx create mode 100644 ui/components/shared/events-timeline/events-timeline.tsx diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 7f2d29e44e..9842b76870 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🚀 Added - Organization and organizational unit row actions (Edit Name, Update Credentials, Test Connections, Delete) in providers table dropdown [(#10317)](https://github.com/prowler-cloud/prowler/pull/10317) +- Events tab in Findings and Resource detail cards showing an AWS CloudTrail timeline with expandable event rows, actor info, request/response JSON payloads, and error details [(#10320)](https://github.com/prowler-cloud/prowler/pull/10320) --- diff --git a/ui/actions/resources/index.ts b/ui/actions/resources/index.ts index e4e24e4d9a..afaf91a381 100644 --- a/ui/actions/resources/index.ts +++ b/ui/actions/resources/index.ts @@ -3,5 +3,6 @@ export { getLatestResources, getMetadataInfo, getResourceById, + getResourceEvents, getResources, } from "./resources"; diff --git a/ui/actions/resources/resources.test.ts b/ui/actions/resources/resources.test.ts new file mode 100644 index 0000000000..b3fc4b257b --- /dev/null +++ b/ui/actions/resources/resources.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted( + () => ({ + fetchMock: vi.fn(), + getAuthHeadersMock: vi.fn(), + handleApiResponseMock: vi.fn(), + }), +); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.example.com/api/v1", + getAuthHeaders: getAuthHeadersMock, +})); + +vi.mock("@/lib/server-actions-helper", () => ({ + handleApiResponse: handleApiResponseMock, +})); + +vi.mock("@/lib/provider-filters", () => ({ + appendSanitizedProviderTypeFilters: vi.fn(), +})); + +vi.mock("next/navigation", () => ({ + redirect: vi.fn(), +})); + +import { getResourceEvents } from "./resources"; + +describe("getResourceEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", fetchMock); + getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" }); + }); + + it("calls the correct API endpoint with default parameters", async () => { + // Given + const mockResponse = new Response("", { status: 200 }); + fetchMock.mockResolvedValue(mockResponse); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await getResourceEvents("resource-123"); + + // Then + expect(fetchMock).toHaveBeenCalledTimes(1); + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.pathname).toBe("/api/v1/resources/resource-123/events"); + expect(calledUrl.searchParams.get("include_read_events")).toBe("false"); + expect(calledUrl.searchParams.get("lookback_days")).toBe("90"); + expect(calledUrl.searchParams.get("page[size]")).toBe("50"); + }); + + it("passes custom parameters to the API", async () => { + // Given + const mockResponse = new Response("", { status: 200 }); + fetchMock.mockResolvedValue(mockResponse); + handleApiResponseMock.mockResolvedValue({ data: [] }); + + // When + await getResourceEvents("resource-456", { + includeReadEvents: true, + lookbackDays: 30, + pageSize: 25, + }); + + // Then + const calledUrl = new URL(fetchMock.mock.calls[0][0]); + expect(calledUrl.searchParams.get("include_read_events")).toBe("true"); + expect(calledUrl.searchParams.get("lookback_days")).toBe("30"); + expect(calledUrl.searchParams.get("page[size]")).toBe("25"); + }); + + it("returns parsed response on success", async () => { + // Given + const mockData = { + data: [ + { + type: "resource-events", + id: "event-1", + attributes: { event_name: "CreateStack" }, + }, + ], + }; + const mockResponse = new Response("", { status: 200 }); + fetchMock.mockResolvedValue(mockResponse); + handleApiResponseMock.mockResolvedValue(mockData); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual(mockData); + expect(handleApiResponseMock).toHaveBeenCalledWith(mockResponse); + }); + + it("returns error object for non-ok responses without calling handleApiResponse", async () => { + // Given + const errorBody = JSON.stringify({ + errors: [ + { + detail: + "Provider credentials are invalid or expired. Please reconnect the provider.", + }, + ], + }); + const mockResponse = new Response(errorBody, { + status: 502, + statusText: "Bad Gateway", + }); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: + "Provider credentials are invalid or expired. Please reconnect the provider.", + status: 502, + }); + expect(handleApiResponseMock).not.toHaveBeenCalled(); + }); + + it("returns error with statusText when response body is not JSON", async () => { + // Given + const mockResponse = new Response("Service Unavailable", { + status: 503, + statusText: "Service Unavailable", + }); + fetchMock.mockResolvedValue(mockResponse); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ + error: "Service Unavailable", + status: 503, + }); + }); + + it("returns generic error when fetch throws", async () => { + // Given + fetchMock.mockRejectedValue(new Error("Network failure")); + + // When + const result = await getResourceEvents("resource-123"); + + // Then + expect(result).toEqual({ error: "An unexpected error occurred." }); + }); + + it.each([ + "../../../etc/passwd", + "resource/../../secret", + "id with spaces", + "id;rm -rf /", + "", + "resource%00id", + "", + ])("rejects malicious or invalid resourceId: %s", async (maliciousId) => { + // When + const result = await getResourceEvents(maliciousId); + + // Then + expect(result).toEqual({ error: "Invalid resource ID format." }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/actions/resources/resources.ts b/ui/actions/resources/resources.ts index d65505704f..36f9761959 100644 --- a/ui/actions/resources/resources.ts +++ b/ui/actions/resources/resources.ts @@ -160,6 +160,64 @@ export const getLatestMetadataInfo = async ({ } }; +const SAFE_RESOURCE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/; + +export const getResourceEvents = async ( + resourceId: string, + { + includeReadEvents = false, + lookbackDays = 90, + pageSize = 50, + }: { + includeReadEvents?: boolean; + lookbackDays?: number; + pageSize?: number; + } = {}, +) => { + if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) { + return { error: "Invalid resource ID format." }; + } + + const headers = await getAuthHeaders({ contentType: false }); + + const url = new URL(`${apiBaseUrl}/resources/${resourceId}/events`); + url.searchParams.append("include_read_events", String(includeReadEvents)); + url.searchParams.append("lookback_days", String(lookbackDays)); + url.searchParams.append("page[size]", String(pageSize)); + + try { + const response = await fetch(url.toString(), { headers }); + + if (!response.ok) { + const rawText = await response.text().catch(() => ""); + const defaultError = "An error occurred while fetching events."; + try { + const errorData = rawText ? JSON.parse(rawText) : null; + return { + error: + errorData?.errors?.[0]?.detail || + errorData?.error || + errorData?.message || + rawText || + response.statusText || + defaultError, + status: response.status, + }; + } catch { + return { + error: rawText || response.statusText || defaultError, + status: response.status, + }; + } + } + + return handleApiResponse(response); + } catch (error) { + console.error("Error fetching resource events:", error); + return { error: "An unexpected error occurred." }; + } +}; + export const getResourceById = async ( id: string, { diff --git a/ui/components/findings/table/finding-detail.test.tsx b/ui/components/findings/table/finding-detail.test.tsx index 1d01919326..ec2e84e2fb 100644 --- a/ui/components/findings/table/finding-detail.test.tsx +++ b/ui/components/findings/table/finding-detail.test.tsx @@ -132,6 +132,10 @@ vi.mock("./delta-indicator", () => ({ DeltaIndicator: () => null, })); +vi.mock("@/components/shared/events-timeline/events-timeline", () => ({ + EventsTimeline: () =>
, +})); + vi.mock("react-markdown", () => ({ default: ({ children }: { children: string }) => {children}, })); diff --git a/ui/components/findings/table/finding-detail.tsx b/ui/components/findings/table/finding-detail.tsx index 195dadc042..d8a3918e14 100644 --- a/ui/components/findings/table/finding-detail.tsx +++ b/ui/components/findings/table/finding-detail.tsx @@ -2,7 +2,7 @@ import { ExternalLink, Link, VolumeX, X } from "lucide-react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import ReactMarkdown from "react-markdown"; import { @@ -23,6 +23,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn"; +import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { CustomLink } from "@/components/ui/custom/custom-link"; import { EntityInfo } from "@/components/ui/entities"; @@ -92,6 +93,12 @@ export const FindingDetail = ({ const searchParams = useSearchParams(); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); + const [activeTab, setActiveTab] = useState("general"); + + useEffect(() => { + setActiveTab("general"); + }, [findingDetails.id]); + const copyFindingUrl = () => { const params = new URLSearchParams(searchParams.toString()); params.set("id", findingDetails.id); @@ -172,12 +179,13 @@ export const FindingDetail = ({
{/* Tabs */} - +
General Resources Scans + Events {!attributes.muted && ( @@ -470,6 +478,14 @@ export const FindingDetail = ({

)} + + {/* Events Tab */} + + +
); diff --git a/ui/components/resources/table/resource-detail-content.tsx b/ui/components/resources/table/resource-detail-content.tsx index 1e4c1f1f15..edbe9494f8 100644 --- a/ui/components/resources/table/resource-detail-content.tsx +++ b/ui/components/resources/table/resource-detail-content.tsx @@ -18,6 +18,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/shadcn"; +import { EventsTimeline } from "@/components/shared/events-timeline/events-timeline"; import { BreadcrumbNavigation, CustomBreadcrumbItem } from "@/components/ui"; import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; import { @@ -134,6 +135,11 @@ export const ResourceDetailContent = ({ const attributes = resource.attributes; const providerData = resource.relationships.provider.data.attributes; + // Reset to overview tab when switching resources + useEffect(() => { + setActiveTab("overview"); + }, [resourceId]); + // Cleanup abort controller on unmount useEffect(() => { return () => { @@ -409,6 +415,7 @@ export const ResourceDetailContent = ({ Findings {totalFindings > 0 && `(${totalFindings})`} + Events {/* Overview Tab */} @@ -535,6 +542,14 @@ export const ResourceDetailContent = ({ )} + + {/* Events Tab */} + + +
); diff --git a/ui/components/shared/events-timeline/events-timeline.test.tsx b/ui/components/shared/events-timeline/events-timeline.test.tsx new file mode 100644 index 0000000000..3daf445384 --- /dev/null +++ b/ui/components/shared/events-timeline/events-timeline.test.tsx @@ -0,0 +1,293 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { EventsTimeline } from "./events-timeline"; + +const { getResourceEventsMock } = vi.hoisted(() => ({ + getResourceEventsMock: vi.fn(), +})); + +vi.mock("@/actions/resources", () => ({ + getResourceEvents: getResourceEventsMock, +})); + +const mockEvent = { + type: "resource-events" as const, + id: "event-1", + attributes: { + event_time: "2026-01-26T16:05:07Z", + event_name: "CreateStack", + event_source: "cloudformation.amazonaws.com", + actor: "admin-role", + actor_uid: "arn:aws:sts::123456:assumed-role/admin-role", + actor_type: "AssumedRole", + source_ip_address: "192.168.1.1", + user_agent: "aws-cli/2.0", + request_data: { stackName: "my-stack" }, + response_data: { stackId: "arn:aws:cloudformation:..." }, + error_code: null, + error_message: null, + }, +}; + +const mockErrorEvent = { + ...mockEvent, + id: "event-2", + attributes: { + ...mockEvent.attributes, + event_name: "DeleteStack", + error_code: "AccessDenied", + error_message: "User is not authorized", + }, +}; + +describe("EventsTimeline", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows non-AWS message for non-AWS providers", () => { + // When + render(); + + // Then + expect( + screen.getByText("Events timeline is only available for AWS resources."), + ).toBeInTheDocument(); + expect(getResourceEventsMock).not.toHaveBeenCalled(); + }); + + it("shows loading state while fetching events", async () => { + // Given + getResourceEventsMock.mockReturnValue(new Promise(() => {})); // never resolves + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText("Fetching CloudTrail events..."), + ).toBeInTheDocument(); + }); + }); + + it("renders events after successful fetch", async () => { + // Given + getResourceEventsMock.mockResolvedValue({ + data: [mockEvent], + }); + + // When + render(); + + // Then + await waitFor(() => { + expect(screen.getByText("CreateStack")).toBeInTheDocument(); + }); + expect(screen.getByText("1 event")).toBeInTheDocument(); + expect(screen.getByText("admin-role")).toBeInTheDocument(); + }); + + it("shows empty state when no events are returned", async () => { + // Given + getResourceEventsMock.mockResolvedValue({ data: [] }); + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText("No events found in the last 90 days."), + ).toBeInTheDocument(); + }); + }); + + it("shows error message when API returns an error", async () => { + // Given + getResourceEventsMock.mockResolvedValue({ + error: "Provider credentials are invalid or expired.", + status: 502, + }); + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText( + "Provider credentials are invalid or expired. Please reconnect your AWS provider.", + ), + ).toBeInTheDocument(); + }); + expect(screen.getByText("Try again")).toBeInTheDocument(); + }); + + it("shows 503 error message for AWS unavailability", async () => { + // Given + getResourceEventsMock.mockResolvedValue({ + error: "Service Unavailable", + status: 503, + }); + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText( + "AWS CloudTrail is temporarily unavailable. Please try again later.", + ), + ).toBeInTheDocument(); + }); + }); + + it("shows raw error message for other error statuses", async () => { + // Given + getResourceEventsMock.mockResolvedValue({ + error: "Invalid lookback_days parameter.", + status: 400, + }); + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText("Invalid lookback_days parameter."), + ).toBeInTheDocument(); + }); + }); + + it("expands event to show detail card on click", async () => { + // Given + const user = userEvent.setup(); + getResourceEventsMock.mockResolvedValue({ data: [mockEvent] }); + + render(); + + await waitFor(() => { + expect(screen.getByText("CreateStack")).toBeInTheDocument(); + }); + + // When - click the event row to expand + await user.click(screen.getByText("CreateStack")); + + // Then - detail card should show expanded info + expect(screen.getByText("192.168.1.1")).toBeInTheDocument(); + expect(screen.getByText("AssumedRole")).toBeInTheDocument(); + expect(screen.getByText("Request")).toBeInTheDocument(); + expect(screen.getByText("Response")).toBeInTheDocument(); + }); + + it("collapses event when clicked again", async () => { + // Given + const user = userEvent.setup(); + getResourceEventsMock.mockResolvedValue({ data: [mockEvent] }); + + render(); + + await waitFor(() => { + expect(screen.getByText("CreateStack")).toBeInTheDocument(); + }); + + // When - expand then collapse (use getAllByText since expanded card also shows event name) + await user.click(screen.getByText("CreateStack")); + expect(screen.getByText("Request")).toBeInTheDocument(); + + await user.click(screen.getAllByText("CreateStack")[0]); + + // Then + expect(screen.queryByText("Request")).not.toBeInTheDocument(); + }); + + it("shows error banner for events with error codes", async () => { + // Given + const user = userEvent.setup(); + getResourceEventsMock.mockResolvedValue({ + data: [mockErrorEvent], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("DeleteStack")).toBeInTheDocument(); + }); + + // When + await user.click(screen.getByText("DeleteStack")); + + // Then + expect(screen.getByText("AccessDenied")).toBeInTheDocument(); + expect(screen.getByText("User is not authorized")).toBeInTheDocument(); + }); + + it("refetches events when include read events checkbox is toggled", async () => { + // Given + const user = userEvent.setup(); + getResourceEventsMock.mockResolvedValue({ data: [mockEvent] }); + + render(); + + await waitFor(() => { + expect(screen.getByText("CreateStack")).toBeInTheDocument(); + }); + + expect(getResourceEventsMock).toHaveBeenCalledWith("resource-1", { + includeReadEvents: false, + }); + + // When + await user.click(screen.getByRole("checkbox")); + + // Then + await waitFor(() => { + expect(getResourceEventsMock).toHaveBeenCalledWith("resource-1", { + includeReadEvents: true, + }); + }); + }); + + it("retries fetch when retry button is clicked", async () => { + // Given + const user = userEvent.setup(); + getResourceEventsMock + .mockResolvedValueOnce({ error: "Something went wrong", status: 500 }) + .mockResolvedValueOnce({ data: [mockEvent] }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Try again")).toBeInTheDocument(); + }); + + // When + await user.click(screen.getByText("Try again")); + + // Then + await waitFor(() => { + expect(screen.getByText("CreateStack")).toBeInTheDocument(); + }); + expect(getResourceEventsMock).toHaveBeenCalledTimes(2); + }); + + it("handles null response gracefully", async () => { + // Given + getResourceEventsMock.mockResolvedValue(null); + + // When + render(); + + // Then + await waitFor(() => { + expect( + screen.getByText("Failed to fetch events. Please try again."), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/components/shared/events-timeline/events-timeline.tsx b/ui/components/shared/events-timeline/events-timeline.tsx new file mode 100644 index 0000000000..415b699050 --- /dev/null +++ b/ui/components/shared/events-timeline/events-timeline.tsx @@ -0,0 +1,458 @@ +"use client"; + +import { + AlertTriangle, + ChevronRight, + Clock, + Download, + Loader2, + Server, + Shield, +} from "lucide-react"; +import { useEffect, useState, useTransition } from "react"; + +import { getResourceEvents } from "@/actions/resources"; +import { + Alert, + AlertDescription, + Badge, + Button, + Card, + Checkbox, + InfoField, +} from "@/components/shadcn"; +import { CodeSnippet } from "@/components/ui/code-snippet/code-snippet"; +import { cn } from "@/lib/utils"; +import { ResourceEventProps } from "@/types"; + +interface EventsTimelineProps { + resourceId?: string; + isAwsProvider: boolean; +} + +export const EventsTimeline = ({ + resourceId, + isAwsProvider, +}: EventsTimelineProps) => { + const [events, setEvents] = useState([]); + const [error, setError] = useState(null); + const [errorStatus, setErrorStatus] = useState(null); + const [expandedRows, setExpandedRows] = useState>(new Set()); + const [includeReadEvents, setIncludeReadEvents] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + const [retryCount, setRetryCount] = useState(0); + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (!isAwsProvider || !resourceId) return; + + let cancelled = false; + + setError(null); + setErrorStatus(null); + setHasFetched(false); + + startTransition(async () => { + try { + const response = await getResourceEvents(resourceId, { + includeReadEvents, + }); + + if (cancelled) return; + + if (!response) { + setError("Failed to fetch events. Please try again."); + return; + } + + if (response.error) { + setError(response.error); + setErrorStatus(response.status || null); + return; + } + + setEvents(response.data || []); + setExpandedRows(new Set()); + } catch (err) { + if (cancelled) return; + console.error("Error fetching events:", err); + setError("An unexpected error occurred."); + } finally { + if (!cancelled) setHasFetched(true); + } + }); + + return () => { + cancelled = true; + }; + }, [resourceId, includeReadEvents, isAwsProvider, retryCount]); + + const toggleRow = (eventId: string) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(eventId)) { + next.delete(eventId); + } else { + next.add(eventId); + } + return next; + }); + }; + + const downloadEventJson = (event: ResourceEventProps) => { + const json = JSON.stringify(event.attributes, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `event-${event.attributes.event_name}-${event.attributes.event_time}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 100); + }; + + if (!isAwsProvider) { + return ( +
+
+ +
+

+ Events timeline is only available for AWS resources. +

+
+ ); + } + + if (isPending && !hasFetched) { + return ( +
+ +

+ Fetching CloudTrail events... +

+
+ ); + } + + if (error) { + return ( +
+ + + +

+ {errorStatus === 502 + ? "Provider credentials are invalid or expired. Please reconnect your AWS provider." + : errorStatus === 503 + ? "AWS CloudTrail is temporarily unavailable. Please try again later." + : error} +

+ +
+
+
+ ); + } + + return ( +
+ {/* Controls bar */} +
+ +
+ {isPending && } + + {events.length} event{events.length !== 1 && "s"} + +
+
+ + {/* Timeline */} + {events.length === 0 && hasFetched ? ( +
+
+ +
+

+ No events found in the last 90 days. +

+

+ CloudTrail events may take up to 15 minutes to appear after an + action is performed. +

+
+ ) : ( +
+ {/* Timeline vertical line */} +
+ +
+ {events.map((event, index) => { + const isExpanded = expandedRows.has(event.id); + const attrs = event.attributes; + const hasError = !!attrs.error_code; + const isLast = index === events.length - 1; + + return ( +
+ {/* Timeline node + row */} + + + {/* Expanded detail card */} + {isExpanded && ( +
+ + {/* Header bar */} +
+
+ + + {attrs.event_name} + + + via {attrs.event_source} + +
+ +
+ + {/* Detail rows */} +
+
+ + {new Date(attrs.event_time).toLocaleString()} + +
+
+ +
+ + {attrs.actor} + + {attrs.actor_type} +
+
+
+
+ + + {attrs.source_ip_address} + + +
+
+ + {/* Error banner */} + {hasError && ( + + + + + {attrs.error_code} + + {attrs.error_message && ( + <> + + {" "} + |{" "} + + + {attrs.error_message} + + + )} + + + )} + + {/* JSON payloads */} + {(attrs.request_data || attrs.response_data) && ( +
+ {attrs.request_data && ( + + )} + {attrs.response_data && ( + + )} +
+ )} +
+
+ )} +
+ ); + })} +
+
+ )} +
+ ); +}; + +// --- Sub-components --- + +const JsonBlock = ({ + label, + data, +}: { + label: string; + data: Record; +}) => { + const [collapsed, setCollapsed] = useState(true); + const json = JSON.stringify(data, null, 2); + const lineCount = json.split("\n").length; + const isLong = lineCount > 8; + + return ( +
+
+ + {label} + + +
+
+
+          {json}
+        
+ {isLong && collapsed && ( +
+ +
+ )} +
+
+ ); +}; + +// --- Helpers --- + +const dateFmt = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", +}); +const timeFmt = new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", +}); + +const formatShortDate = (iso: string) => { + const d = new Date(iso); + return `${dateFmt.format(d)} ${timeFmt.format(d)}`; +}; diff --git a/ui/types/resources.ts b/ui/types/resources.ts index 5413809145..da00e70cb2 100644 --- a/ui/types/resources.ts +++ b/ui/types/resources.ts @@ -145,3 +145,24 @@ export interface ResourceApiResponse { included: ResourceItemProps[]; meta: Meta; } + +export interface ResourceEventAttributes { + event_time: string; + event_name: string; + event_source: string; + actor: string; + actor_uid: string; + actor_type: string; + source_ip_address: string; + user_agent: string; + request_data: Record | null; + response_data: Record | null; + error_code: string | null; + error_message: string | null; +} + +export interface ResourceEventProps { + type: "resource-events"; + id: string; + attributes: ResourceEventAttributes; +}