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>
</>
);
};
@@ -0,0 +1,101 @@
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { UsagePieChart } from '@/settings/usage/components/UsagePieChart';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { getOperationTypeLabel } from '@/settings/usage/utils/getOperationTypeLabel';
import { Select } from '@/ui/input/components/Select';
import { useContext } from 'react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { type UsageOperationType } from '~/generated-metadata/graphql';
type UsageBreakdownField = 'operationType' | 'model';
type UsageBreakdownPieSectionProps = {
title: string;
description?: string;
operationTypes?: UsageOperationType[];
userWorkspaceId?: string;
skip?: boolean;
breakdownField: UsageBreakdownField;
sectionId: string;
};
export const UsageBreakdownPieSection = ({
title,
description,
operationTypes,
userWorkspaceId,
skip,
breakdownField,
sectionId,
}: UsageBreakdownPieSectionProps) => {
const { theme } = useContext(ThemeContext);
const { formatUsageValue } = useUsageValueFormatter();
const colorRegistry = createGraphColorRegistry(theme.color);
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
useUsageAnalyticsData({
operationTypes,
userWorkspaceId,
skip,
});
if (isInitialLoading) {
return <UsageSectionSkeleton />;
}
if (!analytics) {
return null;
}
const breakdownData =
breakdownField === 'operationType'
? analytics.usageByOperationType
: analytics.usageByModel;
if (breakdownData.length === 0) {
return null;
}
const total = breakdownData.reduce((sum, item) => sum + item.creditsUsed, 0);
const formatLabel =
breakdownField === 'operationType'
? getOperationTypeLabel
: (key: string) => key;
const pieData = breakdownData.map((item, index) => ({
id: formatLabel(item.key),
value: item.creditsUsed,
color: getColorSchemeByIndex(colorRegistry, index).solid,
}));
const resolvedDescription = description ?? formatUsageValue(total);
return (
<Section>
<H2Title
title={title}
description={resolvedDescription}
adornment={
<Select
dropdownId={`${sectionId}-period`}
value={period}
options={periodOptions}
onChange={setPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<SubscriptionInfoContainer>
<UsagePieChart data={pieData} />
</SubscriptionInfoContainer>
</Section>
);
};
@@ -0,0 +1,142 @@
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
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 { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext, useState } from 'react';
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
import { SearchInput } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { type UsageOperationType } from '~/generated-metadata/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const StyledSearchInputContainer = styled.div`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledIconChevronRightContainer = styled.div`
color: ${themeCssVariables.font.color.tertiary};
`;
const GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
type UsageByUserTableSectionProps = {
title: string;
description: string;
operationTypes?: UsageOperationType[];
skip?: boolean;
getDetailPath: (userWorkspaceId: string) => string;
showAvatar?: boolean;
};
export const UsageByUserTableSection = ({
title,
description,
operationTypes,
skip,
getDetailPath,
showAvatar = false,
}: UsageByUserTableSectionProps) => {
const { theme } = useContext(ThemeContext);
const { formatUsageValue } = useUsageValueFormatter();
const [searchTerm, setSearchTerm] = useState('');
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
useUsageAnalyticsData({
operationTypes,
skip,
});
if (isInitialLoading) {
return <UsageSectionSkeleton />;
}
if (!analytics) {
return null;
}
const usageByUser = analytics.usageByUser;
if (usageByUser.length === 0) {
return null;
}
const filteredUsers = usageByUser.filter((item) => {
const search = normalizeSearchText(searchTerm);
const name = normalizeSearchText(item.label ?? item.key);
return name.includes(search);
});
return (
<Section>
<H2Title
title={title}
description={description}
adornment={
<Select
dropdownId={`${title.replace(/\s+/g, '-').toLowerCase()}-period`}
value={period}
options={periodOptions}
onChange={setPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<StyledSearchInputContainer>
<SearchInput
placeholder={t`Search for a user...`}
value={searchTerm}
onChange={setSearchTerm}
/>
</StyledSearchInputContainer>
<Table>
<TableRow gridTemplateColumns={GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Usage`}</TableHeader>
<TableHeader />
</TableRow>
{filteredUsers.map((item) => (
<TableRow
key={item.key}
gridTemplateColumns={GRID_TEMPLATE_COLUMNS}
to={getDetailPath(item.key)}
>
<TableCell
color={themeCssVariables.font.color.primary}
gap={showAvatar ? themeCssVariables.spacing[2] : undefined}
>
{showAvatar && (
<Avatar
type="rounded"
size="md"
placeholder={item.label ?? item.key}
placeholderColorSeed={item.key}
/>
)}
{item.label ?? item.key}
</TableCell>
<TableCell align="right">
{formatUsageValue(item.creditsUsed)}
</TableCell>
<TableCell align="center">
<StyledIconChevronRightContainer>
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
</StyledIconChevronRightContainer>
</TableCell>
</TableRow>
))}
</Table>
</Section>
);
};
@@ -0,0 +1,105 @@
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 { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { Select } from '@/ui/input/components/Select';
import { styled } from '@linaria/react';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import { type UsageOperationType } from '~/generated-metadata/graphql';
import { formatDate } from '~/utils/date-utils';
const StyledLineChartContainer = styled.div`
height: 200px;
width: 100%;
`;
type UsageDailyChartSectionProps = {
title: string;
description: string;
chartId: string;
chartLabel: string;
operationTypes?: UsageOperationType[];
userWorkspaceId?: string;
skip?: boolean;
};
export const UsageDailyChartSection = ({
title,
description,
chartId,
chartLabel,
operationTypes,
userWorkspaceId,
skip,
}: UsageDailyChartSectionProps) => {
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
useUsageAnalyticsData({
operationTypes,
userWorkspaceId,
skip,
});
if (isInitialLoading) {
return <UsageSectionSkeleton />;
}
if (!analytics) {
return null;
}
const timeSeries = userWorkspaceId
? (analytics.userDailyUsage?.dailyUsage ?? [])
: analytics.timeSeries;
if (timeSeries.length === 0) {
return null;
}
const lineData: LineChartSeriesWithColor[] = [
{
id: chartId,
label: chartLabel,
data: timeSeries.map((point) => ({
x: formatDate(point.date, 'MMM d'),
y: point.creditsUsed,
})),
},
];
return (
<Section>
<H2Title
title={title}
description={description}
adornment={
<Select
dropdownId={`${chartId}-period`}
value={period}
options={periodOptions}
onChange={setPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<SubscriptionInfoContainer>
<StyledLineChartContainer>
<WidgetComponentInstanceContext.Provider
value={{ instanceId: `${chartId}-line-chart` }}
>
<GraphWidgetLineChart
id={`${chartId}-line-chart`}
data={lineData}
colorMode="automaticPalette"
showLegend={false}
enableArea
/>
</WidgetComponentInstanceContext.Provider>
</StyledLineChartContainer>
</SubscriptionInfoContainer>
</Section>
);
};
@@ -1,10 +1,11 @@
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { ResponsivePie } from '@nivo/pie';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
type UsagePieChartDatum = {
id: string;
@@ -21,6 +22,35 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledTooltip = styled.div`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.light};
border-radius: ${themeCssVariables.border.radius.md};
box-shadow: ${themeCssVariables.boxShadow.strong};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding: ${themeCssVariables.spacing[2]};
`;
const StyledTooltipRow = styled.div`
align-items: center;
display: flex;
font-size: ${themeCssVariables.font.size.xs};
gap: ${themeCssVariables.spacing[2]};
`;
const StyledTooltipLabel = styled.span`
color: ${themeCssVariables.font.color.secondary};
font-weight: ${themeCssVariables.font.weight.medium};
`;
const StyledTooltipValue = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-weight: ${themeCssVariables.font.weight.semiBold};
white-space: nowrap;
`;
export const UsagePieChart = ({ data }: UsagePieChartProps) => {
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
@@ -44,7 +74,15 @@ export const UsagePieChart = ({ data }: UsagePieChartProps) => {
animate
motionConfig={CHART_MOTION_CONFIG}
tooltip={({ datum }) => (
<div>{`${String(datum.id)}: ${t`${formatNumber(datum.value)} credits`}`}</div>
<StyledTooltip>
<StyledTooltipRow>
<GraphWidgetLegendDot color={datum.color} />
<StyledTooltipLabel>{String(datum.id)}</StyledTooltipLabel>
<StyledTooltipValue>
{t`${formatNumber(datum.value)} credits`}
</StyledTooltipValue>
</StyledTooltipRow>
</StyledTooltip>
)}
/>
</StyledContainer>
@@ -0,0 +1,27 @@
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Section } from 'twenty-ui/layout';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const UsageSectionSkeleton = () => {
const { theme } = useContext(ThemeContext);
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Section>
<Skeleton width={160} height={16} />
<Skeleton
width="100%"
height={200}
borderRadius={8}
style={{ marginTop: 16 }}
/>
</Section>
</SkeletonTheme>
);
};
@@ -0,0 +1,6 @@
import { UsageOperationType } from '~/generated-metadata/graphql';
export const AI_OPERATION_TYPES: UsageOperationType[] = [
UsageOperationType.AI_CHAT_TOKEN,
UsageOperationType.AI_WORKFLOW_TOKEN,
];
@@ -12,6 +12,10 @@ export const GET_USAGE_ANALYTICS = gql`
key
creditsUsed
}
usageByModel {
key
creditsUsed
}
timeSeries {
date
creditsUsed
@@ -0,0 +1,51 @@
import { useQuery } from '@apollo/client/react';
import { useState } from 'react';
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
import {
GetUsageAnalyticsDocument,
type UsageOperationType,
} from '~/generated-metadata/graphql';
type UseUsageAnalyticsDataParams = {
operationTypes?: UsageOperationType[];
userWorkspaceId?: string;
skip?: boolean;
};
export const useUsageAnalyticsData = ({
operationTypes,
userWorkspaceId,
skip,
}: UseUsageAnalyticsDataParams) => {
const [period, setPeriod] = useState<PeriodPreset>('30d');
const periodDates = getPeriodDates(period);
const periodOptions = getPeriodOptions();
const { data, loading, previousData } = useQuery(GetUsageAnalyticsDocument, {
variables: {
input: {
...periodDates,
...(operationTypes ? { operationTypes } : {}),
...(userWorkspaceId ? { userWorkspaceId } : {}),
},
},
skip,
});
const effectiveData = data ?? previousData;
const analytics = effectiveData?.getUsageAnalytics;
const isInitialLoading = loading && !effectiveData;
return {
analytics,
loading,
isInitialLoading,
period,
setPeriod,
periodOptions,
};
};
@@ -0,0 +1,21 @@
import { billingState } from '@/client-config/states/billingState';
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const useUsageValueFormatter = () => {
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const { formatNumber } = useNumberFormat();
const formatUsageValue = (value: number): string => {
if (isBillingEnabled) {
return `${formatNumber(value)} credits`;
}
return `$${formatNumber(value, { decimals: 2 })}`;
};
const unitLabel = isBillingEnabled ? 'credits' : '$';
return { formatUsageValue, isBillingEnabled, unitLabel };
};
@@ -2,8 +2,10 @@ import { t } from '@lingui/core/macro';
export const getOperationTypeLabel = (key: string): string => {
switch (key) {
case 'AI_TOKEN':
case 'AI_CHAT_TOKEN':
return t`AI Chat`;
case 'AI_WORKFLOW_TOKEN':
return t`AI Workflow`;
case 'WORKFLOW_EXECUTION':
return t`Workflow Execution`;
case 'CODE_EXECUTION':