feat(settings): add Logs as a dedicated tab in General settings (#21180)

## What & why

The audit-log viewer lived as a full-screen page reachable only via a
"View Logs" button buried in the **Security** tab. This surfaces it as
the **third tab in General settings** (`General | Security | Logs`),
consistent with the other tabs.

## Changes

- **Relocated** the event-logs module
`pages/settings/security/event-logs/` → `modules/settings/event-logs/`
and render it as tab content instead of a `FullScreenContainer` page.
Dropped `SettingsPath.EventLogs`, its route, and the fullscreen handling
in favor of the `general#logs` hash tab.
- **Security tab:** removed the "View Logs" entry; kept the
log-retention setting there.
- **In-tab gating** (shown to users with the Security permission):
Enterprise upgrade card when not entitled, a clear "ClickHouse not
configured" placeholder otherwise (derived from client config), and the
query is skipped when disabled. Replaces a bespoke error component that
string-matched error messages with the shared `SettingsEmptyPlaceholder`
/ `SettingsEnterpriseFeatureGateCard`.
- **Layout:** boxed content column with the table selector + filters
grouped in a `Card` and the results table below, matching settings
conventions. Kept the existing fixed filters (page/event name, member,
period) rather than recreating the record-view filter chips (those are
tightly coupled to record/view context).

Frontend + `twenty-shared` only — no changes to the log query or data.

## Test plan

- [x] `npx nx typecheck twenty-front` and `npx nx lint twenty-front`
pass
- [x] Settings → General shows three tabs; Logs is the third; breadcrumb
stays "Workspace / General"
- [x] With Enterprise + ClickHouse: table selector, filters, refresh,
and the paginated table work
- [x] Non-Enterprise: Enterprise upgrade card shown; no failing query
fires
- [ ] Enterprise without ClickHouse: shows the "ClickHouse not
configured" placeholder
- [ ] Security tab still shows the log-retention setting and the "View
Logs" button is gone
- [ ] A user without the Security permission sees neither the Security
nor Logs tab
This commit is contained in:
Félix Malfait
2026-06-04 08:47:23 +02:00
committed by GitHub
parent e6614299c6
commit 2ac515894b
21 changed files with 358 additions and 408 deletions
@@ -435,14 +435,6 @@ const SettingsSecurityApprovedAccessDomain = lazy(() =>
),
);
const SettingsEventLogs = lazy(() =>
import('~/pages/settings/security/event-logs/SettingsEventLogs').then(
(module) => ({
default: module.SettingsEventLogs,
}),
),
);
const SettingsNewEmailingDomain = lazy(() =>
import('~/pages/settings/emailing-domains/SettingsNewEmailingDomain').then(
(module) => ({
@@ -934,7 +926,6 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.NewApprovedAccessDomain}
element={<SettingsSecurityApprovedAccessDomain />}
/>
<Route path={SettingsPath.EventLogs} element={<SettingsEventLogs />} />
</Route>
{isAdminPageEnabled && (
@@ -1,6 +1,6 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useRef, useState } from 'react';
import { useId, useRef, useState } from 'react';
import { Temporal } from 'temporal-polyfill';
import {
autoUpdate,
@@ -67,7 +67,8 @@ const StyledFloatingContainer = styled.div`
`;
export type SettingsDatePickerInputProps = {
label: string;
label?: string;
instanceId?: string;
value: Date | undefined;
onChange: (date: Date | undefined) => void;
placeholder?: string;
@@ -75,6 +76,7 @@ export type SettingsDatePickerInputProps = {
export const SettingsDatePickerInput = ({
label,
instanceId,
value,
onChange,
placeholder,
@@ -83,6 +85,9 @@ export const SettingsDatePickerInput = ({
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const { userTimezone } = useUserTimezone();
const generatedId = useId();
const pickerInstanceId = instanceId ?? label ?? generatedId;
const { refs, floatingStyles } = useFloating({
open: isOpen,
@@ -97,7 +102,7 @@ export const SettingsDatePickerInput = ({
useListenClickOutside({
refs: [containerRef],
listenerId: `settings-date-picker-${label}`,
listenerId: `settings-date-picker-${pickerInstanceId}`,
callback: handleClose,
enabled: isOpen,
excludedClickOutsideIds: [
@@ -145,7 +150,7 @@ export const SettingsDatePickerInput = ({
return (
<StyledInputContainer ref={containerRef}>
<StyledLabel>{label}</StyledLabel>
{label && <StyledLabel>{label}</StyledLabel>}
<StyledInput
ref={refs.setReference}
hasValue={isDefined(value)}
@@ -165,7 +170,7 @@ export const SettingsDatePickerInput = ({
>
<OverlayContainer>
<DateTimePicker
instanceId={`settings-date-picker-${label}`}
instanceId={`settings-date-picker-${pickerInstanceId}`}
date={zonedDateTime}
onChange={handleDateTimeChange}
onClose={handleDateTimeClose}
@@ -0,0 +1,4 @@
export {
SettingsDatePickerInput as EventLogDatePickerInput,
type SettingsDatePickerInputProps as EventLogDatePickerInputProps,
} from '@/settings/components/SettingsDatePickerInput';
@@ -0,0 +1,173 @@
import { currentWorkspaceMembersState } from '@/auth/states/currentWorkspaceMembersState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { IconBox, IconUser, useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EventLogTable } from '~/generated-metadata/graphql';
import { EventLogDatePickerInput } from './EventLogDatePickerInput';
type EventLogFiltersProps = {
table: EventLogTable;
value: EventLogFiltersState;
onChange: (filters: EventLogFiltersState) => void;
};
const StyledFiltersGrid = styled.div`
display: grid;
gap: ${themeCssVariables.spacing[3]} ${themeCssVariables.spacing[4]};
grid-template-columns: 1fr 1fr;
`;
const StyledFullWidthField = styled.div`
grid-column: 1 / -1;
`;
const StyledPeriodRow = styled.div`
display: grid;
gap: ${themeCssVariables.spacing[4]};
grid-template-columns: 1fr 1fr;
`;
export const EventLogFilters = ({
table,
value,
onChange,
}: EventLogFiltersProps) => {
const { t } = useLingui();
const currentWorkspaceMembers = useAtomStateValue(
currentWorkspaceMembersState,
);
const { objectMetadataItems } = useObjectMetadataItems();
const { getIcon } = useIcons();
const handleEventTypeChange = (eventType: string) => {
onChange({ ...value, eventType: eventType || undefined });
};
const handleUserWorkspaceChange = (userWorkspaceId: string | null) => {
onChange({ ...value, userWorkspaceId: userWorkspaceId || undefined });
};
const handleStartDateChange = (date: Date | undefined) => {
onChange({
...value,
dateRange: {
...value.dateRange,
start: date,
},
});
};
const handleEndDateChange = (date: Date | undefined) => {
onChange({
...value,
dateRange: {
...value.dateRange,
end: date,
},
});
};
const handleRecordIdChange = (recordId: string) => {
onChange({ ...value, recordId: recordId || undefined });
};
const handleObjectMetadataIdChange = (objectMetadataId: string | null) => {
onChange({ ...value, objectMetadataId: objectMetadataId || undefined });
};
const eventLabel =
table === EventLogTable.PAGEVIEW ? t`Page name` : t`Event type`;
const userWorkspaceOptions: SelectOption<string | null>[] = [
{ label: t`All Members`, value: null, Icon: IconUser },
...currentWorkspaceMembers
.filter((member) => member.userWorkspaceId)
.map((workspaceMember) => ({
label:
`${workspaceMember.name.firstName ?? ''} ${workspaceMember.name.lastName ?? ''}`.trim(),
value: workspaceMember.userWorkspaceId as string,
Icon: IconUser,
})),
];
const objectMetadataOptions: SelectOption<string | null>[] = [
{ label: t`All Objects`, value: null, Icon: IconBox },
...objectMetadataItems.map((item) => ({
label: item.labelPlural,
value: item.id,
Icon: getIcon(item.icon) ?? IconBox,
})),
];
return (
<StyledFiltersGrid>
<TextInput
label={eventLabel}
value={value.eventType ?? ''}
onChange={handleEventTypeChange}
placeholder={t`Filter by event`}
fullWidth
/>
<Select
dropdownId="event-log-user-workspace-filter"
label={t`Workspace members`}
value={value.userWorkspaceId ?? null}
options={userWorkspaceOptions}
onChange={handleUserWorkspaceChange}
fullWidth
withSearchInput
/>
<StyledFullWidthField>
<InputLabel>{t`Period`}</InputLabel>
<StyledPeriodRow>
<EventLogDatePickerInput
instanceId="event-log-start-date"
value={value.dateRange?.start}
onChange={handleStartDateChange}
placeholder={t`Start date`}
/>
<EventLogDatePickerInput
instanceId="event-log-end-date"
value={value.dateRange?.end}
onChange={handleEndDateChange}
placeholder={t`End date`}
/>
</StyledPeriodRow>
</StyledFullWidthField>
{table === EventLogTable.OBJECT_EVENT && (
<>
<Select
dropdownId="event-log-object-type-filter"
label={t`Object type`}
value={value.objectMetadataId ?? null}
options={objectMetadataOptions}
onChange={handleObjectMetadataIdChange}
fullWidth
withSearchInput
/>
<TextInput
label={t`Record ID`}
value={value.recordId ?? ''}
onChange={handleRecordIdChange}
placeholder={t`Filter by record ID`}
fullWidth
/>
</>
)}
</StyledFiltersGrid>
);
};
@@ -0,0 +1,66 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useRef, useState } from 'react';
import { JsonDisplay } from '@/ui/field/display/components/JsonDisplay';
import { ExpandedFieldDisplay } from '@/ui/layout/expandable-list/components/ExpandedFieldDisplay';
import { type JsonValue } from 'type-fest';
import { isDefined } from 'twenty-shared/utils';
import { isTwoFirstDepths, JsonTree } from 'twenty-ui/json-visualizer';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
type EventLogJsonCellProps = {
value: Record<string, unknown> | null | undefined;
};
const StyledJsonContainer = styled.div`
cursor: pointer;
`;
const StyledEmptyCell = styled.span`
color: ${themeCssVariables.font.color.tertiary};
`;
export const EventLogJsonCell = ({ value }: EventLogJsonCellProps) => {
const { copyToClipboard } = useCopyToClipboard();
const [isExpanded, setIsExpanded] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
if (!isDefined(value) || Object.keys(value).length === 0) {
return <StyledEmptyCell>-</StyledEmptyCell>;
}
const handleClick = () => {
setIsExpanded(true);
};
const handleClickOutside = () => {
setIsExpanded(false);
};
return (
<>
<StyledJsonContainer ref={anchorRef} onClick={handleClick}>
<JsonDisplay text={JSON.stringify(value)} />
</StyledJsonContainer>
{isExpanded && (
<ExpandedFieldDisplay
anchorElement={anchorRef.current ?? undefined}
onClickOutside={handleClickOutside}
>
<JsonTree
value={value as JsonValue}
shouldExpandNodeInitially={isTwoFirstDepths}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`Empty Object`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</ExpandedFieldDisplay>
)}
</>
);
};
@@ -0,0 +1,337 @@
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { styled } from '@linaria/react';
import { Trans, useLingui } from '@lingui/react/macro';
import { useCallback, useContext, useEffect, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import {
type EventLogRecord,
EventLogTable,
} from '~/generated-metadata/graphql';
import {
type ColumnConfig,
getColumnsForEventLogTable,
} from '@/settings/event-logs/utils/getColumnsForEventLogTable';
import { EventLogJsonCell } from '@/settings/event-logs/components/EventLogJsonCell';
type EventLogResultsTableProps = {
records: EventLogRecord[];
loading: boolean;
hasNextPage: boolean;
onLoadMore: () => void;
selectedTable: EventLogTable;
};
const StyledScrollWrapperContainer = styled.div`
height: 100%;
overflow: hidden;
`;
const StyledTableContainer = styled.div`
> div {
min-width: 100%;
}
`;
const StyledResizableHeaderContainer = styled.div<{ isResizing?: boolean }>`
position: relative;
user-select: ${({ isResizing }) => (isResizing ? 'none' : 'auto')};
`;
const StyledResizeHandle = styled.div<{ isResizing: boolean }>`
background: ${({ isResizing }) =>
isResizing ? themeCssVariables.color.blue : 'transparent'};
bottom: 0;
cursor: col-resize;
position: absolute;
right: 0;
top: 0;
width: 4px;
&:hover {
background: ${themeCssVariables.color.blue};
}
`;
const StyledLoadingMore = styled.div`
color: ${themeCssVariables.font.color.tertiary};
padding: ${themeCssVariables.spacing[4]};
text-align: center;
`;
const StyledSkeletonContainer = styled.div`
padding: ${themeCssVariables.spacing[2]};
`;
const StyledIntersectionObserver = styled.div`
height: 1px;
`;
const SKELETON_ROW_COUNT = 8;
const EVENT_LOG_SCROLL_WRAPPER_INSTANCE_ID = 'event-log-results-table';
const buildGridTemplateColumns = (
columns: ColumnConfig[],
widths: Record<string, number>,
): string => {
return columns
.map((col, index) => {
const isLastColumn = index === columns.length - 1;
const width = widths[col.id] ?? col.defaultWidth;
// oxlint-disable-next-line lingui/no-unlocalized-strings
return isLastColumn ? `minmax(${width}px, 1fr)` : `${width}px`;
})
.join(' ');
};
export const EventLogResultsTable = ({
records,
loading,
hasNextPage,
onLoadMore,
selectedTable,
}: EventLogResultsTableProps) => {
const { theme } = useContext(ThemeContext);
const { t } = useLingui();
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
const showApplicationLogColumns =
selectedTable === EventLogTable.APPLICATION_LOG;
const baseColumns = getColumnsForEventLogTable(selectedTable);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
);
const [resizingColumn, setResizingColumn] = useState<string | null>(null);
// Reset column widths when switching tables to avoid undefined widths for new columns
useEffect(() => {
setColumnWidths(
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
);
}, [selectedTable, baseColumns]);
const handleResizeStart = useCallback(
(columnId: string, event: React.PointerEvent) => {
event.preventDefault();
setResizingColumn(columnId);
const startX = event.clientX;
const column = baseColumns.find((col) => col.id === columnId);
const startWidth = columnWidths[columnId] ?? column?.defaultWidth ?? 100;
const handlePointerMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
const newWidth = Math.max(column?.minWidth ?? 50, startWidth + delta);
setColumnWidths((prev) => ({ ...prev, [columnId]: newWidth }));
};
const handlePointerUp = () => {
setResizingColumn(null);
document.removeEventListener('pointermove', handlePointerMove);
document.removeEventListener('pointerup', handlePointerUp);
};
document.addEventListener('pointermove', handlePointerMove);
document.addEventListener('pointerup', handlePointerUp);
},
[columnWidths, baseColumns],
);
const gridTemplateColumns = buildGridTemplateColumns(
baseColumns,
columnWidths,
);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement(
EVENT_LOG_SCROLL_WRAPPER_INSTANCE_ID,
);
const [shouldFetchMore, setShouldFetchMore] = useState(false);
const { ref: fetchMoreRef, inView } = useInView({
root: scrollWrapperHTMLElement,
rootMargin: '400px',
});
useEffect(() => {
if (inView && hasNextPage && !loading && !shouldFetchMore) {
setShouldFetchMore(true);
onLoadMore();
}
}, [inView, hasNextPage, loading, shouldFetchMore, onLoadMore]);
useEffect(() => {
if (!loading) {
setShouldFetchMore(false);
}
}, [loading]);
const isInitialLoading = loading && records.length === 0;
if (isInitialLoading) {
return (
<StyledScrollWrapperContainer>
<ScrollWrapper
componentInstanceId={EVENT_LOG_SCROLL_WRAPPER_INSTANCE_ID}
>
<StyledTableContainer>
<Table>
<TableRow gridTemplateColumns={gridTemplateColumns}>
{baseColumns.map((column) => (
<TableHeader key={column.id}>{t(column.label)}</TableHeader>
))}
</TableRow>
</Table>
</StyledTableContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
{Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
<Skeleton height={40} key={index} style={{ marginBottom: 4 }} />
))}
</StyledSkeletonContainer>
</SkeletonTheme>
</ScrollWrapper>
</StyledScrollWrapperContainer>
);
}
if (!loading && records.length === 0) {
return (
<SettingsEmptyPlaceholder>
<Trans>No event logs found</Trans>
</SettingsEmptyPlaceholder>
);
}
return (
<StyledScrollWrapperContainer>
<ScrollWrapper componentInstanceId={EVENT_LOG_SCROLL_WRAPPER_INSTANCE_ID}>
<StyledTableContainer>
<Table>
<TableRow gridTemplateColumns={gridTemplateColumns}>
{baseColumns.map((column) => (
<StyledResizableHeaderContainer
key={column.id}
isResizing={resizingColumn === column.id}
>
<TableHeader>{t(column.label)}</TableHeader>
<StyledResizeHandle
isResizing={resizingColumn === column.id}
onPointerDown={(event) =>
handleResizeStart(column.id, event)
}
/>
</StyledResizableHeaderContainer>
))}
</TableRow>
{records.map((record, index) => (
<TableRow
key={`${record.timestamp}-${record.event}-${index}`}
gridTemplateColumns={gridTemplateColumns}
>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.event}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{beautifyPastDateRelativeToNow(record.timestamp)}
</TableCell>
{showApplicationLogColumns ? (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.level ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.message ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.properties?.executionId ?? '-'}
</TableCell>
</>
) : (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.userId ?? '-'}
</TableCell>
{showObjectEventColumns && (
<>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.recordId ?? '-'}
</TableCell>
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{record.objectMetadataId ?? '-'}
</TableCell>
</>
)}
<TableCell
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
<EventLogJsonCell value={record.properties} />
</TableCell>
</>
)}
</TableRow>
))}
</Table>
</StyledTableContainer>
<StyledIntersectionObserver ref={fetchMoreRef} />
{loading && records.length > 0 && (
<StyledLoadingMore>
<Trans>Loading more...</Trans>
</StyledLoadingMore>
)}
</ScrollWrapper>
</StyledScrollWrapperContainer>
);
};
@@ -0,0 +1,50 @@
import { useLingui } from '@lingui/react/macro';
import { Select } from '@/ui/input/components/Select';
import { EventLogTable } from '~/generated-metadata/graphql';
type EventLogTableSelectorProps = {
value: EventLogTable;
onChange: (value: EventLogTable) => void;
};
export const EventLogTableSelector = ({
value,
onChange,
}: EventLogTableSelectorProps) => {
const { t } = useLingui();
const options = [
{
value: EventLogTable.PAGEVIEW,
label: t`Page Views`,
},
{
value: EventLogTable.WORKSPACE_EVENT,
label: t`Workspace Events`,
},
{
value: EventLogTable.OBJECT_EVENT,
label: t`Object Events`,
},
{
value: EventLogTable.USAGE_EVENT,
label: t`Usage Events`,
},
{
value: EventLogTable.APPLICATION_LOG,
label: t`Application Logs`,
},
];
return (
<Select
dropdownId="event-log-table-selector"
label={t`Table`}
fullWidth
value={value}
options={options}
onChange={onChange}
/>
);
};
@@ -0,0 +1,194 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsEmptyPlaceholder } from '@/settings/components/SettingsEmptyPlaceholder';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { EventLogFilters } from '@/settings/event-logs/components/EventLogFilters';
import { EventLogResultsTable } from '@/settings/event-logs/components/EventLogResultsTable';
import { EventLogTableSelector } from '@/settings/event-logs/components/EventLogTableSelector';
import { useEventLogs } from '@/settings/event-logs/hooks/useQueryEventLogs';
import { type EventLogFiltersState } from '@/settings/event-logs/types/EventLogFiltersState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined } from 'twenty-shared/utils';
import { IconRefresh } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { EventLogTable } from '~/generated-metadata/graphql';
const StyledCardContent = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[4]};
padding: ${themeCssVariables.spacing[4]};
`;
const StyledSelectorRow = styled.div`
align-items: flex-end;
display: flex;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledSelectorGrow = styled.div`
flex: 1;
min-width: 0;
`;
const StyledResults = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
`;
const StyledRecordCount = styled.span`
align-self: flex-end;
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.sm};
`;
// The results table scrolls internally and loads more as you reach the bottom,
// so it needs a bounded height.
const StyledTableWrapper = styled.div`
height: 480px;
overflow: hidden;
`;
const RECORDS_PER_PAGE = 100;
export const SettingsLogs = () => {
const { t } = useLingui();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const hasEnterpriseAccess =
currentWorkspace?.hasValidSignedEnterpriseKey === true;
const [selectedTable, setSelectedTable] = useState<EventLogTable>(
EventLogTable.PAGEVIEW,
);
const [filters, setFilters] = useState<EventLogFiltersState>({});
const isApplicationLog = selectedTable === EventLogTable.APPLICATION_LOG;
const canQuery =
isClickHouseConfigured && (isApplicationLog || hasEnterpriseAccess);
const {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
} = useEventLogs(
{
table: selectedTable,
filters: {
eventType: filters.eventType,
userWorkspaceId: filters.userWorkspaceId,
dateRange: filters.dateRange
? {
start: filters.dateRange.start?.toISOString(),
end: filters.dateRange.end?.toISOString(),
}
: undefined,
recordId: filters.recordId,
objectMetadataId: filters.objectMetadataId,
},
first: RECORDS_PER_PAGE,
},
{ skip: !canQuery },
);
const handleTableChange = (table: EventLogTable) => {
setSelectedTable(table);
setFilters({});
};
const handleFiltersChange = (newFilters: EventLogFiltersState) => {
setFilters(newFilters);
};
const renderResults = () => {
if (!isApplicationLog && !hasEnterpriseAccess) {
return (
<SettingsEnterpriseFeatureGateCard
title={t`Enterprise feature`}
description={t`Upgrade to Enterprise to access this log type.`}
buttonTitle={t`Activate`}
/>
);
}
if (!isClickHouseConfigured) {
return (
<SettingsEmptyPlaceholder>
{t`Audit logs require ClickHouse to be configured. Please contact your administrator.`}
</SettingsEmptyPlaceholder>
);
}
if (isDefined(error)) {
return (
<SettingsEmptyPlaceholder>
{t`Something went wrong while loading audit logs. Please try again.`}
</SettingsEmptyPlaceholder>
);
}
return (
<StyledResults>
<StyledRecordCount>{t`${records.length} of ${totalCount}`}</StyledRecordCount>
<StyledTableWrapper>
<EventLogResultsTable
records={records}
loading={loading}
hasNextPage={hasNextPage}
onLoadMore={loadMore}
selectedTable={selectedTable}
/>
</StyledTableWrapper>
</StyledResults>
);
};
return (
<>
<Card rounded fullWidth>
<StyledCardContent>
<StyledSelectorRow>
<StyledSelectorGrow>
<EventLogTableSelector
value={selectedTable}
onChange={handleTableChange}
/>
</StyledSelectorGrow>
<IconButton
Icon={IconRefresh}
variant="secondary"
size="medium"
ariaLabel={t`Refresh`}
onClick={() => {
if (canQuery) {
void refetch();
}
}}
/>
</StyledSelectorRow>
<EventLogFilters
table={selectedTable}
value={filters}
onChange={handleFiltersChange}
/>
</StyledCardContent>
</Card>
{renderResults()}
</>
);
};
@@ -0,0 +1,22 @@
import { gql } from '@apollo/client';
export const GET_EVENT_LOGS = gql`
query EventLogs($input: EventLogQueryInput!) {
eventLogs(input: $input) {
records {
event
timestamp
userId
properties
recordId
objectMetadataId
isCustom
}
totalCount
pageInfo {
endCursor
hasNextPage
}
}
}
`;
@@ -0,0 +1,76 @@
import { isDefined } from 'twenty-shared/utils';
import { useQuery } from '@apollo/client/react';
import {
type EventLogQueryInput,
type EventLogQueryResult,
type EventLogRecord,
} from '~/generated-metadata/graphql';
import { GET_EVENT_LOGS } from '@/settings/event-logs/graphql/queries/getEventLogs';
type EventLogsData = {
eventLogs: EventLogQueryResult;
};
type EventLogsVariables = {
input: EventLogQueryInput;
};
export const useEventLogs = (
input: EventLogQueryInput,
options?: { skip?: boolean },
) => {
const { data, loading, error, refetch, fetchMore } = useQuery<
EventLogsData,
EventLogsVariables
>(GET_EVENT_LOGS, {
variables: { input },
fetchPolicy: 'network-only',
skip: options?.skip,
});
const records = data?.eventLogs.records ?? ([] as EventLogRecord[]);
const totalCount = data?.eventLogs.totalCount ?? 0;
const endCursor = data?.eventLogs.pageInfo.endCursor;
const hasNextPage = data?.eventLogs.pageInfo.hasNextPage ?? false;
const loadMore = () => {
if (!hasNextPage || loading || !endCursor) {
return;
}
fetchMore({
variables: {
input: {
...input,
after: endCursor,
},
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!isDefined(fetchMoreResult)) {
return previousResult;
}
return {
eventLogs: {
...fetchMoreResult.eventLogs,
records: [
...previousResult.eventLogs.records,
...fetchMoreResult.eventLogs.records,
],
},
};
},
});
};
return {
records,
totalCount,
hasNextPage,
loading,
error,
refetch,
loadMore,
};
};
@@ -0,0 +1,10 @@
export type EventLogFiltersState = {
eventType?: string;
userWorkspaceId?: string;
dateRange?: {
start?: Date;
end?: Date;
};
recordId?: string;
objectMetadataId?: string;
};
@@ -0,0 +1,121 @@
import { msg } from '@lingui/core/macro';
import { type MessageDescriptor } from '@lingui/core';
import { EventLogTable } from '~/generated-metadata/graphql';
export type ColumnConfig = {
id: string;
label: MessageDescriptor;
minWidth: number;
defaultWidth: number;
};
const DEFAULT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 200 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 150,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 150 },
{
id: 'properties',
label: msg`Properties`,
minWidth: 200,
defaultWidth: 400,
},
];
const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
{ id: 'event', label: msg`Event`, minWidth: 100, defaultWidth: 180 },
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 130,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'recordId',
label: msg`Record ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'objectMetadataId',
label: msg`Object ID`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'properties',
label: msg`Properties`,
minWidth: 150,
defaultWidth: 300,
},
];
const USAGE_EVENT_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Resource Type`,
minWidth: 100,
defaultWidth: 130,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
{
id: 'properties',
label: msg`Details`,
minWidth: 200,
defaultWidth: 400,
},
];
const APPLICATION_LOG_COLUMNS: ColumnConfig[] = [
{
id: 'event',
label: msg`Function`,
minWidth: 100,
defaultWidth: 160,
},
{
id: 'timestamp',
label: msg`Timestamp`,
minWidth: 100,
defaultWidth: 140,
},
{ id: 'level', label: msg`Level`, minWidth: 60, defaultWidth: 80 },
{
id: 'message',
label: msg`Message`,
minWidth: 200,
defaultWidth: 400,
},
{
id: 'executionId',
label: msg`Execution ID`,
minWidth: 100,
defaultWidth: 140,
},
];
const COLUMNS_BY_TABLE: Record<EventLogTable, ColumnConfig[]> = {
[EventLogTable.OBJECT_EVENT]: OBJECT_EVENT_COLUMNS,
[EventLogTable.USAGE_EVENT]: USAGE_EVENT_COLUMNS,
[EventLogTable.APPLICATION_LOG]: APPLICATION_LOG_COLUMNS,
[EventLogTable.WORKSPACE_EVENT]: DEFAULT_COLUMNS,
[EventLogTable.PAGEVIEW]: DEFAULT_COLUMNS,
};
export const getColumnsForEventLogTable = (
table: EventLogTable,
): ColumnConfig[] => {
return COLUMNS_BY_TABLE[table] ?? DEFAULT_COLUMNS;
};
@@ -1,6 +1,5 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { Link } from 'react-router-dom';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@@ -26,8 +25,6 @@ import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CombinedGraphQLErrors } from '@apollo/client/errors';
import { useMutation } from '@apollo/client/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import {
H2Title,
@@ -37,7 +34,6 @@ import {
IconMail,
IconTrash,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
@@ -57,16 +53,6 @@ const StyledSectionContainer = styled.div`
flex-shrink: 0;
`;
const StyledLinkContainer = styled.div`
> a {
text-decoration: none;
&[data-disabled='true'] {
pointer-events: none;
}
}
`;
export const SettingsSecuritySettings = () => {
const { t } = useLingui();
const { enqueueErrorSnackBar } = useSnackBar();
@@ -259,7 +245,7 @@ export const SettingsSecuritySettings = () => {
<Section>
<H2Title
title={t`Audit Logs`}
description={t`View workspace activity logs`}
description={t`Configure how long audit logs are retained`}
adornment={
<Tag
text={t`Enterprise`}
@@ -271,44 +257,23 @@ export const SettingsSecuritySettings = () => {
/>
{hasEnterpriseAccess ? (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconHistory}
title={t`Workspace Events`}
description={
!isClickHouseConfigured
? t`ClickHouse is required for audit logs. Contact your administrator.`
: t`View and filter events, page views, object changes`
}
Button={
<StyledLinkContainer>
<Link
to={getSettingsPath(SettingsPath.EventLogs)}
data-disabled={!isEventLogsEnabled}
>
<Button
title={t`View Logs`}
variant="secondary"
size="small"
disabled={!isEventLogsEnabled}
/>
</Link>
</StyledLinkContainer>
}
/>
{isEventLogsEnabled && (
<>
<Separator />
<SettingsOptionCardContentCounter
Icon={IconClockHour8}
title={t`Log retention`}
description={t`Number of days to retain audit logs (30-1095 days)`}
value={currentWorkspace?.eventLogRetentionDays ?? 90}
onChange={handleEventLogRetentionDaysChange}
minValue={30}
maxValue={1095}
showButtons={false}
/>
</>
{isEventLogsEnabled ? (
<SettingsOptionCardContentCounter
Icon={IconClockHour8}
title={t`Log retention`}
description={t`Number of days to retain audit logs (30-1095 days)`}
value={currentWorkspace?.eventLogRetentionDays ?? 90}
onChange={handleEventLogRetentionDaysChange}
minValue={30}
maxValue={1095}
showButtons={false}
/>
) : (
<SettingsOptionCardContentButton
Icon={IconHistory}
title={t`Audit Logs`}
description={t`ClickHouse is required for audit logs. Contact your administrator.`}
/>
)}
</Card>
) : (
@@ -13,11 +13,7 @@ export const useShowFullscreen = () => {
location,
'settings/' + SettingsPath.RestPlayground + '/*',
) ||
isMatchingLocation(
location,
'settings/' + SettingsPath.GraphQLPlayground,
) ||
isMatchingLocation(location, 'settings/' + SettingsPath.EventLogs)
isMatchingLocation(location, 'settings/' + SettingsPath.GraphQLPlayground)
) {
return true;
}