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:
@@ -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
+18
-13
@@ -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 />}
|
||||
|
||||
+1
-2
@@ -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}
|
||||
|
||||
+3
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+109
-1
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+1
-2
@@ -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
|
||||
}
|
||||
|
||||
+44
@@ -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>
|
||||
);
|
||||
};
|
||||
+20
-3
@@ -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,
|
||||
|
||||
+45
-237
@@ -1,129 +1,23 @@
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { SettingsBillingLabelValueItem } from '@/settings/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graph-widget-line-chart/components/GraphWidgetLineChart';
|
||||
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { getOperationTypeLabel } from '@/settings/usage/utils/getOperationTypeLabel';
|
||||
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
|
||||
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
|
||||
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
|
||||
import { UsagePieChart } from '@/settings/usage/components/UsagePieChart';
|
||||
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
|
||||
import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection';
|
||||
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { H2Title, IconSparkles } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { GetUsageAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledIconChevronRightContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const StyledLineChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
|
||||
import { SETTINGS_AI_TABS } from '~/pages/settings/ai/constants/SettingsAiTabs';
|
||||
|
||||
export const SettingsUsageAnalyticsSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
|
||||
|
||||
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
|
||||
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
|
||||
const [userPeriod, setUserPeriod] = useState<PeriodPreset>('30d');
|
||||
const [userSearchTerm, setUserSearchTerm] = useState('');
|
||||
|
||||
const colorRegistry = createGraphColorRegistry(theme.color);
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const typeDates = getPeriodDates(typePeriod);
|
||||
const dailyDates = getPeriodDates(dailyPeriod);
|
||||
const userDates = getPeriodDates(userPeriod);
|
||||
|
||||
const { data: typeData, loading: typeLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: typeDates } },
|
||||
);
|
||||
|
||||
const { data: dailyData, loading: dailyLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: dailyDates } },
|
||||
);
|
||||
|
||||
const { data: userData, loading: userLoading } = useQuery(
|
||||
GetUsageAnalyticsDocument,
|
||||
{ variables: { input: userDates } },
|
||||
);
|
||||
|
||||
const typeAnalytics = typeData?.getUsageAnalytics;
|
||||
const dailyAnalytics = dailyData?.getUsageAnalytics;
|
||||
const userAnalytics = userData?.getUsageAnalytics;
|
||||
|
||||
const usageByOperationType = typeAnalytics?.usageByOperationType ?? [];
|
||||
const timeSeries = dailyAnalytics?.timeSeries ?? [];
|
||||
const usageByUser = userAnalytics?.usageByUser ?? [];
|
||||
|
||||
const anyLoading = typeLoading || dailyLoading || userLoading;
|
||||
|
||||
if (anyLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasAnyData =
|
||||
usageByOperationType.length > 0 ||
|
||||
timeSeries.length > 0 ||
|
||||
usageByUser.length > 0;
|
||||
|
||||
const totalCredits = usageByOperationType.reduce(
|
||||
(sum, item) => sum + item.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const filteredUsageByUser = usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(userSearchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
});
|
||||
|
||||
const pieData = usageByOperationType.map((item, index) => ({
|
||||
id: getOperationTypeLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
color: getColorSchemeByIndex(colorRegistry, index).solid,
|
||||
}));
|
||||
|
||||
const lineData: LineChartSeriesWithColor[] = [
|
||||
{
|
||||
id: 'credits',
|
||||
label: t`Credits`,
|
||||
data: timeSeries.map((point) => ({
|
||||
x: formatDate(point.date, 'MMM d'),
|
||||
y: point.creditsUsed,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasAnyData) {
|
||||
if (!isClickHouseConfigured) {
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
@@ -132,8 +26,8 @@ export const SettingsUsageAnalyticsSection = () => {
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<SettingsBillingLabelValueItem
|
||||
label={t`No usage data`}
|
||||
value={t`No credit consumption recorded yet.`}
|
||||
label={t`ClickHouse Not Configured`}
|
||||
value={t`Usage analytics requires ClickHouse. Contact your administrator.`}
|
||||
/>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
@@ -142,125 +36,39 @@ export const SettingsUsageAnalyticsSection = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{usageByOperationType.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by Type`}
|
||||
description={t`${formatNumber(totalCredits)} credits`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-type-period"
|
||||
value={typePeriod}
|
||||
options={periodOptions}
|
||||
onChange={setTypePeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
<UsageBreakdownPieSection
|
||||
title={t`Usage by Type`}
|
||||
breakdownField="operationType"
|
||||
sectionId="usage-type"
|
||||
/>
|
||||
<UsageDailyChartSection
|
||||
title={t`Daily Usage`}
|
||||
description={t`Credit consumption over time.`}
|
||||
chartId="usage-daily"
|
||||
chartLabel={t`Credits`}
|
||||
/>
|
||||
<UsageByUserTableSection
|
||||
title={t`Usage by User`}
|
||||
description={t`Click a user to see their daily breakdown.`}
|
||||
getDetailPath={(userWorkspaceId) =>
|
||||
getSettingsPath(SettingsPath.UsageUserDetail, {
|
||||
userWorkspaceId,
|
||||
})
|
||||
}
|
||||
showAvatar
|
||||
/>
|
||||
<Section>
|
||||
<Link
|
||||
to={`${getSettingsPath(SettingsPath.AI)}#${SETTINGS_AI_TABS.TABS_IDS.USAGE}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<Button
|
||||
Icon={IconSparkles}
|
||||
title={t`View AI usage breakdown`}
|
||||
variant="secondary"
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<UsagePieChart data={pieData} />
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{timeSeries.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Daily Usage`}
|
||||
description={t`Credit consumption over time.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-daily-period"
|
||||
value={dailyPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setDailyPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledLineChartContainer>
|
||||
<GraphWidgetLineChart
|
||||
id="usage-daily-line-chart"
|
||||
data={lineData}
|
||||
colorMode="automaticPalette"
|
||||
showLegend={false}
|
||||
enableArea
|
||||
/>
|
||||
</StyledLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{usageByUser.length > 0 && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage by User`}
|
||||
description={t`Click a user to see their daily breakdown.`}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId="usage-user-period"
|
||||
value={userPeriod}
|
||||
options={periodOptions}
|
||||
onChange={setUserPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledSearchInputContainer>
|
||||
<SearchInput
|
||||
placeholder={t`Search for a user...`}
|
||||
value={userSearchTerm}
|
||||
onChange={setUserSearchTerm}
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
<Table>
|
||||
<TableRow
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader align="right">{t`Credits`}</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
{filteredUsageByUser.map((item) => (
|
||||
<TableRow
|
||||
key={item.key}
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
to={getSettingsPath(SettingsPath.UsageUserDetail, {
|
||||
userWorkspaceId: item.key,
|
||||
})}
|
||||
>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={themeCssVariables.spacing[2]}
|
||||
>
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="md"
|
||||
placeholder={item.label ?? item.key}
|
||||
placeholderColorSeed={item.key}
|
||||
/>
|
||||
{item.label ?? item.key}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatNumber(item.creditsUsed)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
)}
|
||||
</Link>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
|
||||
import { getColorSchemeByIndex } from '@/page-layout/widgets/graph/utils/getColorSchemeByIndex';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { UsagePieChart } from '@/settings/usage/components/UsagePieChart';
|
||||
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
|
||||
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
|
||||
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
|
||||
import { getOperationTypeLabel } from '@/settings/usage/utils/getOperationTypeLabel';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { useContext } from 'react';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { type UsageOperationType } from '~/generated-metadata/graphql';
|
||||
|
||||
type UsageBreakdownField = 'operationType' | 'model';
|
||||
|
||||
type UsageBreakdownPieSectionProps = {
|
||||
title: string;
|
||||
description?: string;
|
||||
operationTypes?: UsageOperationType[];
|
||||
userWorkspaceId?: string;
|
||||
skip?: boolean;
|
||||
breakdownField: UsageBreakdownField;
|
||||
sectionId: string;
|
||||
};
|
||||
|
||||
export const UsageBreakdownPieSection = ({
|
||||
title,
|
||||
description,
|
||||
operationTypes,
|
||||
userWorkspaceId,
|
||||
skip,
|
||||
breakdownField,
|
||||
sectionId,
|
||||
}: UsageBreakdownPieSectionProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatUsageValue } = useUsageValueFormatter();
|
||||
const colorRegistry = createGraphColorRegistry(theme.color);
|
||||
|
||||
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
|
||||
useUsageAnalyticsData({
|
||||
operationTypes,
|
||||
userWorkspaceId,
|
||||
skip,
|
||||
});
|
||||
|
||||
if (isInitialLoading) {
|
||||
return <UsageSectionSkeleton />;
|
||||
}
|
||||
|
||||
if (!analytics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const breakdownData =
|
||||
breakdownField === 'operationType'
|
||||
? analytics.usageByOperationType
|
||||
: analytics.usageByModel;
|
||||
|
||||
if (breakdownData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = breakdownData.reduce((sum, item) => sum + item.creditsUsed, 0);
|
||||
|
||||
const formatLabel =
|
||||
breakdownField === 'operationType'
|
||||
? getOperationTypeLabel
|
||||
: (key: string) => key;
|
||||
|
||||
const pieData = breakdownData.map((item, index) => ({
|
||||
id: formatLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
color: getColorSchemeByIndex(colorRegistry, index).solid,
|
||||
}));
|
||||
|
||||
const resolvedDescription = description ?? formatUsageValue(total);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={title}
|
||||
description={resolvedDescription}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId={`${sectionId}-period`}
|
||||
value={period}
|
||||
options={periodOptions}
|
||||
onChange={setPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<UsagePieChart data={pieData} />
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
|
||||
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
|
||||
import { useUsageValueFormatter } from '@/settings/usage/hooks/useUsageValueFormatter';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Avatar, H2Title, IconChevronRight } from 'twenty-ui/display';
|
||||
import { SearchInput } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { type UsageOperationType } from '~/generated-metadata/graphql';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledIconChevronRightContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
|
||||
|
||||
type UsageByUserTableSectionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
operationTypes?: UsageOperationType[];
|
||||
skip?: boolean;
|
||||
getDetailPath: (userWorkspaceId: string) => string;
|
||||
showAvatar?: boolean;
|
||||
};
|
||||
|
||||
export const UsageByUserTableSection = ({
|
||||
title,
|
||||
description,
|
||||
operationTypes,
|
||||
skip,
|
||||
getDetailPath,
|
||||
showAvatar = false,
|
||||
}: UsageByUserTableSectionProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatUsageValue } = useUsageValueFormatter();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
|
||||
useUsageAnalyticsData({
|
||||
operationTypes,
|
||||
skip,
|
||||
});
|
||||
|
||||
if (isInitialLoading) {
|
||||
return <UsageSectionSkeleton />;
|
||||
}
|
||||
|
||||
if (!analytics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usageByUser = analytics.usageByUser;
|
||||
|
||||
if (usageByUser.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredUsers = usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(searchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={title}
|
||||
description={description}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId={`${title.replace(/\s+/g, '-').toLowerCase()}-period`}
|
||||
value={period}
|
||||
options={periodOptions}
|
||||
onChange={setPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledSearchInputContainer>
|
||||
<SearchInput
|
||||
placeholder={t`Search for a user...`}
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
<Table>
|
||||
<TableRow gridTemplateColumns={GRID_TEMPLATE_COLUMNS}>
|
||||
<TableHeader>{t`Name`}</TableHeader>
|
||||
<TableHeader align="right">{t`Usage`}</TableHeader>
|
||||
<TableHeader />
|
||||
</TableRow>
|
||||
{filteredUsers.map((item) => (
|
||||
<TableRow
|
||||
key={item.key}
|
||||
gridTemplateColumns={GRID_TEMPLATE_COLUMNS}
|
||||
to={getDetailPath(item.key)}
|
||||
>
|
||||
<TableCell
|
||||
color={themeCssVariables.font.color.primary}
|
||||
gap={showAvatar ? themeCssVariables.spacing[2] : undefined}
|
||||
>
|
||||
{showAvatar && (
|
||||
<Avatar
|
||||
type="rounded"
|
||||
size="md"
|
||||
placeholder={item.label ?? item.key}
|
||||
placeholderColorSeed={item.key}
|
||||
/>
|
||||
)}
|
||||
{item.label ?? item.key}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{formatUsageValue(item.creditsUsed)}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<StyledIconChevronRightContainer>
|
||||
<IconChevronRight
|
||||
size={theme.icon.size.md}
|
||||
stroke={theme.icon.stroke.sm}
|
||||
/>
|
||||
</StyledIconChevronRightContainer>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</Table>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graph-widget-line-chart/components/GraphWidgetLineChart';
|
||||
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graph-widget-line-chart/types/LineChartSeriesWithColor';
|
||||
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
|
||||
import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer';
|
||||
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
|
||||
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { styled } from '@linaria/react';
|
||||
import { H2Title } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { type UsageOperationType } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
|
||||
const StyledLineChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
type UsageDailyChartSectionProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
chartId: string;
|
||||
chartLabel: string;
|
||||
operationTypes?: UsageOperationType[];
|
||||
userWorkspaceId?: string;
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const UsageDailyChartSection = ({
|
||||
title,
|
||||
description,
|
||||
chartId,
|
||||
chartLabel,
|
||||
operationTypes,
|
||||
userWorkspaceId,
|
||||
skip,
|
||||
}: UsageDailyChartSectionProps) => {
|
||||
const { analytics, isInitialLoading, period, setPeriod, periodOptions } =
|
||||
useUsageAnalyticsData({
|
||||
operationTypes,
|
||||
userWorkspaceId,
|
||||
skip,
|
||||
});
|
||||
|
||||
if (isInitialLoading) {
|
||||
return <UsageSectionSkeleton />;
|
||||
}
|
||||
|
||||
if (!analytics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeSeries = userWorkspaceId
|
||||
? (analytics.userDailyUsage?.dailyUsage ?? [])
|
||||
: analytics.timeSeries;
|
||||
|
||||
if (timeSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineData: LineChartSeriesWithColor[] = [
|
||||
{
|
||||
id: chartId,
|
||||
label: chartLabel,
|
||||
data: timeSeries.map((point) => ({
|
||||
x: formatDate(point.date, 'MMM d'),
|
||||
y: point.creditsUsed,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={title}
|
||||
description={description}
|
||||
adornment={
|
||||
<Select
|
||||
dropdownId={`${chartId}-period`}
|
||||
value={period}
|
||||
options={periodOptions}
|
||||
onChange={setPeriod}
|
||||
needIconCheck
|
||||
selectSizeVariant="small"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledLineChartContainer>
|
||||
<WidgetComponentInstanceContext.Provider
|
||||
value={{ instanceId: `${chartId}-line-chart` }}
|
||||
>
|
||||
<GraphWidgetLineChart
|
||||
id={`${chartId}-line-chart`}
|
||||
data={lineData}
|
||||
colorMode="automaticPalette"
|
||||
showLegend={false}
|
||||
enableArea
|
||||
/>
|
||||
</WidgetComponentInstanceContext.Provider>
|
||||
</StyledLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,11 @@
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { GraphWidgetLegendDot } from '@/page-layout/widgets/graph/components/GraphWidgetLegendDot';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { styled } from '@linaria/react';
|
||||
import { ResponsivePie } from '@nivo/pie';
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
type UsagePieChartDatum = {
|
||||
id: string;
|
||||
@@ -21,6 +22,35 @@ const StyledContainer = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTooltip = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.light};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
box-shadow: ${themeCssVariables.boxShadow.strong};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
padding: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledTooltipRow = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
gap: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
const StyledTooltipLabel = styled.span`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledTooltipValue = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
export const UsagePieChart = ({ data }: UsagePieChartProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
@@ -44,7 +74,15 @@ export const UsagePieChart = ({ data }: UsagePieChartProps) => {
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<div>{`${String(datum.id)}: ${t`${formatNumber(datum.value)} credits`}`}</div>
|
||||
<StyledTooltip>
|
||||
<StyledTooltipRow>
|
||||
<GraphWidgetLegendDot color={datum.color} />
|
||||
<StyledTooltipLabel>{String(datum.id)}</StyledTooltipLabel>
|
||||
<StyledTooltipValue>
|
||||
{t`${formatNumber(datum.value)} credits`}
|
||||
</StyledTooltipValue>
|
||||
</StyledTooltipRow>
|
||||
</StyledTooltip>
|
||||
)}
|
||||
/>
|
||||
</StyledContainer>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext } from 'twenty-ui/theme-constants';
|
||||
|
||||
export const UsageSectionSkeleton = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<SkeletonTheme
|
||||
baseColor={theme.background.tertiary}
|
||||
highlightColor={theme.background.transparent.lighter}
|
||||
borderRadius={4}
|
||||
>
|
||||
<Section>
|
||||
<Skeleton width={160} height={16} />
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={200}
|
||||
borderRadius={8}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
</Section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { UsageOperationType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const AI_OPERATION_TYPES: UsageOperationType[] = [
|
||||
UsageOperationType.AI_CHAT_TOKEN,
|
||||
UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
];
|
||||
@@ -12,6 +12,10 @@ export const GET_USAGE_ANALYTICS = gql`
|
||||
key
|
||||
creditsUsed
|
||||
}
|
||||
usageByModel {
|
||||
key
|
||||
creditsUsed
|
||||
}
|
||||
timeSeries {
|
||||
date
|
||||
creditsUsed
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { getPeriodDates } from '@/settings/usage/utils/getPeriodDates';
|
||||
import { getPeriodOptions } from '@/settings/usage/utils/getPeriodOptions';
|
||||
import { type PeriodPreset } from '@/settings/usage/utils/periodPreset';
|
||||
import {
|
||||
GetUsageAnalyticsDocument,
|
||||
type UsageOperationType,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type UseUsageAnalyticsDataParams = {
|
||||
operationTypes?: UsageOperationType[];
|
||||
userWorkspaceId?: string;
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const useUsageAnalyticsData = ({
|
||||
operationTypes,
|
||||
userWorkspaceId,
|
||||
skip,
|
||||
}: UseUsageAnalyticsDataParams) => {
|
||||
const [period, setPeriod] = useState<PeriodPreset>('30d');
|
||||
|
||||
const periodDates = getPeriodDates(period);
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const { data, loading, previousData } = useQuery(GetUsageAnalyticsDocument, {
|
||||
variables: {
|
||||
input: {
|
||||
...periodDates,
|
||||
...(operationTypes ? { operationTypes } : {}),
|
||||
...(userWorkspaceId ? { userWorkspaceId } : {}),
|
||||
},
|
||||
},
|
||||
skip,
|
||||
});
|
||||
|
||||
const effectiveData = data ?? previousData;
|
||||
const analytics = effectiveData?.getUsageAnalytics;
|
||||
const isInitialLoading = loading && !effectiveData;
|
||||
|
||||
return {
|
||||
analytics,
|
||||
loading,
|
||||
isInitialLoading,
|
||||
period,
|
||||
setPeriod,
|
||||
periodOptions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { billingState } from '@/client-config/states/billingState';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
|
||||
export const useUsageValueFormatter = () => {
|
||||
const billing = useAtomStateValue(billingState);
|
||||
const isBillingEnabled = billing?.isBillingEnabled ?? false;
|
||||
const { formatNumber } = useNumberFormat();
|
||||
|
||||
const formatUsageValue = (value: number): string => {
|
||||
if (isBillingEnabled) {
|
||||
return `${formatNumber(value)} credits`;
|
||||
}
|
||||
|
||||
return `$${formatNumber(value, { decimals: 2 })}`;
|
||||
};
|
||||
|
||||
const unitLabel = isBillingEnabled ? 'credits' : '$';
|
||||
|
||||
return { formatUsageValue, isBillingEnabled, unitLabel };
|
||||
};
|
||||
@@ -2,8 +2,10 @@ import { t } from '@lingui/core/macro';
|
||||
|
||||
export const getOperationTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'AI_TOKEN':
|
||||
case 'AI_CHAT_TOKEN':
|
||||
return t`AI Chat`;
|
||||
case 'AI_WORKFLOW_TOKEN':
|
||||
return t`AI Workflow`;
|
||||
case 'WORKFLOW_EXECUTION':
|
||||
return t`Workflow Execution`;
|
||||
case 'CODE_EXECUTION':
|
||||
|
||||
+1
-2
@@ -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); // 9am–6pm
|
||||
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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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[];
|
||||
|
||||
|
||||
+2
-1
@@ -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',
|
||||
}
|
||||
|
||||
+82
-2
@@ -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;
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+3
@@ -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,
|
||||
);
|
||||
|
||||
+2
@@ -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,
|
||||
);
|
||||
|
||||
+3
-3
@@ -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' }}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user