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:
+45
-237
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user