From df6913008598c530682da2e856e0b45809747aee Mon Sep 17 00:00:00 2001 From: michal-zielonka <146939528+michal-zielonka@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:59:12 +0000 Subject: [PATCH] feat(OrganizationLogs): Add Logss view to the Organization Closes MPT-26313 --- .../Organizations.spec.mocks.tsx | 4 + .../organizations/Organizations.spec.tsx | 1 + .../features/organizations/Organizations.tsx | 6 + .../api/useOrganizationsApi.spec.tsx | 15 +++ .../organizations/api/useOrganizationsApi.tsx | 15 +++ .../details/DetailsContent.spec.tsx | 8 +- .../details/event-logs/EventLogs.spec.tsx | 31 +++++ .../details/event-logs/EventLogs.tsx | 18 +++ .../EventLogsGrid.config.spec.mocks.ts | 41 ++++++ .../event-logs/EventLogsGrid.config.spec.tsx | 79 +++++++++++ .../event-logs/EventLogsGrid.config.tsx | 38 ++++++ .../details/event-logs/EventLogsGrid.spec.tsx | 33 +++++ .../details/event-logs/EventLogsGrid.tsx | 11 ++ .../event-logs/hooks/useAsyncOptions.spec.tsx | 53 ++++++++ .../event-logs/hooks/useAsyncOptions.tsx | 17 +++ .../event-logs/hooks/useColumns.spec.tsx | 101 ++++++++++++++ .../details/event-logs/hooks/useColumns.tsx | 73 ++++++++++ .../event-logs/hooks/useFields.spec.tsx | 54 ++++++++ .../details/event-logs/hooks/useFields.tsx | 127 ++++++++++++++++++ .../event-logs/hooks/useViews.spec.tsx | 28 ++++ .../details/event-logs/hooks/useViews.tsx | 45 +++++++ .../hooks/useTopBarItems.spec.tsx | 19 ++- .../organizations/hooks/useTopBarItems.tsx | 1 + frontend/src/features/organizations/paths.ts | 1 + frontend/src/i18n/en.json | 98 +++++++++++++- 25 files changed, 906 insertions(+), 11 deletions(-) create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogs.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogs.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.mocks.ts create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogsGrid.config.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogsGrid.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/EventLogsGrid.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useAsyncOptions.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useColumns.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useColumns.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useFields.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useFields.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useViews.spec.tsx create mode 100644 frontend/src/features/organizations/details/event-logs/hooks/useViews.tsx diff --git a/frontend/src/features/organizations/Organizations.spec.mocks.tsx b/frontend/src/features/organizations/Organizations.spec.mocks.tsx index bda885f5..51c712d6 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 6a250c6e..b23b89cc 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 e6723d83..b55feb47 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 35b84c77..b895f731 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 aea5890e..9567256a 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 a589a429..2ce6d821 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 00000000..e0c254f8 --- /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 00000000..f27ca0ff --- /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 00000000..6f18367d --- /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 00000000..a80d6c8d --- /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 00000000..8f939ceb --- /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 00000000..a2b76535 --- /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 00000000..70ad0644 --- /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 00000000..d6b71de0 --- /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 00000000..67b687da --- /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 00000000..cface629 --- /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 00000000..e663ad1b --- /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 00000000..6705c7e9 --- /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 00000000..083e818f --- /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 00000000..ce085dde --- /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 00000000..92ca9e48 --- /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 4e3730ca..27b548e0 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 8a571928..7bf71286 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 948c69e7..a390a5bf 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 62af9fde..755f6fb3 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",