diff --git a/frontend/src/features/organizations/Organizations.spec.mocks.tsx b/frontend/src/features/organizations/Organizations.spec.mocks.tsx
index bda885f..51c712d 100644
--- a/frontend/src/features/organizations/Organizations.spec.mocks.tsx
+++ b/frontend/src/features/organizations/Organizations.spec.mocks.tsx
@@ -34,6 +34,10 @@ jest.mock("~features/organizations/details/events/Events", () => ({
OrganizationEventsDetails: () =>
,
}));
+jest.mock("~features/organizations/details/event-logs/EventLogs", () => ({
+ OrganizationLogsDetails: () => ,
+}));
+
jest.mock("~features/organizations/details/data-source/DataSourceDetails", () => ({
DataSourceDetails: () => ,
}));
diff --git a/frontend/src/features/organizations/Organizations.spec.tsx b/frontend/src/features/organizations/Organizations.spec.tsx
index 6a250c6..b23b89c 100644
--- a/frontend/src/features/organizations/Organizations.spec.tsx
+++ b/frontend/src/features/organizations/Organizations.spec.tsx
@@ -40,6 +40,7 @@ describe("Organizations router", () => {
["data sources", "/organizations/org-1/data-sources", "data-sources"],
["users", "/organizations/org-1/users", "users"],
["events", "/organizations/org-1/events", "events"],
+ ["logs", "/organizations/org-1/logs", "event-logs"],
[
"data source details",
"/organizations/org-1/data-sources/ds-1/details",
diff --git a/frontend/src/features/organizations/Organizations.tsx b/frontend/src/features/organizations/Organizations.tsx
index e6723d8..b55feb4 100644
--- a/frontend/src/features/organizations/Organizations.tsx
+++ b/frontend/src/features/organizations/Organizations.tsx
@@ -51,6 +51,11 @@ const DataSourceAdvancedDetails = lazy(() =>
default: m.DataSourceAdvancedDetails,
})),
);
+const OrganizationLogsDetails = lazy(() =>
+ import("~features/organizations/details/event-logs/EventLogs").then((m) => ({
+ default: m.OrganizationLogsDetails,
+ })),
+);
const allowedRoles = ["admin", "operations"] as const;
@@ -75,6 +80,7 @@ export function Organizations() {
} />
} />
} />
+ } />
diff --git a/frontend/src/features/organizations/api/useOrganizationsApi.spec.tsx b/frontend/src/features/organizations/api/useOrganizationsApi.spec.tsx
index 35b84c7..b895f73 100644
--- a/frontend/src/features/organizations/api/useOrganizationsApi.spec.tsx
+++ b/frontend/src/features/organizations/api/useOrganizationsApi.spec.tsx
@@ -97,6 +97,21 @@ describe("useOrganizationsApi", () => {
});
});
+ it("lists event logs for an organization using the provided query", async () => {
+ const { result } = renderHook(() => useOrganizationsApi());
+
+ const query = withQuery("level=INFO") as unknown as Parameters<
+ ReturnType["listOrganizationLogs"]
+ >[1];
+
+ await result.current.listOrganizationLogs("org-1", query);
+
+ expect(httpMock).toHaveBeenCalledWith({
+ method: "GET",
+ url: "/ops/v1/organizations/org-1/events?level=INFO",
+ });
+ });
+
it("requests a data-source reimport with the provided payload", async () => {
const { result } = renderHook(() => useOrganizationsApi());
const body = { last_import_at: "2026-03-15" };
diff --git a/frontend/src/features/organizations/api/useOrganizationsApi.tsx b/frontend/src/features/organizations/api/useOrganizationsApi.tsx
index aea5890..9567256 100644
--- a/frontend/src/features/organizations/api/useOrganizationsApi.tsx
+++ b/frontend/src/features/organizations/api/useOrganizationsApi.tsx
@@ -8,6 +8,7 @@ import {
DatasourceForceReimport,
DatasourceRead,
EmployeeRead,
+ OrganizationEventRead,
OrganizationRead,
} from "~api/ffc-api-model";
import { useEntityApi } from "~shared/hooks/useEntityApi";
@@ -54,6 +55,18 @@ export function useOrganizationsApi() {
[],
);
+ const listOrganizationLogs = useCallback(
+ (organizationId: string, query?: RqlQuery) => {
+ return http({
+ method: "GET",
+ url:
+ `${rootPath}/${organizationId}/events` +
+ getCustomQueryString(query),
+ });
+ },
+ [],
+ );
+
const forceReimportDatasource = useCallback(
(organizationId: string, datasourceId: string, data?: DatasourceForceReimport) => {
return http({
@@ -73,6 +86,7 @@ export function useOrganizationsApi() {
deleteOrganization,
listOrganizationEmployees,
listOrganizationDataSources,
+ listOrganizationLogs,
forceReimportDatasource,
}),
[
@@ -82,6 +96,7 @@ export function useOrganizationsApi() {
deleteOrganization,
listOrganizationEmployees,
listOrganizationDataSources,
+ listOrganizationLogs,
forceReimportDatasource,
],
);
diff --git a/frontend/src/features/organizations/details/DetailsContent.spec.tsx b/frontend/src/features/organizations/details/DetailsContent.spec.tsx
index a589a42..2ce6d82 100644
--- a/frontend/src/features/organizations/details/DetailsContent.spec.tsx
+++ b/frontend/src/features/organizations/details/DetailsContent.spec.tsx
@@ -135,7 +135,7 @@ describe("OrganizationDetailsContent", () => {
expect(screen.queryByTestId("organization-highlights")).not.toBeInTheDocument();
});
- it("renders the top bar with items for general, dataSources, users, and events in order", () => {
+ it("renders the organization tabs in the top bar in order", () => {
primeEntity({ id: "org-1" });
renderWithOrganizationRoute(, {
@@ -143,10 +143,10 @@ describe("OrganizationDetailsContent", () => {
});
const paths = mockTopBar.mock.lastCall![0].items!.map((item) => item.path);
- expect(paths).toEqual(["general", "data-sources", "users", "events"]);
+ expect(paths).toEqual(["general", "data-sources", "users", "events", "logs"]);
});
- it("shows the termination tab last when the organization has termination feedback", () => {
+ it("shows the termination tab before logs when the organization has termination feedback", () => {
primeEntity({ id: "org-1", termination_feedback: { id: "tf-1", reason: "other" } });
renderWithOrganizationRoute(, {
@@ -154,7 +154,7 @@ describe("OrganizationDetailsContent", () => {
});
const paths = mockTopBar.mock.lastCall![0].items!.map((item) => item.path);
- expect(paths).toEqual(["general", "data-sources", "users", "events", "termination"]);
+ expect(paths).toEqual(["general", "data-sources", "users", "events", "termination", "logs"]);
});
it.each([
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogs.spec.tsx b/frontend/src/features/organizations/details/event-logs/EventLogs.spec.tsx
new file mode 100644
index 0000000..e0c254f
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogs.spec.tsx
@@ -0,0 +1,31 @@
+import type { ComponentProps } from "react";
+
+import { screen } from "@testing-library/react";
+
+import { renderWithOrganizationRoute, renderWithRouter } from "~test-utils";
+
+import { OrganizationLogsDetails } from "./EventLogs";
+import type { EventLogsGrid } from "./EventLogsGrid";
+
+jest.mock("./EventLogsGrid", () => ({
+ EventLogsGrid: ({ organizationId }: ComponentProps) => (
+ {organizationId}
+ ),
+}));
+
+describe("OrganizationLogsDetails", () => {
+ it("renders the event logs grid for the organization from the route", () => {
+ renderWithOrganizationRoute(, { id: "org-123" });
+
+ expect(screen.getByTestId("event-logs-grid")).toHaveTextContent("org-123");
+ });
+
+ it("does not render the grid when the organizationId route param is missing", () => {
+ renderWithRouter(, {
+ initialUrl: "/organizations",
+ routePath: "/organizations",
+ });
+
+ expect(screen.queryByTestId("event-logs-grid")).not.toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogs.tsx b/frontend/src/features/organizations/details/event-logs/EventLogs.tsx
new file mode 100644
index 0000000..f27ca0f
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogs.tsx
@@ -0,0 +1,18 @@
+import { useParams } from "react-router-dom";
+
+import { CardSection } from "~shared/components/CardSection";
+import { useFixedT } from "~shared/hooks/useFixedT";
+
+import { EventLogsGrid } from "./EventLogsGrid";
+
+export function OrganizationLogsDetails() {
+ const { organizationId } = useParams();
+
+ const tSharedDetails = useFixedT("shared:details");
+
+ return (
+
+ {organizationId && }
+
+ );
+}
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.mocks.ts b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.mocks.ts
new file mode 100644
index 0000000..6f18367
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.mocks.ts
@@ -0,0 +1,41 @@
+import type { useGridInfoDialogConfiguration } from "~shared/hooks/useGridInfoDialogConfiguration";
+import { mockDesignSystemGrid } from "~test-utils/mocks/designSystemGrid";
+import {
+ mockGridIdentityModule,
+ mockGridInfoDialogConfigurationModule,
+} from "~test-utils/mocks/gridHooks";
+
+import type { useAsyncOptions } from "./hooks/useAsyncOptions";
+import type { useColumns } from "./hooks/useColumns";
+import type { useFields } from "./hooks/useFields";
+import type { useViews } from "./hooks/useViews";
+
+export { mockUseGridAsync } from "~test-utils/mocks/designSystemGrid";
+export {
+ mockUseGridIdentity,
+ mockUseGridInfoDialogConfiguration,
+} from "~test-utils/mocks/gridHooks";
+
+export const mockUseColumns = jest.fn() as jest.MockedFunction;
+export const mockUseFields = jest.fn() as jest.MockedFunction;
+export const mockUseViews = jest.fn() as jest.MockedFunction;
+// useAsyncOptions wraps the generic useReactQueryRqlGrid — keep the spy loosely typed.
+export const mockUseAsyncOptions = jest.fn() as jest.MockedFunction<
+ (organizationId: Parameters[0]) => unknown
+>;
+
+export type GridInfoDialogConfig = ReturnType;
+
+jest.mock("@swo/design-system/grid", () => mockDesignSystemGrid);
+jest.mock("~shared/hooks/useGridIdentity", () => mockGridIdentityModule);
+jest.mock(
+ "~shared/hooks/useGridInfoDialogConfiguration",
+ () => mockGridInfoDialogConfigurationModule,
+);
+
+jest.mock("./hooks/useColumns", () => ({ useColumns: () => mockUseColumns() }));
+jest.mock("./hooks/useFields", () => ({ useFields: () => mockUseFields() }));
+jest.mock("./hooks/useViews", () => ({ useViews: () => mockUseViews() }));
+jest.mock("./hooks/useAsyncOptions", () => ({
+ useAsyncOptions: (organizationId: string) => mockUseAsyncOptions(organizationId),
+}));
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.tsx b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.tsx
new file mode 100644
index 0000000..a80d6c8
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.tsx
@@ -0,0 +1,79 @@
+import { renderHook } from "@testing-library/react";
+
+import {
+ type GridInfoDialogConfig,
+ mockUseAsyncOptions,
+ mockUseColumns,
+ mockUseFields,
+ mockUseGridAsync,
+ mockUseGridIdentity,
+ mockUseGridInfoDialogConfiguration,
+ mockUseViews,
+} from "./EventLogsGrid.config.spec.mocks";
+
+import { useGridConfig } from "./EventLogsGrid.config";
+import type { useColumns } from "./hooks/useColumns";
+import type { useFields } from "./hooks/useFields";
+import type { useViews } from "./hooks/useViews";
+
+const columns = [{ name: "time" }] as unknown as ReturnType;
+const fields = [{ name: "time", title: "time" }] as ReturnType;
+const views = [{ name: "logInfo" }] as unknown as ReturnType;
+const silentRefresh = jest.fn();
+const refresh = jest.fn();
+
+function getLatestGridAsyncArg() {
+ return mockUseGridAsync.mock.lastCall![0];
+}
+
+describe("useGridConfig (organization event logs)", () => {
+ beforeEach(() => {
+ mockUseColumns.mockReturnValue(columns);
+ mockUseFields.mockReturnValue(fields);
+ mockUseViews.mockReturnValue(views);
+ mockUseAsyncOptions.mockReturnValue({ silentRefresh, refresh, onConfigChange: jest.fn() });
+ mockUseGridInfoDialogConfiguration.mockReturnValue({
+ noDataConfiguration: {},
+ } as GridInfoDialogConfig);
+ mockUseGridAsync.mockReturnValue({ onEvent: jest.fn() });
+ });
+
+ it("loads async options for the given organization", () => {
+ renderHook(() => useGridConfig("org-123"));
+
+ expect(mockUseAsyncOptions).toHaveBeenCalledWith("org-123");
+ });
+
+ it("uses a grid identity distinct from the users grid", () => {
+ renderHook(() => useGridConfig("org-123"));
+
+ expect(mockUseGridIdentity).toHaveBeenCalledWith("organizations-details-logs");
+ });
+
+ it("builds the grid from columns, fields, views, async options and info-dialog config", () => {
+ renderHook(() => useGridConfig("org-123"));
+
+ expect(getLatestGridAsyncArg()).toEqual(
+ expect.objectContaining({
+ id: "organizations-details-logs",
+ columns,
+ fields,
+ views,
+ isDefaultView: true,
+ selectedView: "default",
+ silentRefresh,
+ refresh,
+ noDataConfiguration: {},
+ }),
+ );
+ });
+
+ it("exposes silentRefresh and refresh alongside the useGridAsync result", () => {
+ const onEvent = jest.fn();
+ mockUseGridAsync.mockReturnValue({ onEvent });
+
+ const { result } = renderHook(() => useGridConfig("org-123"));
+
+ expect(result.current).toEqual(expect.objectContaining({ silentRefresh, refresh, onEvent }));
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.tsx b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.tsx
new file mode 100644
index 0000000..8f939ce
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.tsx
@@ -0,0 +1,38 @@
+import { useMemo } from "react";
+
+import { UseAsyncGridConfig, useGridAsync } from "@swo/design-system/grid";
+
+import { OrganizationEventRead } from "~api/ffc-api-model";
+import { useGridIdentity } from "~shared/hooks/useGridIdentity";
+import { useGridInfoDialogConfiguration } from "~shared/hooks/useGridInfoDialogConfiguration";
+
+import { useAsyncOptions } from "./hooks/useAsyncOptions";
+import { useColumns } from "./hooks/useColumns";
+import { useFields } from "./hooks/useFields";
+import { useViews } from "./hooks/useViews";
+
+export function useGridConfig(organizationId: string) {
+ const columns = useColumns();
+ const fields = useFields();
+ const views = useViews();
+ const asyncOptions = useAsyncOptions(organizationId);
+ const gridInfoDialogConfig = useGridInfoDialogConfiguration();
+ const identity = useGridIdentity("organizations-details-logs");
+ const config = useMemo(
+ () =>
+ ({
+ ...identity,
+ columns,
+ fields,
+ views,
+ isDefaultView: true,
+ selectedView: "default",
+ ...asyncOptions,
+ ...gridInfoDialogConfig,
+ }) as UseAsyncGridConfig,
+ [identity, columns, fields, views, asyncOptions, gridInfoDialogConfig],
+ );
+
+ const gridProps = useGridAsync(config);
+ return { silentRefresh: asyncOptions.silentRefresh, refresh: asyncOptions.refresh, ...gridProps };
+}
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogsGrid.spec.tsx b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.spec.tsx
new file mode 100644
index 0000000..a2b7653
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.spec.tsx
@@ -0,0 +1,33 @@
+import { render } from "@testing-library/react";
+
+import { mockDesignSystemGrid, mockGridProps } from "~test-utils/mocks/designSystemGrid";
+
+import { EventLogsGrid } from "./EventLogsGrid";
+import type { useGridConfig } from "./EventLogsGrid.config";
+
+const mockUseGridConfig = jest.fn() as jest.MockedFunction;
+
+jest.mock("@swo/design-system/grid", () => mockDesignSystemGrid);
+jest.mock("./EventLogsGrid.config", () => ({
+ useGridConfig: (...args: Parameters) => mockUseGridConfig(...args),
+}));
+
+describe("EventLogsGrid", () => {
+ beforeEach(() => {
+ mockUseGridConfig.mockReturnValue({ id: "event-logs-grid" } as unknown as ReturnType<
+ typeof useGridConfig
+ >);
+ });
+
+ it("configures the grid for the given organization", () => {
+ render();
+
+ expect(mockUseGridConfig).toHaveBeenCalledWith("org-123");
+ });
+
+ it("renders the grid with the configured props", () => {
+ render();
+
+ expect(mockGridProps).toHaveBeenCalledWith(expect.objectContaining({ id: "event-logs-grid" }));
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/EventLogsGrid.tsx b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.tsx
new file mode 100644
index 0000000..70ad064
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/EventLogsGrid.tsx
@@ -0,0 +1,11 @@
+import { Grid } from "@swo/design-system/grid";
+
+import { OrganizationEventRead } from "~api/ffc-api-model";
+
+import { useGridConfig } from "./EventLogsGrid.config";
+
+export function EventLogsGrid({ organizationId }: { organizationId: string }) {
+ const gridProps = useGridConfig(organizationId);
+
+ return {...gridProps} />;
+}
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.spec.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.spec.tsx
new file mode 100644
index 0000000..d6b71de
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.spec.tsx
@@ -0,0 +1,53 @@
+import { renderHook } from "@testing-library/react";
+
+import type { useOrganizationsApi } from "~features/organizations/api";
+import { mapAxiosResponseDataList } from "~shared/utils/mapAxiosResponseDataList";
+import { mockReactQueryRqlGridModule, mockUseReactQueryRqlGrid } from "~test-utils/mocks/gridHooks";
+
+import { useAsyncOptions } from "./useAsyncOptions";
+
+type OrganizationsApi = ReturnType;
+
+const mockListOrganizationLogs = jest.fn() as jest.MockedFunction<
+ OrganizationsApi["listOrganizationLogs"]
+>;
+
+jest.mock("~features/organizations/api", () => ({
+ useOrganizationsApi: () => ({ listOrganizationLogs: mockListOrganizationLogs }),
+}));
+
+jest.mock("~shared/hooks/useReactQueryRqlGrid", () => mockReactQueryRqlGridModule);
+
+function getLatestOptionsFactory() {
+ return mockUseReactQueryRqlGrid.mock.lastCall![1];
+}
+
+describe("useAsyncOptions (organization event logs)", () => {
+ it("initializes useReactQueryRqlGrid with an organization-scoped logs query key", () => {
+ renderHook(() => useAsyncOptions("org-123"));
+
+ expect(mockUseReactQueryRqlGrid).toHaveBeenCalledWith(
+ ["OrganizationLogs", "org-123"],
+ expect.any(Function),
+ );
+ });
+
+ it("builds query options that list the organization's logs", () => {
+ renderHook(() => useAsyncOptions("org-123"));
+ const query = { toString: () => "rql-string" };
+
+ const options = getLatestOptionsFactory()(query);
+ options.queryFn();
+
+ expect(options.queryKey).toEqual([["OrganizationLogs", "org-123"], "rql-string", "org-123"]);
+ expect(mockListOrganizationLogs).toHaveBeenCalledWith("org-123", query);
+ });
+
+ it("wires mapAxiosResponseDataList as the select transform", () => {
+ renderHook(() => useAsyncOptions("org-123"));
+
+ const options = getLatestOptionsFactory()({ toString: () => "rql" });
+
+ expect(options.select).toBe(mapAxiosResponseDataList);
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.tsx
new file mode 100644
index 0000000..67b687d
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.tsx
@@ -0,0 +1,17 @@
+import { OrganizationEventRead } from "~api/ffc-api-model";
+import { useOrganizationsApi } from "~features/organizations/api";
+import { useReactQueryRqlGrid } from "~shared/hooks/useReactQueryRqlGrid";
+import { mapAxiosResponseDataList } from "~shared/utils/mapAxiosResponseDataList";
+
+export function useAsyncOptions(organizationId: string) {
+ const { listOrganizationLogs } = useOrganizationsApi();
+ const baseQueryKey: unknown[] = ["OrganizationLogs", organizationId];
+ return useReactQueryRqlGrid<
+ OrganizationEventRead,
+ Awaited>
+ >(baseQueryKey, (query) => ({
+ queryKey: [baseQueryKey, query.toString(), organizationId],
+ queryFn: () => listOrganizationLogs(organizationId, query),
+ select: mapAxiosResponseDataList,
+ }));
+}
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useColumns.spec.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useColumns.spec.tsx
new file mode 100644
index 0000000..cface62
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useColumns.spec.tsx
@@ -0,0 +1,101 @@
+import { renderHook, screen } from "@testing-library/react";
+
+import type { OrganizationEventRead } from "~api/ffc-api-model";
+import { columnByName, renderColumnCell } from "~test-utils";
+import { mockDesignSystemGrid } from "~test-utils/mocks/designSystemGrid";
+import { mockEntityStatusChip } from "~test-utils/mocks/entityStatusChip";
+
+import { useColumns } from "./useColumns";
+
+jest.mock("@swo/design-system/grid", () => mockDesignSystemGrid);
+jest.mock("~shared/components/entity-status-chip", () => mockEntityStatusChip);
+
+const COLUMN_FIELDS = [
+ ["time", ["time"]],
+ ["level", ["level"]],
+ ["description", ["description"]],
+ ["evt_class", ["evt_class"]],
+ ["object_id", ["object_id"]],
+ ["object_name", ["object_name"]],
+] as const;
+
+function makeEvent(overrides: Partial = {}): OrganizationEventRead {
+ return {
+ id: "evt-1",
+ time: "2026-11-06T09:00:00Z",
+ level: "INFO",
+ evt_class: "ORGANIZATION_CREATED",
+ object_id: "obj-1",
+ object_name: "Acme",
+ description: "Organization created",
+ ack: false,
+ ...overrides,
+ };
+}
+
+function getColumns() {
+ return renderHook(() => useColumns()).result.current;
+}
+
+describe("useColumns (organization event logs)", () => {
+ it("returns columns in fixed order", () => {
+ expect(getColumns().map((c) => c.name)).toEqual(COLUMN_FIELDS.map(([name]) => name));
+ });
+
+ it.each(COLUMN_FIELDS)("column '%s' maps to fields %j", (name, expectedFields) => {
+ expect(columnByName(getColumns(), name).fields).toEqual(expectedFields);
+ });
+
+ it.each([
+ ["time", false],
+ ["level", false],
+ ["description", false],
+ ["evt_class", true],
+ ["object_id", true],
+ ["object_name", true],
+ ])("sets the initial visibility of the '%s' column", (name, isHidden) => {
+ expect(Boolean(columnByName(getColumns(), name).isHidden)).toBe(isHidden);
+ });
+
+ it("renders the time column as a date-time cell", () => {
+ renderColumnCell(getColumns(), "time", makeEvent());
+
+ expect(screen.getByTestId("grid-cell-date-time")).toHaveTextContent("2026-11-06T09:00:00Z");
+ });
+
+ it.each([
+ ["INFO", "info"],
+ ["WARNING", "warning"],
+ ["ERROR", "error"],
+ ["UNKNOWN", "unknown"],
+ ] as const)("renders level '%s' as a '%s' status chip", (level, status) => {
+ renderColumnCell(getColumns(), "level", makeEvent({ level }));
+
+ expect(screen.getByTestId("status")).toHaveTextContent(status);
+ });
+
+ it("renders the event class translated by its lower-cased key", () => {
+ renderColumnCell(getColumns(), "evt_class", makeEvent({ evt_class: "ORGANIZATION_CREATED" }));
+
+ expect(screen.getByTestId("grid-cell-simple")).toHaveTextContent("organization_created");
+ });
+
+ it.each([
+ ["description", "Organization created"],
+ ["object_id", "obj-1"],
+ ["object_name", "Acme"],
+ ])("renders the '%s' column as plain text", (name, expected) => {
+ renderColumnCell(getColumns(), name, makeEvent());
+
+ expect(screen.getByTestId("grid-cell-simple")).toHaveTextContent(expected);
+ });
+
+ it.each(["description", "object_id", "object_name"] as const)(
+ "renders an empty '%s' cell when the value is missing",
+ (name) => {
+ renderColumnCell(getColumns(), name, makeEvent({ [name]: null }));
+
+ expect(screen.getByTestId("grid-cell-simple")).toBeEmptyDOMElement();
+ },
+ );
+});
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useColumns.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useColumns.tsx
new file mode 100644
index 0000000..e663ad1
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useColumns.tsx
@@ -0,0 +1,73 @@
+import { useMemo } from "react";
+
+import { GridCellDateTime, GridCellSimple, GridColumnDefinition } from "@swo/design-system/grid";
+import { Paths } from "@swo/rql-client";
+
+import { OrganizationEventRead } from "~api/ffc-api-model";
+import { Status } from "~shared/components/entity-status-chip";
+import { useFixedT } from "~shared/hooks/useFixedT";
+
+type Columns = Array<
+ Omit, "fields"> & {
+ fields: Paths[];
+ }
+>;
+
+export function useColumns(): Columns {
+ const tColumns = useFixedT("shared:grid:columns");
+ const tEvtClass = useFixedT("shared:grid:evt_class");
+
+ return useMemo(() => {
+ return [
+ {
+ name: "time",
+ title: tColumns("time"),
+ fields: ["time"],
+ cell: (item: OrganizationEventRead) => ,
+ initialWidth: 200,
+ },
+ {
+ name: "level",
+ title: tColumns("level"),
+ fields: ["level"],
+ cell: (item: OrganizationEventRead) => (
+
+
+
+ ),
+
+ initialWidth: 150,
+ },
+ {
+ name: "description",
+ title: tColumns("description"),
+ fields: ["description"],
+ cell: (item: OrganizationEventRead) => {item.description},
+ },
+ {
+ name: "evt_class",
+ title: tColumns("evt_class"),
+ fields: ["evt_class"],
+ cell: (item: OrganizationEventRead) => (
+ {tEvtClass(item.evt_class.toLowerCase())}
+ ),
+ isHidden: true,
+ initialWidth: 250,
+ },
+ {
+ name: "object_id",
+ title: tColumns("object_id"),
+ fields: ["object_id"],
+ cell: (item: OrganizationEventRead) => {item.object_id},
+ isHidden: true,
+ },
+ {
+ name: "object_name",
+ title: tColumns("object_name"),
+ fields: ["object_name"],
+ cell: (item: OrganizationEventRead) => {item.object_name},
+ isHidden: true,
+ },
+ ];
+ }, [tColumns, tEvtClass]);
+}
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useFields.spec.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useFields.spec.tsx
new file mode 100644
index 0000000..6705c7e
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useFields.spec.tsx
@@ -0,0 +1,54 @@
+import { renderHook } from "@testing-library/react";
+
+import type { GridFieldDefinition } from "@swo/design-system/grid";
+
+import { useFields } from "./useFields";
+
+function getField(name: string) {
+ const { result } = renderHook(() => useFields());
+ return result.current.find((f) => f.name === name) as GridFieldDefinition;
+}
+
+describe("useFields (organization event logs)", () => {
+ it("returns fields in fixed order", () => {
+ const { result } = renderHook(() => useFields());
+
+ expect(result.current.map((f) => f.name)).toEqual([
+ "time",
+ "level",
+ "description",
+ "evt_class",
+ "object_id",
+ "object_name",
+ ]);
+ });
+
+ it("marks time as a date field", () => {
+ expect(getField("time")).toMatchObject({ type: "date" });
+ });
+
+ it("exposes level as a filterable list of INFO, WARNING and ERROR", () => {
+ const level = getField("level");
+
+ expect(level).toMatchObject({ type: "list", allowedOperations: ["Filter"] });
+ expect(level.options!.map((o) => o.value)).toEqual(["INFO", "WARNING", "ERROR"]);
+ });
+
+ it("exposes evt_class as a list of unique event classes labelled by their key", () => {
+ const evtClass = getField("evt_class");
+ const values = evtClass.options!.map((o) => o.value);
+
+ expect(evtClass).toMatchObject({ type: "list" });
+ expect(new Set(values).size).toBe(values.length);
+ expect(evtClass.options!.every((o) => o.label === o.value)).toBe(true);
+ });
+
+ it.each([
+ ["object_id", ["Filter"]],
+ ["description", []],
+ ["evt_class", []],
+ ["object_name", []],
+ ])("restricts '%s' to operations %j", (name, allowedOperations) => {
+ expect(getField(name).allowedOperations).toEqual(allowedOperations);
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useFields.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useFields.tsx
new file mode 100644
index 0000000..083e818
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useFields.tsx
@@ -0,0 +1,127 @@
+import { useMemo } from "react";
+
+import { GridFieldDefinition } from "@swo/design-system/grid";
+
+import { useFixedT } from "~shared/hooks/useFixedT";
+
+export function useFields() {
+ const tFields = useFixedT("shared:grid:fields");
+ const tLevel = useFixedT("organization:events:level");
+ const tEvtClass = useFixedT("shared:grid:evt_class");
+ return useMemo(
+ (): GridFieldDefinition[] => [
+ {
+ title: tFields("time"),
+ name: "time",
+ type: "date",
+ },
+ {
+ title: tFields("level"),
+ name: "level",
+ allowedOperations: ["Filter"],
+ type: "list",
+ options: [
+ { value: "INFO", label: tLevel("INFO") },
+ { value: "WARNING", label: tLevel("WARNING") },
+ { value: "ERROR", label: tLevel("ERROR") },
+ ],
+ },
+ {
+ title: tFields("description"),
+ name: "description",
+ allowedOperations: [],
+ },
+ {
+ title: tFields("evt_class"),
+ name: "evt_class",
+ allowedOperations: [],
+ type: "list",
+ options: [
+ { value: "katara_task_failed", label: tEvtClass("katara_task_failed") },
+ { value: "power_schedule_created", label: tEvtClass("power_schedule_created") },
+ { value: "power_schedule_updated", label: tEvtClass("power_schedule_updated") },
+ { value: "power_schedule_deleted", label: tEvtClass("power_schedule_deleted") },
+ { value: "power_schedule_processed", label: tEvtClass("power_schedule_processed") },
+ { value: "organization_created", label: tEvtClass("organization_created") },
+ { value: "organization_deleted", label: tEvtClass("organization_deleted") },
+ { value: "organization_updated", label: tEvtClass("organization_updated") },
+ { value: "organization_disabled", label: tEvtClass("organization_disabled") },
+ { value: "organization_enabled", label: tEvtClass("organization_enabled") },
+ { value: "resource_discovery_failed", label: tEvtClass("resource_discovery_failed") },
+ { value: "resources_discovered", label: tEvtClass("resources_discovered") },
+ {
+ value: "resources_clustered_discovered",
+ label: tEvtClass("resources_clustered_discovered"),
+ },
+ { value: "alert_added", label: tEvtClass("alert_added") },
+ { value: "alert_removed", label: tEvtClass("alert_removed") },
+ { value: "cloud_account_created", label: tEvtClass("cloud_account_created") },
+ { value: "cloud_account_updated", label: tEvtClass("cloud_account_updated") },
+ { value: "cloud_account_deleted", label: tEvtClass("cloud_account_deleted") },
+ { value: "cloud_account_warning", label: tEvtClass("cloud_account_warning") },
+ { value: "report_import_completed", label: tEvtClass("report_import_completed") },
+ { value: "report_import_failed", label: tEvtClass("report_import_failed") },
+ { value: "report_import_throttled", label: tEvtClass("report_import_throttled") },
+ { value: "assignment_request_accepted", label: tEvtClass("assignment_request_accepted") },
+ { value: "assignment_request_declined", label: tEvtClass("assignment_request_declined") },
+ { value: "root_assigned_resource", label: tEvtClass("root_assigned_resource") },
+ { value: "resource_assigned", label: tEvtClass("resource_assigned") },
+ { value: "rules_processing_started", label: tEvtClass("rules_processing_started") },
+ { value: "rules_processing_completed", label: tEvtClass("rules_processing_completed") },
+ { value: "rule_created", label: tEvtClass("rule_created") },
+ { value: "rule_updated", label: tEvtClass("rule_updated") },
+ { value: "rule_deleted", label: tEvtClass("rule_deleted") },
+ { value: "rule_applied", label: tEvtClass("rule_applied") },
+ { value: "rule_deactivated", label: tEvtClass("rule_deactivated") },
+ { value: "recommendations_dismissed", label: tEvtClass("recommendations_dismissed") },
+ { value: "recommendations_reactivated", label: tEvtClass("recommendations_reactivated") },
+ { value: "pool_created", label: tEvtClass("pool_created") },
+ { value: "pool_updated", label: tEvtClass("pool_updated") },
+ { value: "pool_deleted", label: tEvtClass("pool_deleted") },
+ { value: "policy_created", label: tEvtClass("policy_created") },
+ { value: "policy_updated", label: tEvtClass("policy_updated") },
+ { value: "policy_deleted", label: tEvtClass("policy_deleted") },
+ { value: "policy_enabled", label: tEvtClass("policy_enabled") },
+ { value: "policy_disabled", label: tEvtClass("policy_disabled") },
+ { value: "constraint_created", label: tEvtClass("constraint_created") },
+ { value: "constraint_updated", label: tEvtClass("constraint_updated") },
+ { value: "constraint_deleted", label: tEvtClass("constraint_deleted") },
+ { value: "employee_invited", label: tEvtClass("employee_invited") },
+ {
+ value: "cluster_types_processing_started",
+ label: tEvtClass("cluster_types_processing_started"),
+ },
+ {
+ value: "cluster_types_processing_done",
+ label: tEvtClass("cluster_types_processing_done"),
+ },
+ { value: "cluster_type_applied", label: tEvtClass("cluster_type_applied") },
+ { value: "cluster_resources_deleted", label: tEvtClass("cluster_resources_deleted") },
+ { value: "recalculation_started", label: tEvtClass("recalculation_started") },
+ { value: "recalculation_completed", label: tEvtClass("recalculation_completed") },
+ { value: "recalculation_failed", label: tEvtClass("recalculation_failed") },
+ { value: "shareable_resource_changed", label: tEvtClass("shareable_resource_changed") },
+ { value: "shareable_resource_deleted", label: tEvtClass("shareable_resource_deleted") },
+ { value: "shareable_booking_released", label: tEvtClass("shareable_booking_released") },
+ { value: "calendar_connected", label: tEvtClass("calendar_connected") },
+ { value: "calendar_disconnected", label: tEvtClass("calendar_disconnected") },
+ { value: "calendar_warning", label: tEvtClass("calendar_warning") },
+ { value: "calendar_observer_warning", label: tEvtClass("calendar_observer_warning") },
+ { value: "technical_audit_submit", label: tEvtClass("technical_audit_submit") },
+ { value: "env_power_mngmt", label: tEvtClass("env_power_mngmt") },
+ ],
+ },
+ {
+ title: tFields("object_id"),
+ allowedOperations: ["Filter"],
+ name: "object_id",
+ },
+ {
+ title: tFields("object_name"),
+ allowedOperations: [],
+ name: "object_name",
+ },
+ ],
+ [tFields, tLevel, tEvtClass],
+ );
+}
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useViews.spec.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useViews.spec.tsx
new file mode 100644
index 0000000..ce085dd
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useViews.spec.tsx
@@ -0,0 +1,28 @@
+import { renderHook } from "@testing-library/react";
+
+import { useViews } from "./useViews";
+
+describe("useViews (organization event logs)", () => {
+ it("returns info, warning and error views in fixed order", () => {
+ const { result } = renderHook(() => useViews());
+
+ expect(result.current.map((v) => v.name)).toEqual(["logInfo", "logWarning", "logError"]);
+ });
+
+ it.each([
+ ["logInfo", "INFO"],
+ ["logWarning", "WARNING"],
+ ["logError", "ERROR"],
+ ])("scopes the %s view to level '%s' sorted by newest first", (viewName, level) => {
+ const { result } = renderHook(() => useViews());
+ const view = result.current.find((v) => v.name === viewName)!;
+
+ expect(view.configuration).toEqual({
+ filters: {
+ operator: "and",
+ value: [{ operator: "eq", field: "level", value: level }],
+ },
+ sort: [{ field: "time", direction: "desc" }],
+ });
+ });
+});
diff --git a/frontend/src/features/organizations/details/event-logs/hooks/useViews.tsx b/frontend/src/features/organizations/details/event-logs/hooks/useViews.tsx
new file mode 100644
index 0000000..92ca9e4
--- /dev/null
+++ b/frontend/src/features/organizations/details/event-logs/hooks/useViews.tsx
@@ -0,0 +1,45 @@
+import { useMemo } from "react";
+
+import { useFixedT } from "~shared/hooks/useFixedT";
+
+export function useViews() {
+ const tView = useFixedT("shared:grid:views");
+
+ return useMemo(() => {
+ return [
+ {
+ name: "logInfo",
+ title: tView("logEventInfo"),
+ configuration: {
+ filters: {
+ operator: "and",
+ value: [{ operator: "eq", field: "level", value: "INFO" }],
+ },
+ sort: [{ field: "time", direction: "desc" }],
+ },
+ },
+ {
+ name: "logWarning",
+ title: tView("logEventWarning"),
+ configuration: {
+ filters: {
+ operator: "and",
+ value: [{ operator: "eq", field: "level", value: "WARNING" }],
+ },
+ sort: [{ field: "time", direction: "desc" }],
+ },
+ },
+ {
+ name: "logError",
+ title: tView("logEventError"),
+ configuration: {
+ filters: {
+ operator: "and",
+ value: [{ operator: "eq", field: "level", value: "ERROR" }],
+ },
+ sort: [{ field: "time", direction: "desc" }],
+ },
+ },
+ ];
+ }, [tView]);
+}
diff --git a/frontend/src/features/organizations/hooks/useTopBarItems.spec.tsx b/frontend/src/features/organizations/hooks/useTopBarItems.spec.tsx
index 4e3730c..27b548e 100644
--- a/frontend/src/features/organizations/hooks/useTopBarItems.spec.tsx
+++ b/frontend/src/features/organizations/hooks/useTopBarItems.spec.tsx
@@ -21,10 +21,16 @@ describe("useTopBarItems", () => {
it("returns the organization tabs without termination when there is no termination feedback", () => {
const items = getItems({ entity: { id: "org-1" } as OrganizationRead });
- expect(items.map((item) => item.path)).toEqual(["general", "data-sources", "users", "events"]);
+ expect(items.map((item) => item.path)).toEqual([
+ "general",
+ "data-sources",
+ "users",
+ "events",
+ "logs",
+ ]);
});
- it("appends the termination tab when the organization has termination feedback", () => {
+ it("inserts the termination tab before logs when the organization has termination feedback", () => {
const entity = {
id: "org-1",
termination_feedback: { id: "tf-1", reason: "other" },
@@ -32,7 +38,14 @@ describe("useTopBarItems", () => {
const items = getItems({ entity });
- expect(items.at(-1)?.path).toBe("termination");
+ expect(items.map((item) => item.path)).toEqual([
+ "general",
+ "data-sources",
+ "users",
+ "events",
+ "termination",
+ "logs",
+ ]);
});
it("ignores the organization's termination feedback when a routeId is given", () => {
diff --git a/frontend/src/features/organizations/hooks/useTopBarItems.tsx b/frontend/src/features/organizations/hooks/useTopBarItems.tsx
index 8a57192..7bf7128 100644
--- a/frontend/src/features/organizations/hooks/useTopBarItems.tsx
+++ b/frontend/src/features/organizations/hooks/useTopBarItems.tsx
@@ -38,6 +38,7 @@ export function useTopBarItems({
path: SEGMENTS.termination,
isHidden: !entity?.termination_feedback?.id,
},
+ { label: tDetails("logs:title"), path: SEGMENTS.logs },
].filter((item) => !item.isHidden),
[tDetails, entity?.termination_feedback?.id],
);
diff --git a/frontend/src/features/organizations/paths.ts b/frontend/src/features/organizations/paths.ts
index 948c69e..a390a5b 100644
--- a/frontend/src/features/organizations/paths.ts
+++ b/frontend/src/features/organizations/paths.ts
@@ -12,6 +12,7 @@ export const SEGMENTS = {
dataSources: "data-sources",
users: "users",
termination: "termination",
+ logs: "logs",
dataSourceDetails: "details",
dataSourceAdvanced: "advanced",
} as const;
diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json
index 62af9fd..755f6fb 100644
--- a/frontend/src/i18n/en.json
+++ b/frontend/src/i18n/en.json
@@ -160,6 +160,7 @@
},
"users": { "title": "Users" },
"events": { "title": "Events" },
+ "logs": { "title": "Logs" },
"termination": {
"title": "Termination",
"feedback_title": "Termination feedback",
@@ -218,6 +219,13 @@
"add_user": "Add user",
"cancel": "Cancel",
"save": "Save"
+ },
+ "events": {
+ "level": {
+ "INFO": "Info",
+ "WARNING": "Warning",
+ "ERROR": "Error"
+ }
}
},
"organizations": {
@@ -288,7 +296,10 @@
"inactiveEntitlements": "Inactive Entitlements",
"allEntitlements": "All Entitlements",
"mainEntitlements": "Default",
- "mainOrganizations": "Default"
+ "mainOrganizations": "Default",
+ "logEventInfo": "Info Logs",
+ "logEventWarning": "Warning Logs",
+ "logEventError": "Error Logs"
},
"columns": {
"actions": "Actions",
@@ -329,7 +340,13 @@
"deletable_at": "Deletable Since",
"client": "Client",
"resource_type": "Resource Type",
- "last_discovery_at": "Last discovery at"
+ "last_discovery_at": "Last discovery at",
+ "time": "Timestamp",
+ "evt_class": "Event Class",
+ "level": "Level",
+ "description": "Description",
+ "object_id": "Object ID",
+ "object_name": "Object Name"
},
"fields": {
"id": "ID",
@@ -364,7 +381,13 @@
"organization_name": "Organization Name",
"client_name": "Client Name",
"client_id": "Client ID",
- "client_external_id": "Client External ID"
+ "client_external_id": "Client External ID",
+ "time": "Timestamp",
+ "evt_class": "Event Class",
+ "level": "Level",
+ "description": "Description",
+ "object_id": "Object ID",
+ "object_name": "Object Name"
},
"dataSourceType": {
"azure_cnr": "Azure subscription",
@@ -383,6 +406,72 @@
"load_balancer": "Load Balancer",
"unknown": "Unknown Resource Type"
},
+ "evt_class": {
+ "katara_task_failed": "Report task failed",
+ "power_schedule_created": "Power schedule created",
+ "power_schedule_updated": "Power schedule updated",
+ "power_schedule_deleted": "Power schedule deleted",
+ "power_schedule_processed": "Power schedule processed",
+ "organization_created": "Organization created",
+ "organization_deleted": "Organization deleted",
+ "organization_updated": "Organization updated",
+ "organization_disabled": "Organization disabled",
+ "organization_enabled": "Organization enabled",
+ "resource_discovery_failed": "Resource discovery failed",
+ "resources_discovered": "Resources discovered",
+ "resources_clustered_discovered": "Clustered resources discovered",
+ "alert_added": "Alert added",
+ "alert_removed": "Alert removed",
+ "cloud_account_created": "Data source created",
+ "cloud_account_updated": "Data source updated",
+ "cloud_account_deleted": "Data source deleted",
+ "cloud_account_warning": "Data source warning",
+ "report_import_completed": "Billing report import completed",
+ "report_import_failed": "Billing report import failed",
+ "report_import_throttled": "Billing report import throttled",
+ "assignment_request_accepted": "Assignment request accepted",
+ "assignment_request_declined": "Assignment request declined",
+ "root_assigned_resource": "Resource assigned to root pool",
+ "resource_assigned": "Resource assigned",
+ "rules_processing_started": "Assignment rules processing started",
+ "rules_processing_completed": "Assignment rules processing completed",
+ "rule_created": "Assignment rule created",
+ "rule_updated": "Assignment rule updated",
+ "rule_deleted": "Assignment rule deleted",
+ "rule_applied": "Assignment rule applied",
+ "rule_deactivated": "Assignment rule deactivated",
+ "recommendations_dismissed": "Recommendations dismissed",
+ "recommendations_reactivated": "Recommendations reactivated",
+ "pool_created": "Pool created",
+ "pool_updated": "Pool updated",
+ "pool_deleted": "Pool deleted",
+ "policy_created": "Policy created",
+ "policy_updated": "Policy updated",
+ "policy_deleted": "Policy deleted",
+ "policy_enabled": "Policy enabled",
+ "policy_disabled": "Policy disabled",
+ "constraint_created": "Constraint created",
+ "constraint_updated": "Constraint updated",
+ "constraint_deleted": "Constraint deleted",
+ "employee_invited": "User invited",
+ "cluster_types_processing_started": "Cluster types processing started",
+ "cluster_types_processing_done": "Cluster types processing completed",
+ "cluster_type_applied": "Cluster type applied",
+ "cluster_resources_deleted": "Cluster resources deleted",
+ "recalculation_started": "Expenses recalculation started",
+ "recalculation_completed": "Expenses recalculation completed",
+ "recalculation_failed": "Expenses recalculation failed",
+ "shareable_resource_changed": "Shared resource changed",
+ "shareable_resource_deleted": "Shared resource deleted",
+ "shareable_booking_released": "Shared resource booking released",
+ "calendar_connected": "Calendar connected",
+ "calendar_disconnected": "Calendar disconnected",
+ "calendar_warning": "Calendar warning",
+ "calendar_observer_warning": "Calendar sync warning",
+ "technical_audit_submit": "Technical audit submitted",
+ "env_power_mngmt": "Environment power management",
+ "unknown": "Unknown"
+ },
"userTypes": { "admin": "Admin", "user": "User" },
"infoDialog": { "noData": { "description": "There is no data available to display." } },
"actions": { "add": "Add" }
@@ -399,7 +488,8 @@
"discovery": "Discovery",
"billing_import": "Billing import",
"monitoring_metrics": "Monitoring metrics",
- "noDataSource": "Data source information not available. Selected data source does not exist."
+ "noDataSource": "Data source information not available. Selected data source does not exist.",
+ "logs": "Event logs"
},
"properties": {
"linkedDataSource": "Linked data source ID",