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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+101
@@ -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>
|
||||
);
|
||||
};
|
||||
+142
@@ -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>
|
||||
);
|
||||
};
|
||||
+105
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user