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
@@ -1781,6 +1781,12 @@ type ClientConfig {
isClickHouseConfigured: Boolean!
}
type UsageBreakdownItem {
key: String!
label: String
creditsUsed: Float!
}
type ConfigVariable {
name: String!
description: String!
@@ -2380,6 +2386,26 @@ type Impersonate {
workspace: WorkspaceUrlsAndId!
}
type UsageTimeSeries {
date: String!
creditsUsed: Float!
}
type UsageUserDaily {
userWorkspaceId: String!
dailyUsage: [UsageTimeSeries!]!
}
type UsageAnalytics {
usageByUser: [UsageBreakdownItem!]!
usageByOperationType: [UsageBreakdownItem!]!
usageByModel: [UsageBreakdownItem!]!
timeSeries: [UsageTimeSeries!]!
periodStart: DateTime!
periodEnd: DateTime!
userDailyUsage: UsageUserDaily
}
type DevelopmentApplication {
id: String!
universalIdentifier: String!
@@ -2575,31 +2601,6 @@ type PostgresCredentials {
workspaceId: UUID!
}
type UsageBreakdownItem {
key: String!
label: String
creditsUsed: Float!
}
type UsageTimeSeries {
date: String!
creditsUsed: Float!
}
type UsageUserDaily {
userWorkspaceId: String!
dailyUsage: [UsageTimeSeries!]!
}
type UsageAnalytics {
usageByUser: [UsageBreakdownItem!]!
usageByOperationType: [UsageBreakdownItem!]!
timeSeries: [UsageTimeSeries!]!
periodStart: DateTime!
periodEnd: DateTime!
userDailyUsage: UsageUserDaily
}
type FrontComponent {
id: UUID!
name: String!
@@ -3274,6 +3275,8 @@ type Query {
getAiProviders: JSON!
getModelsDevProviders: [ModelsDevProviderSuggestion!]!
getModelsDevSuggestions(providerType: String!): [ModelsDevModelSuggestion!]!
getAdminAiUsageByWorkspace(periodStart: DateTime, periodEnd: DateTime): [UsageBreakdownItem!]!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
getPostgresCredentials: PostgresCredentials
findManyPublicDomains: [PublicDomain!]!
getEmailingDomains: [EmailingDomain!]!
@@ -3281,7 +3284,6 @@ type Query {
findOneMarketplaceApp(universalIdentifier: String!): MarketplaceApp!
findManyApplications: [Application!]!
findOneApplication(id: UUID, universalIdentifier: UUID): Application!
getUsageAnalytics(input: UsageAnalyticsInput): UsageAnalytics!
}
input GetApiKeyInput {
@@ -3394,6 +3396,14 @@ input UsageAnalyticsInput {
periodStart: DateTime
periodEnd: DateTime
userWorkspaceId: String
operationTypes: [UsageOperationType!]
}
enum UsageOperationType {
AI_CHAT_TOKEN
AI_WORKFLOW_TOKEN
WORKFLOW_EXECUTION
CODE_EXECUTION
}
type Mutation {
@@ -1460,6 +1460,13 @@ export interface ClientConfig {
__typename: 'ClientConfig'
}
export interface UsageBreakdownItem {
key: Scalars['String']
label?: Scalars['String']
creditsUsed: Scalars['Float']
__typename: 'UsageBreakdownItem'
}
export interface ConfigVariable {
name: Scalars['String']
description: Scalars['String']
@@ -2064,6 +2071,29 @@ export interface Impersonate {
__typename: 'Impersonate'
}
export interface UsageTimeSeries {
date: Scalars['String']
creditsUsed: Scalars['Float']
__typename: 'UsageTimeSeries'
}
export interface UsageUserDaily {
userWorkspaceId: Scalars['String']
dailyUsage: UsageTimeSeries[]
__typename: 'UsageUserDaily'
}
export interface UsageAnalytics {
usageByUser: UsageBreakdownItem[]
usageByOperationType: UsageBreakdownItem[]
usageByModel: UsageBreakdownItem[]
timeSeries: UsageTimeSeries[]
periodStart: Scalars['DateTime']
periodEnd: Scalars['DateTime']
userDailyUsage?: UsageUserDaily
__typename: 'UsageAnalytics'
}
export interface DevelopmentApplication {
id: Scalars['String']
universalIdentifier: Scalars['String']
@@ -2275,35 +2305,6 @@ export interface PostgresCredentials {
__typename: 'PostgresCredentials'
}
export interface UsageBreakdownItem {
key: Scalars['String']
label?: Scalars['String']
creditsUsed: Scalars['Float']
__typename: 'UsageBreakdownItem'
}
export interface UsageTimeSeries {
date: Scalars['String']
creditsUsed: Scalars['Float']
__typename: 'UsageTimeSeries'
}
export interface UsageUserDaily {
userWorkspaceId: Scalars['String']
dailyUsage: UsageTimeSeries[]
__typename: 'UsageUserDaily'
}
export interface UsageAnalytics {
usageByUser: UsageBreakdownItem[]
usageByOperationType: UsageBreakdownItem[]
timeSeries: UsageTimeSeries[]
periodStart: Scalars['DateTime']
periodEnd: Scalars['DateTime']
userDailyUsage?: UsageUserDaily
__typename: 'UsageAnalytics'
}
export interface FrontComponent {
id: Scalars['UUID']
name: Scalars['String']
@@ -2823,6 +2824,8 @@ export interface Query {
getAiProviders: Scalars['JSON']
getModelsDevProviders: ModelsDevProviderSuggestion[]
getModelsDevSuggestions: ModelsDevModelSuggestion[]
getAdminAiUsageByWorkspace: UsageBreakdownItem[]
getUsageAnalytics: UsageAnalytics
getPostgresCredentials?: PostgresCredentials
findManyPublicDomains: PublicDomain[]
getEmailingDomains: EmailingDomain[]
@@ -2830,7 +2833,6 @@ export interface Query {
findOneMarketplaceApp: MarketplaceApp
findManyApplications: Application[]
findOneApplication: Application
getUsageAnalytics: UsageAnalytics
__typename: 'Query'
}
@@ -2846,6 +2848,8 @@ export type SortNulls = 'NULLS_FIRST' | 'NULLS_LAST'
export type EventLogTable = 'WORKSPACE_EVENT' | 'PAGEVIEW' | 'OBJECT_EVENT' | 'USAGE_EVENT'
export type UsageOperationType = 'AI_CHAT_TOKEN' | 'AI_WORKFLOW_TOKEN' | 'WORKFLOW_EXECUTION' | 'CODE_EXECUTION'
export interface Mutation {
addQueryToEventStream: Scalars['Boolean']
removeQueryFromEventStream: Scalars['Boolean']
@@ -4585,6 +4589,14 @@ export interface ClientConfigGenqlSelection{
__scalar?: boolean | number
}
export interface UsageBreakdownItemGenqlSelection{
key?: boolean | number
label?: boolean | number
creditsUsed?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ConfigVariableGenqlSelection{
name?: boolean | number
description?: boolean | number
@@ -5246,6 +5258,32 @@ export interface ImpersonateGenqlSelection{
__scalar?: boolean | number
}
export interface UsageTimeSeriesGenqlSelection{
date?: boolean | number
creditsUsed?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UsageUserDailyGenqlSelection{
userWorkspaceId?: boolean | number
dailyUsage?: UsageTimeSeriesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UsageAnalyticsGenqlSelection{
usageByUser?: UsageBreakdownItemGenqlSelection
usageByOperationType?: UsageBreakdownItemGenqlSelection
usageByModel?: UsageBreakdownItemGenqlSelection
timeSeries?: UsageTimeSeriesGenqlSelection
periodStart?: boolean | number
periodEnd?: boolean | number
userDailyUsage?: UsageUserDailyGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface DevelopmentApplicationGenqlSelection{
id?: boolean | number
universalIdentifier?: boolean | number
@@ -5476,39 +5514,6 @@ export interface PostgresCredentialsGenqlSelection{
__scalar?: boolean | number
}
export interface UsageBreakdownItemGenqlSelection{
key?: boolean | number
label?: boolean | number
creditsUsed?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UsageTimeSeriesGenqlSelection{
date?: boolean | number
creditsUsed?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UsageUserDailyGenqlSelection{
userWorkspaceId?: boolean | number
dailyUsage?: UsageTimeSeriesGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface UsageAnalyticsGenqlSelection{
usageByUser?: UsageBreakdownItemGenqlSelection
usageByOperationType?: UsageBreakdownItemGenqlSelection
timeSeries?: UsageTimeSeriesGenqlSelection
periodStart?: boolean | number
periodEnd?: boolean | number
userDailyUsage?: UsageUserDailyGenqlSelection
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FrontComponentGenqlSelection{
id?: boolean | number
name?: boolean | number
@@ -6056,6 +6061,8 @@ export interface QueryGenqlSelection{
getAiProviders?: boolean | number
getModelsDevProviders?: ModelsDevProviderSuggestionGenqlSelection
getModelsDevSuggestions?: (ModelsDevModelSuggestionGenqlSelection & { __args: {providerType: Scalars['String']} })
getAdminAiUsageByWorkspace?: (UsageBreakdownItemGenqlSelection & { __args?: {periodStart?: (Scalars['DateTime'] | null), periodEnd?: (Scalars['DateTime'] | null)} })
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
getPostgresCredentials?: PostgresCredentialsGenqlSelection
findManyPublicDomains?: PublicDomainGenqlSelection
getEmailingDomains?: EmailingDomainGenqlSelection
@@ -6063,7 +6070,6 @@ export interface QueryGenqlSelection{
findOneMarketplaceApp?: (MarketplaceAppGenqlSelection & { __args: {universalIdentifier: Scalars['String']} })
findManyApplications?: ApplicationGenqlSelection
findOneApplication?: (ApplicationGenqlSelection & { __args?: {id?: (Scalars['UUID'] | null), universalIdentifier?: (Scalars['UUID'] | null)} })
getUsageAnalytics?: (UsageAnalyticsGenqlSelection & { __args?: {input?: (UsageAnalyticsInput | null)} })
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -6098,7 +6104,7 @@ export interface LineChartDataInput {objectMetadataId: Scalars['UUID'],configura
export interface BarChartDataInput {objectMetadataId: Scalars['UUID'],configuration: Scalars['JSON']}
export interface UsageAnalyticsInput {periodStart?: (Scalars['DateTime'] | null),periodEnd?: (Scalars['DateTime'] | null),userWorkspaceId?: (Scalars['String'] | null)}
export interface UsageAnalyticsInput {periodStart?: (Scalars['DateTime'] | null),periodEnd?: (Scalars['DateTime'] | null),userWorkspaceId?: (Scalars['String'] | null),operationTypes?: (UsageOperationType[] | null)}
export interface MutationGenqlSelection{
addQueryToEventStream?: { __args: {input: AddQuerySubscriptionInput} }
@@ -7593,6 +7599,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const UsageBreakdownItem_possibleTypes: string[] = ['UsageBreakdownItem']
export const isUsageBreakdownItem = (obj?: { __typename?: any } | null): obj is UsageBreakdownItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageBreakdownItem"')
return UsageBreakdownItem_possibleTypes.includes(obj.__typename)
}
const ConfigVariable_possibleTypes: string[] = ['ConfigVariable']
export const isConfigVariable = (obj?: { __typename?: any } | null): obj is ConfigVariable => {
if (!obj?.__typename) throw new Error('__typename is missing in "isConfigVariable"')
@@ -8225,6 +8239,30 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const UsageTimeSeries_possibleTypes: string[] = ['UsageTimeSeries']
export const isUsageTimeSeries = (obj?: { __typename?: any } | null): obj is UsageTimeSeries => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageTimeSeries"')
return UsageTimeSeries_possibleTypes.includes(obj.__typename)
}
const UsageUserDaily_possibleTypes: string[] = ['UsageUserDaily']
export const isUsageUserDaily = (obj?: { __typename?: any } | null): obj is UsageUserDaily => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageUserDaily"')
return UsageUserDaily_possibleTypes.includes(obj.__typename)
}
const UsageAnalytics_possibleTypes: string[] = ['UsageAnalytics']
export const isUsageAnalytics = (obj?: { __typename?: any } | null): obj is UsageAnalytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageAnalytics"')
return UsageAnalytics_possibleTypes.includes(obj.__typename)
}
const DevelopmentApplication_possibleTypes: string[] = ['DevelopmentApplication']
export const isDevelopmentApplication = (obj?: { __typename?: any } | null): obj is DevelopmentApplication => {
if (!obj?.__typename) throw new Error('__typename is missing in "isDevelopmentApplication"')
@@ -8409,38 +8447,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const UsageBreakdownItem_possibleTypes: string[] = ['UsageBreakdownItem']
export const isUsageBreakdownItem = (obj?: { __typename?: any } | null): obj is UsageBreakdownItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageBreakdownItem"')
return UsageBreakdownItem_possibleTypes.includes(obj.__typename)
}
const UsageTimeSeries_possibleTypes: string[] = ['UsageTimeSeries']
export const isUsageTimeSeries = (obj?: { __typename?: any } | null): obj is UsageTimeSeries => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageTimeSeries"')
return UsageTimeSeries_possibleTypes.includes(obj.__typename)
}
const UsageUserDaily_possibleTypes: string[] = ['UsageUserDaily']
export const isUsageUserDaily = (obj?: { __typename?: any } | null): obj is UsageUserDaily => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageUserDaily"')
return UsageUserDaily_possibleTypes.includes(obj.__typename)
}
const UsageAnalytics_possibleTypes: string[] = ['UsageAnalytics']
export const isUsageAnalytics = (obj?: { __typename?: any } | null): obj is UsageAnalytics => {
if (!obj?.__typename) throw new Error('__typename is missing in "isUsageAnalytics"')
return UsageAnalytics_possibleTypes.includes(obj.__typename)
}
const FrontComponent_possibleTypes: string[] = ['FrontComponent']
export const isFrontComponent = (obj?: { __typename?: any } | null): obj is FrontComponent => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFrontComponent"')
@@ -9455,6 +9461,13 @@ export const enumEventLogTable = {
USAGE_EVENT: 'USAGE_EVENT' as const
}
export const enumUsageOperationType = {
AI_CHAT_TOKEN: 'AI_CHAT_TOKEN' as const,
AI_WORKFLOW_TOKEN: 'AI_WORKFLOW_TOKEN' as const,
WORKFLOW_EXECUTION: 'WORKFLOW_EXECUTION' as const,
CODE_EXECUTION: 'CODE_EXECUTION' as const
}
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -14,6 +14,7 @@ import {
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { billingState } from '@/client-config/states/billingState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { formatNumber } from '~/utils/format/formatNumber';
@@ -78,17 +79,6 @@ const StyledSectionTitle = styled.span`
padding-bottom: ${themeCssVariables.spacing[2]};
`;
const formatCredits = (credits: number): string => {
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
return '';
@@ -105,6 +95,19 @@ export const AIChatContextUsageButton = () => {
const { t } = useLingui();
const [isHovered, setIsHovered] = useState(false);
const agentChatUsage = useAtomStateValue(agentChatUsageState);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
// Values from the streaming API arrive as display credits (micro-credits / 1000).
// 1000 display credits = $1. Convert accordingly.
const formatChatCost = (displayCredits: number): string => {
if (isBillingEnabled) {
return `${formatNumber(displayCredits, { decimals: 1 })} credits`;
}
const dollars = displayCredits / 1000;
return `$${formatNumber(dollars, { decimals: 2 })}`;
};
const hasMessages = useAtomComponentSelectorValue(
agentChatHasMessageComponentSelector,
@@ -202,7 +205,9 @@ export const AIChatContextUsageButton = () => {
/>
<SettingsBillingLabelValueItem
label={t`Cost`}
value={`${formatCredits(lastMessage.inputCredits + lastMessage.outputCredits)} ${t`credits`}`}
value={formatChatCost(
lastMessage.inputCredits + lastMessage.outputCredits,
)}
/>
</StyledSection>
</>
@@ -230,7 +235,7 @@ export const AIChatContextUsageButton = () => {
/>
<SettingsBillingLabelValueItem
label={t`Total cost`}
value={`${formatCredits(totalCredits)} ${t`credits`}`}
value={formatChatCost(totalCredits)}
/>
</StyledSection>
</StyledHoverCard>
@@ -155,6 +155,12 @@ const SettingsAI = lazy(() =>
})),
);
const SettingsAIUsageUserDetail = lazy(() =>
import('~/pages/settings/ai/SettingsAIUsageUserDetail').then((module) => ({
default: module.SettingsAIUsageUserDetail,
})),
);
const SettingsApplications = lazy(() =>
import('~/pages/settings/applications/SettingsApplications').then(
(module) => ({
@@ -536,6 +542,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AISkillDetail}
element={<SettingsSkillForm mode="edit" />}
/>
<Route
path={SettingsPath.AIUsageUserDetail}
element={<SettingsAIUsageUserDetail />}
/>
<Route
path={SettingsPath.LogicFunctionDetail}
element={<SettingsLogicFunctionDetail />}
@@ -3,7 +3,6 @@ import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedSta
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import {
AppTooltip,
@@ -52,7 +51,7 @@ export const CommandMenuItemMoreActionsButton = () => {
<StyledTooltipWrapper>
<AppTooltip
anchorSelect="#toggle-side-panel-button"
content={i18n._(ariaLabel)}
content={ariaLabel}
delay={TooltipDelay.longDelay}
place={TooltipPosition.Bottom}
offset={5}
@@ -285,10 +285,9 @@ export const GraphWidgetBarChart = ({
onMouseLeave={handleTooltipMouseLeave}
onSliceClick={onSliceClick}
/>
<GraphWidgetLegend
items={legendItems}
show={showLegend && data.length > 0 && keys.length > 0}
/>
{showLegend && data.length > 0 && keys.length > 0 && (
<GraphWidgetLegend items={legendItems} show />
)}
</StyledContainer>
);
};
@@ -146,16 +146,18 @@ export const GraphWidgetGaugeChart = ({
</StyledH1TitleWrapper>
)}
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={[
{
id: 'gauge',
label: data.label || t`Value`,
color: colorScheme.solid,
},
]}
/>
{showLegend && (
<GraphWidgetLegend
show
items={[
{
id: 'gauge',
label: data.label || t`Value`,
color: colorScheme.solid,
},
]}
/>
)}
</StyledContainer>
);
};
@@ -348,10 +348,9 @@ export const GraphWidgetLineChart = ({
onMouseEnter={handleTooltipMouseEnter}
onMouseLeave={handleTooltipMouseLeave}
/>
<GraphWidgetLegend
show={showLegend && data.length > 0}
items={legendItems}
/>
{showLegend && data.length > 0 && (
<GraphWidgetLegend show items={legendItems} />
)}
</StyledContainer>
);
};
@@ -231,10 +231,9 @@ export const GraphWidgetPieChart = ({
displayType={displayType}
onSliceClick={onSliceClick}
/>
<GraphWidgetLegend
show={showLegend && data.length > 0}
items={legendItems}
/>
{showLegend && data.length > 0 && (
<GraphWidgetLegend show items={legendItems} />
)}
</StyledContainer>
);
};
@@ -1,11 +1,14 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { useMutation, useQuery } from '@apollo/client/react';
import { t } from '@lingui/core/macro';
import { Tag } from 'twenty-ui/components';
import { H2Title, IconBolt, IconLock, IconRobot } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { useClientConfig } from '@/client-config/hooks/useClientConfig';
import { SettingsAdminAiModelsTable } from '@/settings/admin-panel/ai/components/SettingsAdminAiModelsTable';
import { SettingsAdminAiProviderListCard } from '@/settings/admin-panel/ai/components/SettingsAdminAiProviderListCard';
@@ -13,23 +16,51 @@ import { AI_PROVIDER_SOURCE } from '@/settings/admin-panel/ai/constants/AiProvid
import { SET_ADMIN_AI_MODEL_RECOMMENDED } from '@/settings/admin-panel/ai/graphql/mutations/setAdminAiModelRecommended';
import { SET_ADMIN_DEFAULT_AI_MODEL } from '@/settings/admin-panel/ai/graphql/mutations/setAdminDefaultAiModel';
import { GET_ADMIN_AI_MODELS } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiModels';
import { GET_ADMIN_AI_USAGE_BY_WORKSPACE } from '@/settings/admin-panel/ai/graphql/queries/getAdminAiUsageByWorkspace';
import { GET_AI_PROVIDERS } from '@/settings/admin-panel/ai/graphql/queries/getAiProviders';
import { type GetAiProvidersResult } from '@/settings/admin-panel/ai/types/GetAiProvidersResult';
import { getModelIcon } from '@/settings/admin-panel/ai/utils/getModelIcon';
import { parseProviderItems } from '@/settings/admin-panel/ai/utils/parseProviderItems';
import { SettingsAdminTabSkeletonLoader } from '@/settings/admin-panel/components/SettingsAdminTabSkeletonLoader';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import {
AiModelRole,
type AdminAiModelConfig,
} from '~/generated-metadata/graphql';
const USAGE_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px';
type UsageBreakdownItem = {
key: string;
label?: string | null;
creditsUsed: number;
};
export const SettingsAdminAI = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const { refetch: refetchClientConfig } = useClientConfig();
const { formatUsageValue } = useUsageValueFormatter();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const hasEnterpriseAccess =
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
const [usagePeriod, setUsagePeriod] = useState<PeriodPreset>('30d');
const periodOptions = getPeriodOptions();
const usageDates = getPeriodDates(usagePeriod);
const { data, loading: isLoadingModels } = useQuery<{
getAdminAiModels: {
@@ -45,6 +76,19 @@ export const SettingsAdminAI = () => {
const { data: providersData, loading: isLoadingProviders } =
useQuery<GetAiProvidersResult>(GET_AI_PROVIDERS);
const { data: usageData, previousData: previousUsageData } = useQuery<{
getAdminAiUsageByWorkspace: UsageBreakdownItem[];
}>(GET_ADMIN_AI_USAGE_BY_WORKSPACE, {
variables: {
periodStart: usageDates.periodStart,
periodEnd: usageDates.periodEnd,
},
skip: !hasEnterpriseAccess,
});
const effectiveUsageData = usageData ?? previousUsageData;
const usageByWorkspace = effectiveUsageData?.getAdminAiUsageByWorkspace ?? [];
const models = data?.getAdminAiModels?.models ?? [];
const providerItems = useMemo(
@@ -208,6 +252,70 @@ export const SettingsAdminAI = () => {
/>
</Section>
)}
<Section>
<H2Title
title={t`AI Usage by Workspace`}
description={t`AI consumption across all workspaces.`}
adornment={
hasEnterpriseAccess ? (
<Select
dropdownId="admin-ai-usage-period"
value={usagePeriod}
options={periodOptions}
onChange={setUsagePeriod}
needIconCheck
selectSizeVariant="small"
/>
) : (
<Tag
text={t`Enterprise`}
color="transparent"
Icon={IconLock}
variant="border"
/>
)
}
/>
{hasEnterpriseAccess ? (
usageByWorkspace.length > 0 ? (
<Table>
<TableRow gridTemplateColumns={USAGE_TABLE_GRID_TEMPLATE_COLUMNS}>
<TableHeader>{t`Workspace`}</TableHeader>
<TableHeader align="right">{t`Usage`}</TableHeader>
</TableRow>
{usageByWorkspace.map((item) => (
<TableRow
key={item.key}
gridTemplateColumns={USAGE_TABLE_GRID_TEMPLATE_COLUMNS}
>
<TableCell color={themeCssVariables.font.color.primary}>
{item.label ?? item.key}
</TableCell>
<TableCell align="right">
{formatUsageValue(item.creditsUsed)}
</TableCell>
</TableRow>
))}
</Table>
) : (
<Card rounded>
<TableRow gridTemplateColumns="1fr">
<TableCell
color={themeCssVariables.font.color.tertiary}
align="center"
>
{t`No AI usage data recorded yet.`}
</TableCell>
</TableRow>
</Card>
)
) : (
<SettingsEnterpriseFeatureGateCard
description={t`AI usage analytics across workspaces is available with an Enterprise key.`}
/>
)}
</Section>
</>
);
};
@@ -0,0 +1,17 @@
import { gql } from '@apollo/client';
export const GET_ADMIN_AI_USAGE_BY_WORKSPACE = gql`
query GetAdminAiUsageByWorkspace(
$periodStart: DateTime
$periodEnd: DateTime
) {
getAdminAiUsageByWorkspace(
periodStart: $periodStart
periodEnd: $periodEnd
) {
key
label
creditsUsed
}
}
`;
@@ -63,7 +63,6 @@ export const SettingsBillingCreditsSection = ({
} = getWorkflowNodeExecutionUsage();
const progressBarValue = (usedCredits / totalGrantedCredits) * 100;
const displayedProgressBarValue = progressBarValue < 3 ? 3 : progressBarValue;
const intervalLabel = getIntervalLabel(isMonthlyPlan);
@@ -90,7 +89,7 @@ export const SettingsBillingCreditsSection = ({
value={`${formatNumber(usedCredits)}/${formatNumber(totalGrantedCredits, { abbreviate: true, decimals: 2 })}`}
/>
<ProgressBar
value={displayedProgressBarValue}
value={progressBarValue}
barColor={
progressBarValue > 100 ? theme.color.red8 : theme.color.blue
}
@@ -0,0 +1,44 @@
import { currentUserState } from '@/auth/states/currentUserState';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { IconArrowUp, IconLock } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card } from 'twenty-ui/layout';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsEnterpriseFeatureGateCard = ({
description,
}: {
description: string;
}) => {
const currentUser = useAtomStateValue(currentUserState);
const navigateSettings = useNavigateSettings();
const canAccessAdminPanel = currentUser?.canAccessFullAdminPanel === true;
return (
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconLock}
title={t`Enterprise feature`}
description={description}
Button={
canAccessAdminPanel ? (
<Button
title={t`Activate`}
variant="primary"
accent="blue"
size="small"
Icon={IconArrowUp}
onClick={() =>
navigateSettings(SettingsPath.AdminPanelEnterprise)
}
/>
) : undefined
}
/>
</Card>
);
};
@@ -71,9 +71,20 @@ const StyledIntervalContainer = styled.div`
width: 100%;
`;
const StyledIntervalCardContent = styled.div`
display: flex;
flex-direction: column;
`;
const StyledIntervalTitle = styled.div`
color: ${themeCssVariables.font.color.secondary};
font-size: ${themeCssVariables.font.size.md};
margin-bottom: ${themeCssVariables.spacing[2]};
`;
const StyledIntervalSubtitle = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
`;
export const EnterprisePlanModal = () => {
@@ -132,7 +143,7 @@ export const EnterprisePlanModal = () => {
return (
<ModalStatefulWrapper
modalInstanceId={ENTERPRISE_PLAN_MODAL_ID}
size="small"
size="medium"
padding="none"
isClosable
>
@@ -157,13 +168,19 @@ export const EnterprisePlanModal = () => {
checked={selectedInterval === 'monthly'}
handleChange={() => setSelectedInterval('monthly')}
>
<StyledIntervalTitle>{t`Monthly subscription`}</StyledIntervalTitle>
<StyledIntervalCardContent>
<StyledIntervalTitle>{t`Monthly`}</StyledIntervalTitle>
<StyledIntervalSubtitle>{`$${MONTHLY_PRICE} / ${t`seat / month`}`}</StyledIntervalSubtitle>
</StyledIntervalCardContent>
</CardPicker>
<CardPicker
checked={selectedInterval === 'yearly'}
handleChange={() => setSelectedInterval('yearly')}
>
<StyledIntervalTitle>{t`Yearly subscription`}</StyledIntervalTitle>
<StyledIntervalCardContent>
<StyledIntervalTitle>{t`Yearly`}</StyledIntervalTitle>
<StyledIntervalSubtitle>{`$${YEARLY_PRICE} / ${t`seat / month`}`}</StyledIntervalSubtitle>
</StyledIntervalCardContent>
</CardPicker>
</StyledIntervalContainer>
@@ -22,7 +22,6 @@ import {
IconCalendarEvent,
IconColorSwatch,
type IconComponent,
IconChartBar,
IconCurrencyDollar,
IconDoorEnter,
IconHelpCircle,
@@ -79,9 +78,6 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
const isApplicationEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_APPLICATION_ENABLED,
);
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
);
const isSupportChatConfigured =
supportChat?.supportDriver === 'FRONT' &&
isNonEmptyString(supportChat.supportFrontChatId);
@@ -165,15 +161,6 @@ const useSettingsNavigationItems = (): SettingsNavigationSection[] => {
isHidden:
!isBillingEnabled || !permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`Usage`,
path: SettingsPath.Usage,
Icon: IconChartBar,
isHidden:
!isUsageAnalyticsEnabled ||
isBillingEnabled ||
!permissionMap[PermissionFlagType.WORKSPACE],
},
{
label: t`APIs & Webhooks`,
path: SettingsPath.ApiWebhooks,
@@ -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':
@@ -6,7 +6,6 @@ import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingC
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { i18n } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { motion } from 'framer-motion';
import { useContext } from 'react';
@@ -167,7 +166,7 @@ export const PageHeaderToggleSidePanelButton = () => {
<StyledTooltipWrapper>
<AppTooltip
anchorSelect="#toggle-side-panel-button"
content={i18n._(ariaLabel)}
content={ariaLabel}
delay={TooltipDelay.longDelay}
place={TooltipPosition.Bottom}
offset={5}
@@ -16,6 +16,10 @@ export const SettingsUsage = () => {
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Billing</Trans>,
href: getSettingsPath(SettingsPath.Billing),
},
{ children: <Trans>Usage</Trans> },
]}
>
@@ -1,32 +1,23 @@
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 { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
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 { Trans, useLingui } from '@lingui/react/macro';
import { t } from '@lingui/core/macro';
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { t } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Avatar, H2Title } from 'twenty-ui/display';
import { Avatar } from 'twenty-ui/display';
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';
const StyledUserHeader = styled.div`
align-items: center;
@@ -51,96 +42,44 @@ const StyledUserCredits = styled.span`
font-size: ${themeCssVariables.font.size.sm};
`;
const StyledLineChartContainer = styled.div`
height: 200px;
width: 100%;
`;
export const SettingsUsageUserDetail = () => {
const { t: tLingui } = useLingui();
const { userWorkspaceId } = useParams<{ userWorkspaceId: string }>();
const { theme } = useContext(ThemeContext);
const { formatNumber } = useNumberFormat();
const colorRegistry = createGraphColorRegistry(theme.color);
const { formatUsageValue } = useUsageValueFormatter();
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
const { analytics, isInitialLoading } = useUsageAnalyticsData({
userWorkspaceId,
skip: !userWorkspaceId,
});
const periodOptions = getPeriodOptions();
const userName = analytics?.usageByUser?.find(
(item) => item.key === userWorkspaceId,
)?.label;
const dailyDates = getPeriodDates(dailyPeriod);
const typeDates = getPeriodDates(typePeriod);
const { data: dailyData, loading: dailyLoading } = useQuery(
GetUsageAnalyticsDocument,
{
variables: {
input: {
...dailyDates,
userWorkspaceId,
},
},
skip: !userWorkspaceId,
},
);
const { data: typeData, loading: typeLoading } = useQuery(
GetUsageAnalyticsDocument,
{
variables: {
input: {
...typeDates,
userWorkspaceId,
},
},
skip: !userWorkspaceId,
},
);
const dailyAnalytics = dailyData?.getUsageAnalytics;
const typeAnalytics = typeData?.getUsageAnalytics;
const userDailyUsage = dailyAnalytics?.userDailyUsage?.dailyUsage ?? [];
const usageByOperationType = typeAnalytics?.usageByOperationType ?? [];
const userName =
dailyAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
?.label ??
typeAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
?.label;
const totalCredits = usageByOperationType.reduce(
(sum, item) => sum + item.creditsUsed,
0,
);
const totalCredits = analytics
? analytics.usageByOperationType.reduce(
(sum, item) => sum + item.creditsUsed,
0,
)
: 0;
const displayName = userName ?? userWorkspaceId ?? '';
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: userDailyUsage.map((point) => ({
x: formatDate(point.date, 'MMM d'),
y: point.creditsUsed,
})),
},
];
const isInitialLoading =
(dailyLoading || typeLoading) && !dailyData && !typeData;
const hasAnyData = analytics
? (analytics.userDailyUsage?.dailyUsage?.length ?? 0) > 0 ||
analytics.usageByOperationType.length > 0
: false;
const breadcrumbLinks = [
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>Billing</Trans>,
href: getSettingsPath(SettingsPath.Billing),
},
{
children: <Trans>Usage</Trans>,
href: getSettingsPath(SettingsPath.Usage),
@@ -167,24 +106,8 @@ export const SettingsUsageUserDetail = () => {
<Skeleton width={100} height={13} />
</StyledUserInfo>
</StyledUserHeader>
<Section>
<Skeleton width={120} height={16} />
<Skeleton
width="100%"
height={200}
borderRadius={8}
style={{ marginTop: 16 }}
/>
</Section>
<Section>
<Skeleton width={120} height={16} />
<Skeleton
width="100%"
height={220}
borderRadius={8}
style={{ marginTop: 16 }}
/>
</Section>
<UsageSectionSkeleton />
<UsageSectionSkeleton />
</SkeletonTheme>
</SettingsPageContainer>
</SubMenuTopBarContainer>
@@ -204,12 +127,12 @@ export const SettingsUsageUserDetail = () => {
<StyledUserInfo>
<StyledUserName>{displayName}</StyledUserName>
<StyledUserCredits>
{t`${formatNumber(totalCredits)} credits used`}
{t`${formatUsageValue(totalCredits)} used`}
</StyledUserCredits>
</StyledUserInfo>
</StyledUserHeader>
{userDailyUsage.length === 0 && pieData.length === 0 && (
{!hasAnyData && (
<Section>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
@@ -220,57 +143,21 @@ export const SettingsUsageUserDetail = () => {
</Section>
)}
{userDailyUsage.length > 0 && (
<Section>
<H2Title
title={t`Daily Usage`}
description={t`Per-day credit consumption.`}
adornment={
<Select
dropdownId="user-daily-period"
value={dailyPeriod}
options={periodOptions}
onChange={setDailyPeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<SubscriptionInfoContainer>
<StyledLineChartContainer>
<GraphWidgetLineChart
id="user-daily-line-chart"
data={lineData}
colorMode="automaticPalette"
showLegend={false}
enableArea
/>
</StyledLineChartContainer>
</SubscriptionInfoContainer>
</Section>
)}
{usageByOperationType.length > 0 && (
<Section>
<H2Title
title={t`Usage by Type`}
description={t`${formatNumber(totalCredits)} credits`}
adornment={
<Select
dropdownId="user-type-period"
value={typePeriod}
options={periodOptions}
onChange={setTypePeriod}
needIconCheck
selectSizeVariant="small"
/>
}
/>
<SubscriptionInfoContainer>
<UsagePieChart data={pieData} />
</SubscriptionInfoContainer>
</Section>
)}
<UsageDailyChartSection
title={t`Daily Usage`}
description={t`Per-day credit consumption.`}
userWorkspaceId={userWorkspaceId}
skip={!userWorkspaceId}
chartId="user-daily"
chartLabel={t`Credits`}
/>
<UsageBreakdownPieSection
title={t`Usage by Type`}
userWorkspaceId={userWorkspaceId}
skip={!userWorkspaceId}
breakdownField="operationType"
sectionId="user-type"
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -7,12 +7,14 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { SettingsPath } from 'twenty-shared/types';
import { FeatureFlagKey, SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { t } from '@lingui/core/macro';
import {
H2Title,
IconChartBar,
IconCpu,
IconFileText,
IconSettingsBolt,
@@ -23,6 +25,7 @@ import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIModelsTab } from './components/SettingsAIModelsTab';
import { SettingsAIUsageTab } from './components/SettingsAIUsageTab';
import { SettingsAgentSkills } from './components/SettingsAgentSkills';
import { SettingsToolsTable } from './components/SettingsToolsTable';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
@@ -39,6 +42,10 @@ export const SettingsAI = () => {
SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID,
);
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
);
const tabs = [
{
id: SETTINGS_AI_TABS.TABS_IDS.MODELS,
@@ -55,6 +62,15 @@ export const SettingsAI = () => {
title: t`Tools`,
Icon: IconTool,
},
...(isUsageAnalyticsEnabled
? [
{
id: SETTINGS_AI_TABS.TABS_IDS.USAGE,
title: t`Usage`,
Icon: IconChartBar,
},
]
: []),
{
id: SETTINGS_AI_TABS.TABS_IDS.MORE,
title: t`More`,
@@ -65,6 +81,7 @@ export const SettingsAI = () => {
const isModelsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MODELS;
const isSkillsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS;
const isToolsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS;
const isUsageTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.USAGE;
const isMoreTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.MORE;
return (
@@ -86,6 +103,7 @@ export const SettingsAI = () => {
{isModelsTab && <SettingsAIModelsTab />}
{isSkillsTab && <SettingsAgentSkills />}
{isToolsTab && <SettingsToolsTable />}
{isUsageTab && <SettingsAIUsageTab />}
{isMoreTab && (
<>
<Section>
@@ -0,0 +1,107 @@
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
import { AI_OPERATION_TYPES } from '@/settings/usage/constants/AiOperationTypes';
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { useParams } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Section } from 'twenty-ui/layout';
export const SettingsAIUsageUserDetail = () => {
const { t: tLingui } = useLingui();
const { userWorkspaceId } = useParams<{ userWorkspaceId: string }>();
const { analytics, isInitialLoading } = useUsageAnalyticsData({
operationTypes: AI_OPERATION_TYPES,
userWorkspaceId,
skip: !userWorkspaceId,
});
const userName = analytics?.usageByUser?.find(
(item) => item.key === userWorkspaceId,
)?.label;
const displayName = userName ?? userWorkspaceId ?? '';
const hasAnyData = analytics
? (analytics.userDailyUsage?.dailyUsage?.length ?? 0) > 0 ||
analytics.usageByOperationType.length > 0
: false;
const breadcrumbLinks = [
{
children: <Trans>Workspace</Trans>,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: <Trans>AI</Trans>,
href: getSettingsPath(SettingsPath.AI),
},
{ children: isInitialLoading ? '' : displayName },
];
if (isInitialLoading) {
return (
<SubMenuTopBarContainer
title={tLingui`AI User Usage`}
links={breadcrumbLinks}
>
<SettingsPageContainer>
<UsageSectionSkeleton />
<UsageSectionSkeleton />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
}
return (
<SubMenuTopBarContainer title={displayName} links={breadcrumbLinks}>
<SettingsPageContainer>
{!hasAnyData && (
<Section>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`No usage data`}
value={t`No AI consumption recorded for this user.`}
/>
</SubscriptionInfoContainer>
</Section>
)}
<UsageDailyChartSection
title={t`Daily AI Usage`}
description={t`Per-day AI consumption.`}
operationTypes={AI_OPERATION_TYPES}
userWorkspaceId={userWorkspaceId}
skip={!userWorkspaceId}
chartId="ai-user-daily"
chartLabel={t`AI Usage`}
/>
<UsageBreakdownPieSection
title={t`AI Usage by Type`}
operationTypes={AI_OPERATION_TYPES}
userWorkspaceId={userWorkspaceId}
skip={!userWorkspaceId}
breakdownField="operationType"
sectionId="ai-user-type"
/>
<UsageBreakdownPieSection
title={t`AI Usage by Model`}
description={t`Breakdown across AI models.`}
operationTypes={AI_OPERATION_TYPES}
userWorkspaceId={userWorkspaceId}
skip={!userWorkspaceId}
breakdownField="model"
sectionId="ai-user-model"
/>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
@@ -0,0 +1,101 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { billingState } from '@/client-config/states/billingState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection';
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
import { AI_OPERATION_TYPES } from '@/settings/usage/constants/AiOperationTypes';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { Tag } from 'twenty-ui/components';
import { H2Title, IconLock } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
export const SettingsAIUsageTab = () => {
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const billing = useAtomStateValue(billingState);
const isBillingEnabled = billing?.isBillingEnabled ?? false;
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
const hasEnterpriseAccess =
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
if (!hasEnterpriseAccess) {
return (
<Section>
<H2Title
title={t`AI Usage`}
description={t`Track AI consumption across your workspace.`}
adornment={
<Tag
text={t`Enterprise`}
color="transparent"
Icon={IconLock}
variant="border"
/>
}
/>
<SettingsEnterpriseFeatureGateCard
description={t`AI usage analytics is available with an Enterprise key.`}
/>
</Section>
);
}
if (!isClickHouseConfigured) {
return (
<Section>
<H2Title
title={t`AI Usage`}
description={t`Track AI consumption across your workspace.`}
/>
<SubscriptionInfoContainer>
<SettingsBillingLabelValueItem
label={t`ClickHouse Not Configured`}
value={t`AI usage analytics requires ClickHouse. Contact your administrator.`}
/>
</SubscriptionInfoContainer>
</Section>
);
}
return (
<>
<UsageDailyChartSection
title={t`Daily AI Usage`}
description={t`AI consumption over time.`}
operationTypes={AI_OPERATION_TYPES}
chartId="ai-usage-daily"
chartLabel={t`AI Usage`}
/>
<UsageBreakdownPieSection
title={t`AI Usage by Type`}
operationTypes={AI_OPERATION_TYPES}
breakdownField="operationType"
sectionId="ai-usage-type"
/>
<UsageBreakdownPieSection
title={t`AI Usage by Model`}
description={t`Breakdown across AI models.`}
operationTypes={AI_OPERATION_TYPES}
breakdownField="model"
sectionId="ai-usage-model"
/>
<UsageByUserTableSection
title={t`AI Usage by User`}
description={t`Click a user to see their daily breakdown.`}
operationTypes={AI_OPERATION_TYPES}
getDetailPath={(userWorkspaceId) =>
getSettingsPath(SettingsPath.AIUsageUserDetail, {
userWorkspaceId,
})
}
/>
</>
);
};
@@ -4,6 +4,7 @@ export const SETTINGS_AI_TABS = {
MODELS: 'models',
SKILLS: 'skills',
TOOLS: 'tools',
USAGE: 'usage',
MORE: 'more',
},
} as const;
@@ -311,7 +311,6 @@ export const SettingsEnterprise = ({
<Button
Icon={IconKey}
title={isActivating ? t`Activating...` : t`Activate`}
variant="secondary"
accent="blue"
onClick={handleActivate}
disabled={isActivating || !enterpriseKey.trim()}
@@ -630,7 +629,6 @@ export const SettingsEnterprise = ({
<Button
Icon={IconKey}
title={t`Get Enterprise Key`}
variant="secondary"
onClick={openCheckoutModal}
/>
</Section>
@@ -8,6 +8,7 @@ import { authProvidersState } from '@/client-config/states/authProvidersState';
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
import { Separator } from '@/settings/components/Separator';
import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/SettingsEnterpriseFeatureGateCard';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsOptionCardContentCounter } from '@/settings/components/SettingsOptions/SettingsOptionCardContentCounter';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
@@ -25,6 +26,7 @@ import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBa
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';
@@ -38,7 +40,6 @@ import {
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useMutation } from '@apollo/client/react';
import { UpdateWorkspaceDocument } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
@@ -250,49 +251,53 @@ export const SettingsSecurity = () => {
/>
}
/>
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconHistory}
title={t`Workspace Events`}
description={
!isClickHouseConfigured
? t`ClickHouse is required for audit logs. Contact your administrator.`
: !hasEnterpriseAccess
? t`Upgrade to Enterprise to access audit logs`
{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>
}
}
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}
/>
</>
)}
</Card>
) : (
<SettingsEnterpriseFeatureGateCard
description={t`Upgrade to Enterprise to access audit logs.`}
/>
{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}
/>
</>
)}
</Card>
)}
</Section>
<Section>
<H2Title
@@ -4,6 +4,9 @@ import { OBJECT_RECORD_UPDATED_EVENT } from 'src/engine/core-modules/audit/utils
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { type GenericTrackEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { USER_WORKSPACE_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-user-workspaces.util';
export type ObjectEventFixture = GenericTrackEvent & {
recordId: string;
@@ -11,6 +14,20 @@ export type ObjectEventFixture = GenericTrackEvent & {
isCustom?: boolean;
};
export type UsageEventFixture = {
timestamp: string;
workspaceId: string;
userWorkspaceId: string;
resourceType: string;
operationType: string;
quantity: number;
unit: string;
creditsUsedMicro: number;
resourceId: string;
resourceContext: string;
metadata: Record<string, never>;
};
export const workspaceEventFixtures: Array<GenericTrackEvent> = [
{
type: 'track',
@@ -18,7 +35,7 @@ export const workspaceEventFixtures: Array<GenericTrackEvent> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
{
@@ -27,7 +44,7 @@ export const workspaceEventFixtures: Array<GenericTrackEvent> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
},
];
@@ -39,7 +56,7 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
@@ -50,7 +67,7 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
@@ -61,9 +78,155 @@ export const objectEventFixtures: Array<ObjectEventFixture> = [
timestamp: '2024-10-24T15:55:35.177',
version: '1',
userId: '20202020-3957-45c9-be39-337dc4d9100a',
workspaceId: '20202020-1c25-4d02-bf25-6aeccf7ea419',
workspaceId: SEED_APPLE_WORKSPACE_ID,
properties: {},
recordId: '20202020-c21e-4ec2-873b-de4264d89025',
objectMetadataId: '20202020-1f76-4e46-b33b-58a70e007ba0',
},
];
const buildUsageEventFixtures = (): UsageEventFixture[] => {
const now = new Date();
const fixtures: UsageEventFixture[] = [];
const users = [
USER_WORKSPACE_DATA_SEED_IDS.TIM,
USER_WORKSPACE_DATA_SEED_IDS.JANE,
USER_WORKSPACE_DATA_SEED_IDS.JONY,
USER_WORKSPACE_DATA_SEED_IDS.PHIL,
];
// Weight per user so the breakdown isn't uniform
const userWeights = [1.0, 0.6, 0.3, 0.15];
const aiModelIds = [
'anthropic/claude-opus-4-6',
'openai/gpt-5.4',
'openai/gpt-5.4-mini',
'google/gemini-2.5-pro',
];
const operations: {
resourceType: string;
operationType: string;
baseCreditsMicro: number;
baseQuantity: number;
unit: string;
modelIds?: string[];
}[] = [
{
resourceType: 'AI',
operationType: 'AI_CHAT_TOKEN',
baseCreditsMicro: 5000,
baseQuantity: 1200,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'AI',
operationType: 'AI_WORKFLOW_TOKEN',
baseCreditsMicro: 3500,
baseQuantity: 800,
unit: 'TOKEN',
modelIds: aiModelIds,
},
{
resourceType: 'WORKFLOW',
operationType: 'WORKFLOW_EXECUTION',
baseCreditsMicro: 12000,
baseQuantity: 1,
unit: 'INVOCATION',
},
{
resourceType: 'WORKFLOW',
operationType: 'CODE_EXECUTION',
baseCreditsMicro: 3000,
baseQuantity: 1,
unit: 'INVOCATION',
},
];
// Pseudo-random using a seed for reproducibility across runs
let rngState = 42;
const nextRandom = () => {
rngState = (rngState * 1664525 + 1013904223) & 0x7fffffff;
return rngState / 0x7fffffff;
};
for (let daysAgo = 34; daysAgo >= 0; daysAgo--) {
const day = new Date(now);
day.setDate(day.getDate() - daysAgo);
day.setHours(0, 0, 0, 0);
// Weekdays have more activity than weekends
const dayOfWeek = day.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const dayMultiplier = isWeekend ? 0.3 : 1.0;
// Gradual ramp-up over the month (more recent = more usage)
const recencyMultiplier = 0.5 + 0.5 * ((35 - daysAgo) / 35);
for (let userIdx = 0; userIdx < users.length; userIdx++) {
const userWeight = userWeights[userIdx];
for (const op of operations) {
// Skip some user/operation combos randomly for variety
if (nextRandom() < 0.25) {
continue;
}
const eventsCount = Math.max(
1,
Math.round(
(1 + nextRandom() * 4) *
dayMultiplier *
recencyMultiplier *
userWeight,
),
);
for (let eventIdx = 0; eventIdx < eventsCount; eventIdx++) {
const hour = Math.floor(9 + nextRandom() * 9); // 9am6pm
const minute = Math.floor(nextRandom() * 60);
const second = Math.floor(nextRandom() * 60);
const eventDate = new Date(day);
eventDate.setHours(
hour,
minute,
second,
Math.floor(nextRandom() * 1000),
);
const jitter = 0.5 + nextRandom();
const resourceContext = op.modelIds
? op.modelIds[Math.floor(nextRandom() * op.modelIds.length)]
: '';
fixtures.push({
timestamp: formatDateForClickHouse(eventDate),
workspaceId: SEED_APPLE_WORKSPACE_ID,
userWorkspaceId: users[userIdx],
resourceType: op.resourceType,
operationType: op.operationType,
quantity: Math.round(op.baseQuantity * jitter),
unit: op.unit,
creditsUsedMicro: Math.round(op.baseCreditsMicro * jitter),
resourceId: '',
resourceContext,
metadata: {},
});
}
}
}
}
return fixtures;
};
export const usageEventFixtures: UsageEventFixture[] =
buildUsageEventFixtures();
@@ -2,7 +2,11 @@
import { createClient, ClickHouseLogLevel } from '@clickhouse/client';
import { config } from 'dotenv';
import { objectEventFixtures, workspaceEventFixtures } from './fixtures';
import {
objectEventFixtures,
usageEventFixtures,
workspaceEventFixtures,
} from './fixtures';
config({
path: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
@@ -34,6 +38,14 @@ async function seedEvents() {
format: 'JSONEachRow',
});
console.log(`⚡ Seeding ${usageEventFixtures.length} usage events...`);
await client.insert({
table: 'usageEvent',
values: usageEventFixtures,
format: 'JSONEachRow',
});
console.log('✅ All events seeded successfully');
} catch (error) {
console.error('Error seeding events:', error);
@@ -22,6 +22,7 @@ import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RedisClientModule } from 'src/engine/core-modules/redis-client/redis-client.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TelemetryModule } from 'src/engine/core-modules/telemetry/telemetry.module';
import { UsageModule } from 'src/engine/core-modules/usage/usage.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -42,6 +43,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
PermissionsModule,
SecureHttpClientModule,
ApplicationRegistrationModule,
UsageModule,
],
providers: [
AdminPanelResolver,
@@ -1,7 +1,10 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Int, Mutation, Query } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import GraphQLJSON from 'graphql-type-json';
import { In, type Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
@@ -10,6 +13,8 @@ import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-pan
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { UsageBreakdownItemDTO } from 'src/engine/core-modules/usage/dtos/usage-breakdown-item.dto';
import { UsageAnalyticsService } from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { AiModelRole } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-role.enum';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
@@ -41,6 +46,7 @@ import { type AiProviderConfig } from 'src/engine/metadata-modules/ai/ai-models/
import { type AiProviderModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-provider-model-config.type';
import { extractConfigVariableName } from 'src/engine/metadata-modules/ai/ai-models/utils/extract-config-variable-name.util';
import { loadDefaultAiProviders } from 'src/engine/metadata-modules/ai/ai-models/utils/load-default-ai-providers.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ServerLevelImpersonateGuard } from 'src/engine/guards/server-level-impersonate.guard';
@@ -75,6 +81,9 @@ export class AdminPanelResolver {
private readonly twentyConfigService: TwentyConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly modelsDevCatalogService: ModelsDevCatalogService,
private readonly usageAnalyticsService: UsageAnalyticsService,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
@UseGuards(ServerLevelImpersonateGuard)
@@ -499,4 +508,47 @@ export class AdminPanelResolver {
return true;
}
@UseGuards(AdminPanelGuard)
@Query(() => [UsageBreakdownItemDTO])
async getAdminAiUsageByWorkspace(
@Args('periodStart', { type: () => Date, nullable: true })
periodStart?: Date,
@Args('periodEnd', { type: () => Date, nullable: true })
periodEnd?: Date,
): Promise<UsageBreakdownItemDTO[]> {
const defaultEnd = new Date();
const defaultStart = new Date();
defaultStart.setDate(defaultStart.getDate() - 30);
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
const items = await this.usageAnalyticsService.getAdminAiUsageByWorkspace({
periodStart: periodStart ?? defaultStart,
periodEnd: periodEnd ?? defaultEnd,
useDollarMode,
});
if (items.length === 0) {
return items;
}
const workspaceIds = items.map((item) => item.key);
const workspaces = await this.workspaceRepository.find({
where: { id: In(workspaceIds) },
select: { id: true, displayName: true },
});
const nameMap = new Map(
workspaces
.filter((workspace) => isDefined(workspace.displayName))
.map((workspace) => [workspace.id, workspace.displayName!]),
);
return items.map((item) => ({
...item,
label: nameMap.get(item.key),
}));
}
}
@@ -2,6 +2,8 @@
import { Field, InputType } from '@nestjs/graphql';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
@InputType()
export class UsageAnalyticsInput {
@Field(() => Date, { nullable: true })
@@ -12,4 +14,7 @@ export class UsageAnalyticsInput {
@Field(() => String, { nullable: true })
userWorkspaceId?: string;
@Field(() => [UsageOperationType], { nullable: true })
operationTypes?: UsageOperationType[];
}
@@ -14,6 +14,9 @@ export class UsageAnalyticsDTO {
@Field(() => [UsageBreakdownItemDTO])
usageByOperationType: UsageBreakdownItemDTO[];
@Field(() => [UsageBreakdownItemDTO])
usageByModel: UsageBreakdownItemDTO[];
@Field(() => [UsageTimeSeriesDTO])
timeSeries: UsageTimeSeriesDTO[];
@@ -3,7 +3,8 @@
import { registerEnumType } from '@nestjs/graphql';
export enum UsageOperationType {
AI_TOKEN = 'AI_TOKEN',
AI_CHAT_TOKEN = 'AI_CHAT_TOKEN',
AI_WORKFLOW_TOKEN = 'AI_WORKFLOW_TOKEN',
WORKFLOW_EXECUTION = 'WORKFLOW_EXECUTION',
CODE_EXECUTION = 'CODE_EXECUTION',
}
@@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
import { toDollars } from 'src/engine/core-modules/usage/utils/to-dollars.util';
export type UsageBreakdownItem = {
key: string;
@@ -31,6 +32,8 @@ type PeriodParams = {
workspaceId: string;
periodStart: Date;
periodEnd: Date;
operationTypes?: string[];
useDollarMode?: boolean;
};
const ALLOWED_GROUP_BY_FIELDS = [
@@ -38,6 +41,7 @@ const ALLOWED_GROUP_BY_FIELDS = [
'resourceId',
'operationType',
'resourceType',
'resourceContext',
] as const;
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
@@ -48,6 +52,40 @@ const BREAKDOWN_QUERY_LIMIT = 50;
export class UsageAnalyticsService {
constructor(private readonly clickHouseService: ClickHouseService) {}
async getAdminAiUsageByWorkspace(params: {
periodStart: Date;
periodEnd: Date;
useDollarMode?: boolean;
}): Promise<UsageBreakdownItem[]> {
const aiOperationTypes = ['AI_CHAT_TOKEN', 'AI_WORKFLOW_TOKEN'];
const convert = params.useDollarMode ? toDollars : toDisplayCredits;
const query = `
SELECT
workspaceId AS key,
sum(creditsUsedMicro) AS creditsUsedMicro
FROM usageEvent
WHERE timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
AND operationType IN ({operationTypes:Array(String)})
GROUP BY workspaceId
ORDER BY creditsUsedMicro DESC
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
periodStart: formatDateForClickHouse(params.periodStart),
periodEnd: formatDateForClickHouse(params.periodEnd),
operationTypes: aiOperationTypes,
});
return rows.map((row) => ({
key: row.key,
creditsUsed: convert(row.creditsUsedMicro),
}));
}
async getUsageByUser(params: PeriodParams): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
@@ -56,6 +94,14 @@ export class UsageAnalyticsService {
});
}
async getUsageByModel(params: PeriodParams): Promise<UsageBreakdownItem[]> {
return this.queryBreakdown({
...params,
groupByField: 'resourceContext',
extraWhere: "AND resourceContext != ''",
});
}
async getUsageByOperationType(
params: PeriodParams & { userWorkspaceId?: string },
): Promise<UsageBreakdownItem[]> {
@@ -90,6 +136,8 @@ export class UsageAnalyticsService {
periodStart,
periodEnd,
groupByField,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
@@ -97,6 +145,19 @@ export class UsageAnalyticsService {
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageBreakdownItem[]> {
if (
!ALLOWED_GROUP_BY_FIELDS.includes(
groupByField as (typeof ALLOWED_GROUP_BY_FIELDS)[number],
)
) {
throw new Error(`Invalid groupByField: ${groupByField}`);
}
const opTypeFilter =
operationTypes && operationTypes.length > 0
? 'AND operationType IN ({operationTypes:Array(String)})'
: '';
const query = `
SELECT
${groupByField} AS key,
@@ -105,22 +166,28 @@ export class UsageAnalyticsService {
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${opTypeFilter}
${extraWhere}
GROUP BY ${groupByField}
ORDER BY creditsUsedMicro DESC
LIMIT ${BREAKDOWN_QUERY_LIMIT}
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<BreakdownRowMicro>(query, {
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(operationTypes && operationTypes.length > 0
? { operationTypes }
: {}),
...(extraParams ?? {}),
});
return rows.map((row) => ({
key: row.key,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
creditsUsed: convert(row.creditsUsedMicro),
}));
}
@@ -128,12 +195,19 @@ export class UsageAnalyticsService {
workspaceId,
periodStart,
periodEnd,
operationTypes,
useDollarMode = false,
extraWhere = '',
extraParams,
}: PeriodParams & {
extraWhere?: string;
extraParams?: Record<string, unknown>;
}): Promise<UsageTimeSeriesPoint[]> {
const opTypeFilter =
operationTypes && operationTypes.length > 0
? 'AND operationType IN ({operationTypes:Array(String)})'
: '';
const query = `
SELECT
formatDateTime(timestamp, '%Y-%m-%d') AS date,
@@ -142,24 +216,30 @@ export class UsageAnalyticsService {
WHERE workspaceId = {workspaceId:String}
AND timestamp >= {periodStart:String}
AND timestamp < {periodEnd:String}
${opTypeFilter}
${extraWhere}
GROUP BY date
ORDER BY date ASC
`;
const convert = useDollarMode ? toDollars : toDisplayCredits;
const rows = await this.clickHouseService.select<TimeSeriesRowMicro>(
query,
{
workspaceId,
periodStart: formatDateForClickHouse(periodStart),
periodEnd: formatDateForClickHouse(periodEnd),
...(operationTypes && operationTypes.length > 0
? { operationTypes }
: {}),
...(extraParams ?? {}),
},
);
return rows.map((row) => ({
date: row.date,
creditsUsed: toDisplayCredits(row.creditsUsedMicro),
creditsUsed: convert(row.creditsUsedMicro),
}));
}
}
@@ -17,6 +17,7 @@ import {
} from 'src/engine/core-modules/usage/services/usage-analytics.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -34,6 +35,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
export class UsageResolver {
constructor(
private readonly usageAnalyticsService: UsageAnalyticsService,
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@@ -56,21 +58,26 @@ export class UsageResolver {
const periodStart = input?.periodStart ?? defaultPeriodStart;
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
const useDollarMode = !this.twentyConfigService.get('IS_BILLING_ENABLED');
const periodParams = {
workspaceId: workspace.id,
periodStart,
periodEnd,
operationTypes: input?.operationTypes ?? undefined,
useDollarMode,
};
const [usageByUser, usageByOperationType, timeSeries] = await Promise.all([
this.usageAnalyticsService.getUsageByUser(periodParams),
this.usageAnalyticsService.getUsageByOperationType({
...periodParams,
userWorkspaceId: input?.userWorkspaceId ?? undefined,
}),
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
]);
const [usageByUser, usageByOperationType, usageByModel, timeSeries] =
await Promise.all([
this.usageAnalyticsService.getUsageByUser(periodParams),
this.usageAnalyticsService.getUsageByOperationType({
...periodParams,
userWorkspaceId: input?.userWorkspaceId ?? undefined,
}),
this.usageAnalyticsService.getUsageByModel(periodParams),
this.usageAnalyticsService.getUsageTimeSeries(periodParams),
]);
const resolvedUsageByUser = await this.resolveBreakdownKeys(
usageByUser,
@@ -80,6 +87,7 @@ export class UsageResolver {
const result: UsageAnalyticsDTO = {
usageByUser: resolvedUsageByUser,
usageByOperationType,
usageByModel,
timeSeries,
periodStart,
periodEnd,
@@ -0,0 +1,6 @@
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai/ai-billing/constants/dollar-to-credit-multiplier';
// Converts internal micro-credits to dollars.
// Rounds to 2 decimal places (e.g. 7500 → 0.01).
export const toDollars = (internalCredits: number): number =>
Math.round((internalCredits / DOLLAR_TO_CREDIT_MULTIPLIER) * 100) / 100;
@@ -330,6 +330,7 @@ describe('AiBillingService', () => {
'gpt-4o',
{ usage: mockTokenUsage },
'workspace-1',
UsageOperationType.AI_CHAT_TOKEN,
'agent-id-123',
);
@@ -340,7 +341,7 @@ describe('AiBillingService', () => {
[
{
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_TOKEN,
operationType: UsageOperationType.AI_CHAT_TOKEN,
creditsUsedMicro: 7500,
quantity: 1500,
unit: UsageUnit.TOKEN,
@@ -55,6 +55,7 @@ export class AiBillingService {
modelId: ModelId,
billingInput: BillingUsageInput,
workspaceId: string,
operationType: UsageOperationType,
agentId?: string | null,
userWorkspaceId?: string | null,
): void {
@@ -73,6 +74,7 @@ export class AiBillingService {
creditsUsedMicro,
totalTokens,
modelId,
operationType,
agentId,
userWorkspaceId,
);
@@ -83,6 +85,7 @@ export class AiBillingService {
creditsUsedMicro: number,
totalTokens: number,
modelId: ModelId,
operationType: UsageOperationType,
agentId?: string | null,
userWorkspaceId?: string | null,
): void {
@@ -91,7 +94,7 @@ export class AiBillingService {
[
{
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_TOKEN,
operationType,
creditsUsedMicro,
quantity: totalTokens,
unit: UsageUnit.TOKEN,
@@ -13,6 +13,8 @@ import {
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -251,6 +253,7 @@ export class ChatExecutionService {
registeredModel.modelId,
{ usage, cacheCreationTokens },
workspace.id,
UsageOperationType.AI_CHAT_TOKEN,
null,
userWorkspaceId,
);
@@ -9,6 +9,7 @@ import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/inte
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
import {
WorkflowStepExecutorException,
@@ -93,6 +94,7 @@ export class AiAgentWorkflowAction implements WorkflowAction {
agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,
{ usage, cacheCreationTokens },
workspaceId,
UsageOperationType.AI_WORKFLOW_TOKEN,
agent?.id || null,
userWorkspaceId,
);
@@ -28,7 +28,7 @@ const buildUsageEventRow = (
workspaceId,
userWorkspaceId: overrides.userWorkspaceId ?? '',
resourceType: overrides.resourceType ?? UsageResourceType.AI,
operationType: overrides.operationType ?? UsageOperationType.AI_TOKEN,
operationType: overrides.operationType ?? UsageOperationType.AI_CHAT_TOKEN,
quantity: overrides.quantity ?? 0,
unit: overrides.unit ?? UsageUnit.TOKEN,
creditsUsedMicro: overrides.creditsUsedMicro ?? 0,
@@ -66,7 +66,7 @@ describe('ClickHouse Usage Event Writer (integration)', () => {
const row = buildUsageEventRow(workspaceId, {
userWorkspaceId: '00000000-0000-0000-0000-000000000002',
resourceType: UsageResourceType.AI,
operationType: UsageOperationType.AI_TOKEN,
operationType: UsageOperationType.AI_CHAT_TOKEN,
quantity: 1500,
unit: UsageUnit.TOKEN,
creditsUsedMicro: 7500,
@@ -94,7 +94,7 @@ describe('ClickHouse Usage Event Writer (integration)', () => {
expect(rows).toHaveLength(1);
expect(rows[0].workspaceId).toBe(workspaceId);
expect(rows[0].resourceType).toBe(UsageResourceType.AI);
expect(rows[0].operationType).toBe(UsageOperationType.AI_TOKEN);
expect(rows[0].operationType).toBe(UsageOperationType.AI_CHAT_TOKEN);
expect(rows[0].quantity).toBe(1500);
expect(rows[0].unit).toBe(UsageUnit.TOKEN);
expect(rows[0].creditsUsedMicro).toBe(7500);
@@ -10,8 +10,8 @@ export enum SettingsPath {
NewImapSmtpCaldavConnection = 'accounts/new-imap-smtp-caldav-connection',
EditImapSmtpCaldavConnection = 'accounts/edit-imap-smtp-caldav-connection/:connectedAccountId',
Billing = 'billing',
Usage = 'usage',
UsageUserDetail = 'usage/user/:userWorkspaceId',
Usage = 'billing/usage',
UsageUserDetail = 'billing/usage/user/:userWorkspaceId',
Enterprise = 'enterprise',
Objects = 'objects',
ObjectOverview = 'objects/overview',
@@ -32,6 +32,7 @@ export enum SettingsPath {
EmailingDomainDetail = 'domains/emailing-domain/:domainId',
Updates = 'updates',
AI = 'ai',
AIUsageUserDetail = 'ai/usage/user/:userWorkspaceId',
AIPrompts = 'ai/prompts',
AINewAgent = 'ai/new-agent',
AIAgentDetail = 'ai/agents/:agentId',
@@ -17,8 +17,7 @@ export type StyledBarProps = {
};
const StyledBar = styled.div<StyledBarProps>`
height: 100%;
min-height: ${themeCssVariables.spacing[2]};
height: ${themeCssVariables.spacing[2]};
background-color: ${({ backgroundColor }) => backgroundColor ?? ''};
border-radius: ${({ withBorderRadius }) =>
withBorderRadius ? themeCssVariables.border.radius.xxl : '0'};
@@ -38,6 +37,8 @@ const StyledBarFilling = styled.div<{
width: 100%;
`;
const MIN_BAR_WIDTH_PX = 12;
export const ProgressBar = ({
value,
className,
@@ -53,7 +54,10 @@ export const ProgressBar = ({
aria-valuenow={Math.ceil(value)}
>
<motion.div
style={{ height: '100%' }}
style={{
height: '100%',
minWidth: value > 0 ? MIN_BAR_WIDTH_PX : 0,
}}
animate={{ width: `${Math.ceil(value)}%` }}
transition={{ duration: 0.3, ease: 'linear' }}
>