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:
@@ -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}
|
||||
|
||||
+4
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+337
@@ -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>
|
||||
);
|
||||
};
|
||||
+50
@@ -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;
|
||||
};
|
||||
+121
@@ -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;
|
||||
};
|
||||
+18
-53
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user