feat(ui): Add CloudTrail Events tab to detail cards (#10320)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-03-17 09:45:29 +01:00
committed by GitHub
parent 6a4278ed4d
commit 712da2cf98
10 changed files with 1040 additions and 2 deletions

View File

@@ -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)
---

View File

@@ -3,5 +3,6 @@ export {
getLatestResources,
getMetadataInfo,
getResourceById,
getResourceEvents,
getResources,
} from "./resources";

View 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();
});
});

View File

@@ -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,
{

View File

@@ -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>,
}));

View File

@@ -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>
);

View File

@@ -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>
);

View 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();
});
});
});

View 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)}`;
};

View File

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