fix: resolve settings/usage chart crash and add ClickHouse usage event seeds (#19039)

## Summary

- **Fix settings/usage page crash**: The `GraphWidgetLineChart`
component used on `settings/usage` was crashing with "Instance id is not
provided and cannot be found in context" because it requires
`WidgetComponentInstanceContext` (for tooltip/crosshair component
states) which is only provided inside the widget system. Wraps the
standalone chart usages with the required context provider.
- **Avoid mounting `GraphWidgetLegend` when hidden**: The legend
component calls `useIsPageLayoutInEditMode()` which requires
`PageLayoutEditModeProviderContext` — another context only available
inside the widget system. Since the settings page passes
`showLegend={false}`, the fix conditionally unmounts the legend instead
of always mounting it with a `show` prop. Applied consistently across
all four chart types (line, bar, pie, gauge).
- **Add ClickHouse usage event seeds**: Generates ~400 realistic
`usageEvent` rows spanning the past 35 days with weighted user activity,
weekday/weekend patterns, and gradual ramp-up. Enables developers to see
the usage analytics page with data locally.

## Test plan

- [ ] Navigate to `settings/usage` — page should render without errors
- [ ] Verify the daily usage line chart displays correctly
- [ ] Navigate to a user detail page from the usage list
- [ ] Verify the user detail chart renders without errors
- [ ] Run `npx nx clickhouse:seed twenty-server` and confirm usage
events are seeded
- [ ] Verify chart legend still works correctly on dashboard widgets (no
regression)


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-29 07:31:36 +02:00
committed by GitHub
parent 6efd9ad26e
commit a51b5ed589
54 changed files with 2072 additions and 1049 deletions
@@ -1,129 +1,23 @@
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graph-widget-line-chart/components/GraphWidgetLineChart';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
import { Select } from '@/ui/input/components/Select';
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 { getOperationTypeLabel } from '@/settings/usage/utils/getOperationTypeLabel';
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
import { UsagePieChart } from '@/settings/usage/components/UsagePieChart';
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection';
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { Link } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { H2Title, IconSparkles } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useQuery } from '@apollo/client/react';
import { GetUsageAnalyticsDocument } from '~/generated-metadata/graphql';
import { formatDate } from '~/utils/date-utils';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledIconChevronRightContainer = styled.div`
color: ${themeCssVariables.font.color.tertiary};
`;
const StyledLineChartContainer = styled.div`
height: 200px;
width: 100%;
`;
const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs';
export const SettingsUsageAnalyticsSection = () => {
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
const [userPeriod, setUserPeriod] = useState<PeriodPreset>('30d');
const [userSearchTerm, setUserSearchTerm] = useState('');
const colorRegistry = createGraphColorRegistry(theme.color);
const periodOptions = getPeriodOptions();
const typeDates = getPeriodDates(typePeriod);
const dailyDates = getPeriodDates(dailyPeriod);
const userDates = getPeriodDates(userPeriod);
const { data: typeData, loading: typeLoading } = useQuery(
GetUsageAnalyticsDocument,
{ variables: { input: typeDates } },
);
const { data: dailyData, loading: dailyLoading } = useQuery(
GetUsageAnalyticsDocument,
{ variables: { input: dailyDates } },
);
const { data: userData, loading: userLoading } = useQuery(
GetUsageAnalyticsDocument,
{ variables: { input: userDates } },
);
const typeAnalytics = typeData?.getUsageAnalytics;
const dailyAnalytics = dailyData?.getUsageAnalytics;
const userAnalytics = userData?.getUsageAnalytics;
const usageByOperationType = typeAnalytics?.usageByOperationType ?? [];
const timeSeries = dailyAnalytics?.timeSeries ?? [];
const usageByUser = userAnalytics?.usageByUser ?? [];
const anyLoading = typeLoading || dailyLoading || userLoading;
if (anyLoading) {
return null;
}
const hasAnyData =
usageByOperationType.length > 0 ||
timeSeries.length > 0 ||
usageByUser.length > 0;
const totalCredits = usageByOperationType.reduce(
(sum, item) => sum + item.creditsUsed,
0,
);
const filteredUsageByUser = usageByUser.filter((item) => {
const search = normalizeSearchText(userSearchTerm);
const name = normalizeSearchText(item.label ?? item.key);
return name.includes(search);
});
const pieData = usageByOperationType.map((item, index) => ({
id: getOperationTypeLabel(item.key),
value: item.creditsUsed,
color: getColorSchemeByIndex(colorRegistry, index).solid,
}));
const lineData: LineChartSeriesWithColor[] = [
{
id: 'credits',
label: t`Credits`,
data: timeSeries.map((point) => ({
x: formatDate(point.date, 'MMM d'),
y: point.creditsUsed,
})),
},
];
if (!hasAnyData) {
if (!isClickHouseConfigured) {
return (
<Section>
<H2Title
@@ -132,8 +26,8 @@ export const SettingsUsageAnalyticsSection = () => {
/>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`No usage data`}
value={t`No credit consumption recorded yet.`}
label={t`ClickHouse Not Configured`}
value={t`Usage analytics requires ClickHouse. Contact your administrator.`}
/>
</SubscriptionInfoContainer>
</Section>
@@ -142,125 +36,39 @@ export const SettingsUsageAnalyticsSection = () => {
return (
<>
{usageByOperationType.length > 0 && (
<Section>
<H2Title
title={t`Usage by Type`}
description={t`${formatNumber(totalCredits)} credits`}
adornment={
<Select
dropdownId="usage-type-period"
value={typePeriod}
options={periodOptions}
onChange={setTypePeriod}
needIconCheck
selectSizeVariant="small"
/>
}
<UsageBreakdownPieSection
title={t`Usage by Type`}
breakdownField="operationType"
sectionId="usage-type"
/>
<UsageDailyChartSection
title={t`Daily Usage`}
description={t`Credit consumption over time.`}
chartId="usage-daily"
chartLabel={t`Credits`}
/>
<UsageByUserTableSection
title={t`Usage by User`}
description={t`Click a user to see their daily breakdown.`}
getDetailPath={(userWorkspaceId) =>
getSettingsPath(SettingsPath.UsageUserDetail, {
userWorkspaceId,
})
}
showAvatar
/>
<Section>
<Link
to={`${getSettingsPath(SettingsPath.AI)}#${SETTINGS_AI_TABS.TABS_IDS.USAGE}`}
style={{ textDecoration: 'none' }}
>
<Button
Icon={IconSparkles}
title={t`View AI usage breakdown`}
variant="secondary"
/>
<SubscriptionInfoContainer>
<UsagePieChart data={pieData} />
</SubscriptionInfoContainer>
</Section>
)}
{timeSeries.length > 0 && (
<Section>
<H2Title
title={t`Daily Usage`}
description={t`Credit consumption over time.`}
adornment={
<Select
dropdownId="usage-daily-period"
value={dailyPeriod}
options={periodOptions}
onChange={setDailyPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<SubscriptionInfoContainer>
<StyledLineChartContainer>
<GraphWidgetLineChart
id="usage-daily-line-chart"
data={lineData}
colorMode="automaticPalette"
showLegend={false}
enableArea
/>
</StyledLineChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
{usageByUser.length > 0 && (
<Section>
<H2Title
title={t`Usage by User`}
description={t`Click a user to see their daily breakdown.`}
adornment={
<Select
dropdownId="usage-user-period"
value={userPeriod}
options={periodOptions}
onChange={setUserPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search for a user...`}
value={userSearchTerm}
onChange={setUserSearchTerm}
/>
</StyledSearchInputContainer>
<Table>
<TableRow
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Credits`}</TableHeader>
<TableHeader />
</TableRow>
{filteredUsageByUser.map((item) => (
<TableRow
key={item.key}
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
to={getSettingsPath(SettingsPath.UsageUserDetail, {
userWorkspaceId: item.key,
})}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={themeCssVariables.spacing[2]}
>
<Avatar
type="rounded"
size="md"
placeholder={item.label ?? item.key}
placeholderColorSeed={item.key}
/>
{item.label ?? item.key}
</TableCell>
<TableCell align="right">
{formatNumber(item.creditsUsed)}
</TableCell>
<TableCell align="center">
<StyledIconChevronRightContainer>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconChevronRightContainer>
</TableCell>
</TableRow>
))}
</Table>
</Section>
)}
</Link>
</Section>
</>
);
};