mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-22 03:08:23 +00:00
feat(ui): Add CloudTrail Events tab to detail cards (#10320)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ export {
|
||||
getLatestResources,
|
||||
getMetadataInfo,
|
||||
getResourceById,
|
||||
getResourceEvents,
|
||||
getResources,
|
||||
} from "./resources";
|
||||
|
||||
171
ui/actions/resources/resources.test.ts
Normal file
171
ui/actions/resources/resources.test.ts
Normal file
@@ -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 /",
|
||||
"<script>alert(1)</script>",
|
||||
"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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -132,6 +132,10 @@ vi.mock("./delta-indicator", () => ({
|
||||
DeltaIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
|
||||
EventsTimeline: () => <div data-testid="events-timeline" />,
|
||||
}));
|
||||
|
||||
vi.mock("react-markdown", () => ({
|
||||
default: ({ children }: { children: string }) => <span>{children}</span>,
|
||||
}));
|
||||
|
||||
@@ -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 = ({
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="resources">Resources</TabsTrigger>
|
||||
<TabsTrigger value="scans">Scans</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{!attributes.muted && (
|
||||
@@ -470,6 +478,14 @@ export const FindingDetail = ({
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={finding.relationships?.resource?.id}
|
||||
isAwsProvider={providerDetails?.provider === "aws"}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 = ({
|
||||
<TabsTrigger value="findings">
|
||||
Findings {totalFindings > 0 && `(${totalFindings})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="events">Events</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
@@ -535,6 +542,14 @@ export const ResourceDetailContent = ({
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Events Tab */}
|
||||
<TabsContent value="events" className="flex flex-col gap-4">
|
||||
<EventsTimeline
|
||||
resourceId={resourceId}
|
||||
isAwsProvider={providerData.provider === "aws"}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
293
ui/components/shared/events-timeline/events-timeline.test.tsx
Normal file
293
ui/components/shared/events-timeline/events-timeline.test.tsx
Normal file
@@ -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(<EventsTimeline resourceId="resource-1" isAwsProvider={false} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Fetching CloudTrail events..."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders events after successful fetch", async () => {
|
||||
// Given
|
||||
getResourceEventsMock.mockResolvedValue({
|
||||
data: [mockEvent],
|
||||
});
|
||||
|
||||
// When
|
||||
render(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// 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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
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(<EventsTimeline resourceId="resource-1" isAwsProvider={true} />);
|
||||
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText("Failed to fetch events. Please try again."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
458
ui/components/shared/events-timeline/events-timeline.tsx
Normal file
458
ui/components/shared/events-timeline/events-timeline.tsx
Normal file
@@ -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<ResourceEventProps[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorStatus, setErrorStatus] = useState<number | null>(null);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(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 (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<div className="bg-bg-neutral-tertiary/50 rounded-full p-3">
|
||||
<Shield className="text-text-neutral-tertiary h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Events timeline is only available for AWS resources.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPending && !hasFetched) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
Fetching CloudTrail events...
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<Alert variant="error" className="max-w-sm">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
{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}
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={() => setRetryCount((c) => c + 1)}
|
||||
aria-label="Retry fetching CloudTrail events"
|
||||
className="mt-1"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Controls bar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<Checkbox
|
||||
checked={includeReadEvents}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeReadEvents(checked === true)
|
||||
}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
Include read events
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending && <Loader2 className="size-4 animate-spin" />}
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
{events.length} event{events.length !== 1 && "s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
{events.length === 0 && hasFetched ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12">
|
||||
<div className="bg-bg-neutral-tertiary/50 rounded-full p-3">
|
||||
<Clock className="text-text-neutral-tertiary h-6 w-6" />
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
No events found in the last 90 days.
|
||||
</p>
|
||||
<p className="text-text-neutral-tertiary max-w-xs text-center text-xs">
|
||||
CloudTrail events may take up to 15 minutes to appear after an
|
||||
action is performed.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative ml-3">
|
||||
{/* Timeline vertical line */}
|
||||
<div className="border-border-neutral-tertiary absolute top-0 bottom-0 left-0 w-px border-l" />
|
||||
|
||||
<div className="flex flex-col">
|
||||
{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 (
|
||||
<div key={event.id} className={cn(!isLast && "pb-1")}>
|
||||
{/* Timeline node + row */}
|
||||
<button
|
||||
onClick={() => toggleRow(event.id)}
|
||||
aria-expanded={isExpanded}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-4 py-2.5 pr-3 pl-6 text-left transition-colors",
|
||||
"hover:bg-bg-neutral-tertiary/30 rounded-r-lg",
|
||||
"focus-visible:ring-border-neutral-secondary/50 outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
)}
|
||||
>
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-[14px] left-0 -translate-x-1/2 rounded-full transition-all",
|
||||
isExpanded
|
||||
? "bg-button-primary h-3 w-3"
|
||||
: hasError
|
||||
? "border-border-error-primary bg-bg-fail-secondary h-2.5 w-2.5 border"
|
||||
: "border-border-neutral-tertiary bg-bg-neutral-primary h-2.5 w-2.5 border",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{/* Time */}
|
||||
<span className="text-text-neutral-tertiary w-[80px] shrink-0 text-xs tabular-nums">
|
||||
{formatShortDate(attrs.event_time)}
|
||||
</span>
|
||||
|
||||
{/* Event name */}
|
||||
<span
|
||||
className={cn(
|
||||
"text-text-neutral-primary shrink-0 text-sm font-medium",
|
||||
hasError && "text-text-error",
|
||||
)}
|
||||
>
|
||||
{attrs.event_name}
|
||||
</span>
|
||||
|
||||
{/* Source (dimmed) */}
|
||||
<span className="text-text-neutral-tertiary hidden truncate text-xs lg:inline">
|
||||
{attrs.event_source}
|
||||
</span>
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
{/* Actor */}
|
||||
<span className="text-text-neutral-tertiary hidden max-w-[200px] truncate text-right font-mono text-xs md:inline">
|
||||
{attrs.actor}
|
||||
</span>
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"text-text-neutral-tertiary h-4 w-4 shrink-0 transition-transform duration-200",
|
||||
isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expanded detail card */}
|
||||
{isExpanded && (
|
||||
<div className="relative mt-1 mb-2 ml-6">
|
||||
<Card
|
||||
variant="inner"
|
||||
padding="none"
|
||||
className="gap-0 overflow-hidden"
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className="bg-bg-neutral-tertiary/40 flex items-center justify-between px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Server className="text-text-neutral-tertiary h-4 w-4" />
|
||||
<span className="text-text-neutral-primary text-sm font-medium">
|
||||
{attrs.event_name}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary text-xs">
|
||||
via {attrs.event_source}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadEventJson(event);
|
||||
}}
|
||||
aria-label={`Download ${attrs.event_name} event as JSON`}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Detail rows */}
|
||||
<div className="divide-border-neutral-tertiary/50 divide-y px-5">
|
||||
<div className="py-2.5">
|
||||
<InfoField label="When" inline>
|
||||
{new Date(attrs.event_time).toLocaleString()}
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<InfoField label="Who" inline>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-neutral-primary text-xs">
|
||||
{attrs.actor}
|
||||
</span>
|
||||
<Badge variant="tag">{attrs.actor_type}</Badge>
|
||||
</div>
|
||||
</InfoField>
|
||||
</div>
|
||||
<div className="py-2.5">
|
||||
<InfoField label="From" inline>
|
||||
<span className="font-mono">
|
||||
{attrs.source_ip_address}
|
||||
</span>
|
||||
</InfoField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{hasError && (
|
||||
<Alert
|
||||
variant="error"
|
||||
className="rounded-none border-x-0 border-b-0"
|
||||
>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<AlertDescription>
|
||||
<span className="text-xs font-medium">
|
||||
{attrs.error_code}
|
||||
</span>
|
||||
{attrs.error_message && (
|
||||
<>
|
||||
<span className="text-text-error/30">
|
||||
{" "}
|
||||
|{" "}
|
||||
</span>
|
||||
<span className="text-text-error/70 text-xs">
|
||||
{attrs.error_message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* JSON payloads */}
|
||||
{(attrs.request_data || attrs.response_data) && (
|
||||
<div className="border-border-neutral-tertiary/50 flex flex-col gap-4 border-t p-5">
|
||||
{attrs.request_data && (
|
||||
<JsonBlock
|
||||
label="Request"
|
||||
data={attrs.request_data}
|
||||
/>
|
||||
)}
|
||||
{attrs.response_data && (
|
||||
<JsonBlock
|
||||
label="Response"
|
||||
data={attrs.response_data}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
const JsonBlock = ({
|
||||
label,
|
||||
data,
|
||||
}: {
|
||||
label: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const lineCount = json.split("\n").length;
|
||||
const isLong = lineCount > 8;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-text-neutral-tertiary text-xs font-medium">
|
||||
{label}
|
||||
</span>
|
||||
<CodeSnippet value={json} hideCode />
|
||||
</div>
|
||||
<div className="bg-bg-neutral-tertiary border-border-neutral-tertiary relative overflow-hidden rounded-md border">
|
||||
<pre
|
||||
className={cn(
|
||||
"minimal-scrollbar overflow-auto p-4 font-mono text-xs leading-relaxed",
|
||||
isLong && collapsed && "max-h-[180px]",
|
||||
)}
|
||||
>
|
||||
{json}
|
||||
</pre>
|
||||
{isLong && collapsed && (
|
||||
<div className="from-bg-neutral-tertiary/0 via-bg-neutral-tertiary to-bg-neutral-tertiary absolute inset-x-0 bottom-0 flex items-end justify-center bg-gradient-to-b pt-8 pb-2">
|
||||
<Button
|
||||
variant="link"
|
||||
size="link-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
Show all ({lineCount} lines)
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- 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)}`;
|
||||
};
|
||||
@@ -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<string, unknown> | null;
|
||||
response_data: Record<string, unknown> | null;
|
||||
error_code: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
export interface ResourceEventProps {
|
||||
type: "resource-events";
|
||||
id: string;
|
||||
attributes: ResourceEventAttributes;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user