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;
+}