refactor(billing): clean up analytics code, fix security, deduplicate
- Extract shared billing analytics utils (PeriodPreset, getExecutionTypeLabel, getPeriodDates, getPeriodOptions, getChartColors) into billing-analytics.utils.ts - Extract BillingChartTooltip component to replace 4 duplicated inline tooltip styles - Extract BillingLineChartContainer / BillingPieChartContainer shared styled components - Fix resourceTotal recomputed inside .map() on every iteration - Restrict ClickHouse groupByField to union type (SQL injection prevention) - Remove unused Logger in BillingAnalyticsService - Extract BREAKDOWN_QUERY_LIMIT constant from magic number - Deduplicate toDisplayCredits mapping into private mapToDisplayCredits helper - Extract getDefaultAnalyticsPeriod to eliminate let declarations in resolver - Remove redundant return statement in validateCanCheckoutSessionPermissionOrThrow - Validate userWorkspaceId belongs to current workspace before querying (security) - Scope resolveUserNames by workspaceId to prevent cross-workspace PII leakage - Split monolithic billing-usage-breakdown.dto.ts into one-export-per-file DTOs - Merge duplicate FeatureFlagKey import in SettingsBillingContent - Fix TableCell cursor inheritance for clickable table rows - Fix SubscriptionInfoContainer box-sizing for layout alignment - Gate usage analytics UI behind IS_USAGE_ANALYTICS_ENABLED feature flag Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
export const BillingLineChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
export const BillingPieChartContainer = styled.div`
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledTooltip = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
box-shadow: ${themeCssVariables.boxShadow.light};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
|
||||
export const BillingChartTooltip = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<StyledTooltip>
|
||||
<strong>{label}</strong>: {value}
|
||||
</StyledTooltip>
|
||||
);
|
||||
+42
-136
@@ -1,5 +1,17 @@
|
||||
import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip';
|
||||
import {
|
||||
BillingLineChartContainer,
|
||||
BillingPieChartContainer,
|
||||
} from '@/billing/components/BillingChartContainers';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getChartColors,
|
||||
getExecutionTypeLabel,
|
||||
getPeriodDates,
|
||||
getPeriodOptions,
|
||||
} from '@/billing/utils/billing-analytics.utils';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { useLineChartTheme } from '@/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartTheme';
|
||||
@@ -25,21 +37,6 @@ import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
import { normalizeSearchText } from '~/utils/normalizeSearchText';
|
||||
|
||||
const getExecutionTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'ai_token':
|
||||
return t`AI Chat`;
|
||||
case 'workflow_execution':
|
||||
return t`Workflow Execution`;
|
||||
case 'code_execution':
|
||||
return t`Code Execution`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
type PeriodPreset = '7d' | '30d' | '90d';
|
||||
|
||||
const StyledBarRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -68,16 +65,6 @@ const StyledValueText = styled.span`
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledPieChartContainer = styled.div`
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSearchInputContainer = styled.div`
|
||||
padding-bottom: ${themeCssVariables.spacing[2]};
|
||||
`;
|
||||
@@ -86,61 +73,8 @@ const StyledIconChevronRightContainer = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
`;
|
||||
|
||||
const StyledClickableTable = styled(Table)`
|
||||
a > * {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
const USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS = '1fr 120px 36px';
|
||||
|
||||
const getChartColors = (theme: {
|
||||
color: {
|
||||
blue: string;
|
||||
purple: string;
|
||||
turquoise: string;
|
||||
orange: string;
|
||||
pink: string;
|
||||
green: string;
|
||||
};
|
||||
}): string[] => [
|
||||
theme.color.blue,
|
||||
theme.color.purple,
|
||||
theme.color.turquoise,
|
||||
theme.color.orange,
|
||||
theme.color.pink,
|
||||
theme.color.green,
|
||||
];
|
||||
|
||||
const getPeriodDates = (
|
||||
preset: PeriodPreset,
|
||||
): { periodStart: string; periodEnd: string } => {
|
||||
const now = new Date();
|
||||
const daysMap: Record<PeriodPreset, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
const start = new Date(now);
|
||||
|
||||
start.setDate(start.getDate() - daysMap[preset]);
|
||||
|
||||
return {
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: now.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const usePeriodOptions = () =>
|
||||
useMemo(
|
||||
() => [
|
||||
{ value: '7d' as const, label: t`Last 7 days` },
|
||||
{ value: '30d' as const, label: t`Last 30 days` },
|
||||
{ value: '90d' as const, label: t`Last 90 days` },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
export const SettingsBillingAnalyticsSection = () => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
@@ -151,9 +85,9 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
const [resourcePeriod, setResourcePeriod] = useState<PeriodPreset>('30d');
|
||||
const [userSearchTerm, setUserSearchTerm] = useState('');
|
||||
|
||||
const chartColors = useMemo(() => getChartColors(theme), [theme]);
|
||||
const chartColors = getChartColors(theme);
|
||||
const lineChartTheme = useLineChartTheme();
|
||||
const periodOptions = usePeriodOptions();
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]);
|
||||
const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]);
|
||||
@@ -193,17 +127,6 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
const usageByUser = userAnalytics?.usageByUser ?? [];
|
||||
const usageByResource = resourceAnalytics?.usageByResource ?? [];
|
||||
|
||||
const filteredUsageByUser = useMemo(
|
||||
() =>
|
||||
usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(userSearchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
}),
|
||||
[usageByUser, userSearchTerm],
|
||||
);
|
||||
|
||||
const allLoading =
|
||||
typeLoading && dailyLoading && userLoading && resourceLoading;
|
||||
|
||||
@@ -222,6 +145,18 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
0,
|
||||
);
|
||||
|
||||
const resourceTotal = usageByResource.reduce(
|
||||
(sum, resource) => sum + resource.creditsUsed,
|
||||
0,
|
||||
);
|
||||
|
||||
const filteredUsageByUser = usageByUser.filter((item) => {
|
||||
const search = normalizeSearchText(userSearchTerm);
|
||||
const name = normalizeSearchText(item.label ?? item.key);
|
||||
|
||||
return name.includes(search);
|
||||
});
|
||||
|
||||
const pieData = usageByExecutionType.map((item, index) => ({
|
||||
id: getExecutionTypeLabel(item.key),
|
||||
value: item.creditsUsed,
|
||||
@@ -274,7 +209,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledPieChartContainer>
|
||||
<BillingPieChartContainer>
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
@@ -292,23 +227,13 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<div
|
||||
style={{
|
||||
background: theme.background.primary,
|
||||
border: `1px solid ${theme.border.color.medium}`,
|
||||
borderRadius: theme.border.radius.sm,
|
||||
padding: '6px 10px',
|
||||
fontSize: theme.font.size.sm,
|
||||
color: theme.font.color.primary,
|
||||
boxShadow: theme.boxShadow.light,
|
||||
}}
|
||||
>
|
||||
<strong>{datum.id}</strong>: {formatNumber(datum.value)}{' '}
|
||||
{t`credits`}
|
||||
</div>
|
||||
<BillingChartTooltip
|
||||
label={String(datum.id)}
|
||||
value={`${formatNumber(datum.value)} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledPieChartContainer>
|
||||
</BillingPieChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
@@ -330,7 +255,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledChartContainer>
|
||||
<BillingLineChartContainer>
|
||||
<ResponsiveLine
|
||||
data={lineData}
|
||||
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
|
||||
@@ -361,9 +286,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
? lineData[0].data
|
||||
.filter(
|
||||
(_, index) =>
|
||||
index %
|
||||
Math.ceil(timeSeries.length / 7) ===
|
||||
0,
|
||||
index % Math.ceil(timeSeries.length / 7) === 0,
|
||||
)
|
||||
.map((point) => point.x)
|
||||
: undefined,
|
||||
@@ -378,24 +301,13 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
theme={lineChartTheme}
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => (
|
||||
<div
|
||||
style={{
|
||||
background: theme.background.primary,
|
||||
border: `1px solid ${theme.border.color.medium}`,
|
||||
borderRadius: theme.border.radius.sm,
|
||||
padding: '6px 10px',
|
||||
fontSize: theme.font.size.sm,
|
||||
color: theme.font.color.primary,
|
||||
boxShadow: theme.boxShadow.light,
|
||||
}}
|
||||
>
|
||||
<strong>{slice.points[0]?.data.xFormatted}</strong>:{' '}
|
||||
{formatNumber(Number(slice.points[0]?.data.yFormatted))}{' '}
|
||||
{t`credits`}
|
||||
</div>
|
||||
<BillingChartTooltip
|
||||
label={String(slice.points[0]?.data.xFormatted)}
|
||||
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</BillingLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
@@ -423,7 +335,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
onChange={setUserSearchTerm}
|
||||
/>
|
||||
</StyledSearchInputContainer>
|
||||
<StyledClickableTable>
|
||||
<Table>
|
||||
<TableRow
|
||||
gridTemplateColumns={USAGE_USER_TABLE_GRID_TEMPLATE_COLUMNS}
|
||||
>
|
||||
@@ -464,7 +376,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</StyledClickableTable>
|
||||
</Table>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -486,10 +398,6 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
{usageByResource.map((item, index) => {
|
||||
const resourceTotal = usageByResource.reduce(
|
||||
(sum, resource) => sum + resource.creditsUsed,
|
||||
0,
|
||||
);
|
||||
const percentage =
|
||||
resourceTotal > 0
|
||||
? (item.creditsUsed / resourceTotal) * 100
|
||||
@@ -498,9 +406,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
return (
|
||||
<StyledBarRow key={item.key}>
|
||||
<StyledBarLabel>
|
||||
<StyledLabelText>
|
||||
{item.label ?? item.key}
|
||||
</StyledLabelText>
|
||||
<StyledLabelText>{item.label ?? item.key}</StyledLabelText>
|
||||
<StyledValueText>
|
||||
{formatNumber(item.creditsUsed)} {t`credits`}
|
||||
</StyledValueText>
|
||||
|
||||
@@ -22,9 +22,11 @@ import { useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
SubscriptionStatus,
|
||||
BillingPortalSessionDocument,
|
||||
FeatureFlagKey,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useGetWorkflowNodeExecutionUsage } from '@/billing/hooks/useGetWorkflowNodeExecutionUsage';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
|
||||
export const SettingsBillingContent = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -53,6 +55,10 @@ export const SettingsBillingContent = () => {
|
||||
skip: !hasSubscriptions,
|
||||
});
|
||||
|
||||
const isUsageAnalyticsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_USAGE_ANALYTICS_ENABLED,
|
||||
);
|
||||
|
||||
const billingPortalButtonDisabled =
|
||||
loading || !isDefined(data) || !isDefined(data.billingPortalSession.url);
|
||||
|
||||
@@ -84,20 +90,24 @@ export const SettingsBillingContent = () => {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SettingsBillingAnalyticsSection />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage`}
|
||||
description={t`View detailed usage analytics for your workspace`}
|
||||
/>
|
||||
<Link to={getSettingsPath(SettingsPath.Usage)}>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
{isUsageAnalyticsEnabled && (
|
||||
<>
|
||||
<SettingsBillingAnalyticsSection />
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Usage`}
|
||||
description={t`View detailed usage analytics for your workspace`}
|
||||
/>
|
||||
<Link to={getSettingsPath(SettingsPath.Usage)}>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
</>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Manage billing information`}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { t } from '@lingui/core/macro';
|
||||
|
||||
export type PeriodPreset = '7d' | '30d' | '90d';
|
||||
|
||||
const PERIOD_DAYS: Record<PeriodPreset, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
|
||||
export const getExecutionTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'ai_token':
|
||||
return t`AI Chat`;
|
||||
case 'workflow_execution':
|
||||
return t`Workflow Execution`;
|
||||
case 'code_execution':
|
||||
return t`Code Execution`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
export const getPeriodOptions = (): {
|
||||
value: PeriodPreset;
|
||||
label: string;
|
||||
}[] => [
|
||||
{ value: '7d', label: t`Last 7 days` },
|
||||
{ value: '30d', label: t`Last 30 days` },
|
||||
{ value: '90d', label: t`Last 90 days` },
|
||||
];
|
||||
|
||||
export const getPeriodDates = (
|
||||
preset: PeriodPreset,
|
||||
): { periodStart: string; periodEnd: string } => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
|
||||
start.setDate(start.getDate() - PERIOD_DAYS[preset]);
|
||||
|
||||
return {
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: now.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const CHART_COLORS_KEYS = [
|
||||
'blue',
|
||||
'purple',
|
||||
'turquoise',
|
||||
'orange',
|
||||
'pink',
|
||||
'green',
|
||||
] as const;
|
||||
|
||||
export const getChartColors = (theme: {
|
||||
color: Record<(typeof CHART_COLORS_KEYS)[number], string>;
|
||||
}): string[] => CHART_COLORS_KEYS.map((key) => theme.color[key]);
|
||||
@@ -18,7 +18,7 @@ type TableCellProps = {
|
||||
const StyledTableCell = styled.div<TableCellProps>`
|
||||
align-items: center;
|
||||
color: ${({ color }) => color || themeCssVariables.font.color.secondary};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'default')};
|
||||
cursor: ${({ clickable }) => (clickable === true ? 'pointer' : 'inherit')};
|
||||
display: flex;
|
||||
gap: ${({ gap }) => gap ?? 'normal'};
|
||||
height: ${({ height }) => height ?? themeCssVariables.spacing[8]};
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import { BillingChartTooltip } from '@/billing/components/BillingChartTooltip';
|
||||
import {
|
||||
BillingLineChartContainer,
|
||||
BillingPieChartContainer,
|
||||
} from '@/billing/components/BillingChartContainers';
|
||||
import { SettingsBillingLabelValueItem } from '@/billing/components/internal/SettingsBillingLabelValueItem';
|
||||
import { SubscriptionInfoContainer } from '@/billing/components/SubscriptionInfoContainer';
|
||||
import {
|
||||
type PeriodPreset,
|
||||
getChartColors,
|
||||
getExecutionTypeLabel,
|
||||
getPeriodDates,
|
||||
getPeriodOptions,
|
||||
} from '@/billing/utils/billing-analytics.utils';
|
||||
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
|
||||
import { CHART_MOTION_CONFIG } from '@/page-layout/widgets/graph/constants/ChartMotionConfig';
|
||||
import { useLineChartTheme } from '@/page-layout/widgets/graph/graph-widget-line-chart/hooks/useLineChartTheme';
|
||||
@@ -23,18 +35,6 @@ import { useQuery } from '@apollo/client/react';
|
||||
import { GetBillingAnalyticsDocument } from '~/generated-metadata/graphql';
|
||||
import { formatDate } from '~/utils/date-utils';
|
||||
|
||||
type PeriodPreset = '7d' | '30d' | '90d';
|
||||
|
||||
const StyledChartContainer = styled.div`
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledPieChartContainer = styled.div`
|
||||
height: 220px;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledUserHeader = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@@ -58,75 +58,18 @@ const StyledUserCredits = styled.span`
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
`;
|
||||
|
||||
const getExecutionTypeLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'ai_token':
|
||||
return t`AI Chat`;
|
||||
case 'workflow_execution':
|
||||
return t`Workflow Execution`;
|
||||
case 'code_execution':
|
||||
return t`Code Execution`;
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
const getChartColors = (theme: {
|
||||
color: {
|
||||
blue: string;
|
||||
purple: string;
|
||||
turquoise: string;
|
||||
orange: string;
|
||||
pink: string;
|
||||
green: string;
|
||||
};
|
||||
}): string[] => [
|
||||
theme.color.blue,
|
||||
theme.color.purple,
|
||||
theme.color.turquoise,
|
||||
theme.color.orange,
|
||||
theme.color.pink,
|
||||
theme.color.green,
|
||||
];
|
||||
|
||||
const getPeriodDates = (
|
||||
preset: PeriodPreset,
|
||||
): { periodStart: string; periodEnd: string } => {
|
||||
const now = new Date();
|
||||
const daysMap: Record<PeriodPreset, number> = {
|
||||
'7d': 7,
|
||||
'30d': 30,
|
||||
'90d': 90,
|
||||
};
|
||||
const start = new Date(now);
|
||||
|
||||
start.setDate(start.getDate() - daysMap[preset]);
|
||||
|
||||
return {
|
||||
periodStart: start.toISOString(),
|
||||
periodEnd: now.toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const SettingsUsageUserDetail = () => {
|
||||
const { t: tLingui } = useLingui();
|
||||
const { userWorkspaceId } = useParams<{ userWorkspaceId: string }>();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { formatNumber } = useNumberFormat();
|
||||
const lineChartTheme = useLineChartTheme();
|
||||
const chartColors = useMemo(() => getChartColors(theme), [theme]);
|
||||
const chartColors = getChartColors(theme);
|
||||
|
||||
const [dailyPeriod, setDailyPeriod] = useState<PeriodPreset>('30d');
|
||||
const [typePeriod, setTypePeriod] = useState<PeriodPreset>('30d');
|
||||
|
||||
const periodOptions = useMemo(
|
||||
() => [
|
||||
{ value: '7d' as const, label: t`Last 7 days` },
|
||||
{ value: '30d' as const, label: t`Last 30 days` },
|
||||
{ value: '90d' as const, label: t`Last 90 days` },
|
||||
],
|
||||
[],
|
||||
);
|
||||
const periodOptions = getPeriodOptions();
|
||||
|
||||
const dailyDates = useMemo(() => getPeriodDates(dailyPeriod), [dailyPeriod]);
|
||||
const typeDates = useMemo(() => getPeriodDates(typePeriod), [typePeriod]);
|
||||
@@ -164,12 +107,10 @@ export const SettingsUsageUserDetail = () => {
|
||||
const usageByExecutionType = typeAnalytics?.usageByExecutionType ?? [];
|
||||
|
||||
const userName =
|
||||
dailyAnalytics?.usageByUser?.find(
|
||||
(item) => item.key === userWorkspaceId,
|
||||
)?.label ??
|
||||
typeAnalytics?.usageByUser?.find(
|
||||
(item) => item.key === userWorkspaceId,
|
||||
)?.label;
|
||||
dailyAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
|
||||
?.label ??
|
||||
typeAnalytics?.usageByUser?.find((item) => item.key === userWorkspaceId)
|
||||
?.label;
|
||||
|
||||
const totalCredits = usageByExecutionType.reduce(
|
||||
(sum, item) => sum + item.creditsUsed,
|
||||
@@ -194,7 +135,8 @@ export const SettingsUsageUserDetail = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const isInitialLoading = dailyLoading && typeLoading && !dailyData && !typeData;
|
||||
const isInitialLoading =
|
||||
dailyLoading && typeLoading && !dailyData && !typeData;
|
||||
|
||||
const breadcrumbLinks = [
|
||||
{
|
||||
@@ -252,10 +194,7 @@ export const SettingsUsageUserDetail = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SubMenuTopBarContainer
|
||||
title={tLingui`User Usage`}
|
||||
links={breadcrumbLinks}
|
||||
>
|
||||
<SubMenuTopBarContainer title={tLingui`User Usage`} links={breadcrumbLinks}>
|
||||
<SettingsPageContainer>
|
||||
<StyledUserHeader>
|
||||
<Avatar
|
||||
@@ -300,7 +239,7 @@ export const SettingsUsageUserDetail = () => {
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledChartContainer>
|
||||
<BillingLineChartContainer>
|
||||
<ResponsiveLine
|
||||
data={lineData}
|
||||
margin={{ top: 10, right: 20, bottom: 30, left: 50 }}
|
||||
@@ -331,8 +270,7 @@ export const SettingsUsageUserDetail = () => {
|
||||
? lineData[0].data
|
||||
.filter(
|
||||
(_, index) =>
|
||||
index %
|
||||
Math.ceil(userDailyUsage.length / 7) ===
|
||||
index % Math.ceil(userDailyUsage.length / 7) ===
|
||||
0,
|
||||
)
|
||||
.map((point) => point.x)
|
||||
@@ -348,26 +286,13 @@ export const SettingsUsageUserDetail = () => {
|
||||
theme={lineChartTheme}
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => (
|
||||
<div
|
||||
style={{
|
||||
background: theme.background.primary,
|
||||
border: `1px solid ${theme.border.color.medium}`,
|
||||
borderRadius: theme.border.radius.sm,
|
||||
padding: '6px 10px',
|
||||
fontSize: theme.font.size.sm,
|
||||
color: theme.font.color.primary,
|
||||
boxShadow: theme.boxShadow.light,
|
||||
}}
|
||||
>
|
||||
<strong>{slice.points[0]?.data.xFormatted}</strong>:{' '}
|
||||
{formatNumber(
|
||||
Number(slice.points[0]?.data.yFormatted),
|
||||
)}{' '}
|
||||
{t`credits`}
|
||||
</div>
|
||||
<BillingChartTooltip
|
||||
label={String(slice.points[0]?.data.xFormatted)}
|
||||
value={`${formatNumber(Number(slice.points[0]?.data.yFormatted))} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledChartContainer>
|
||||
</BillingLineChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
@@ -389,7 +314,7 @@ export const SettingsUsageUserDetail = () => {
|
||||
}
|
||||
/>
|
||||
<SubscriptionInfoContainer>
|
||||
<StyledPieChartContainer>
|
||||
<BillingPieChartContainer>
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
margin={{ top: 20, right: 80, bottom: 20, left: 80 }}
|
||||
@@ -407,23 +332,13 @@ export const SettingsUsageUserDetail = () => {
|
||||
animate
|
||||
motionConfig={CHART_MOTION_CONFIG}
|
||||
tooltip={({ datum }) => (
|
||||
<div
|
||||
style={{
|
||||
background: theme.background.primary,
|
||||
border: `1px solid ${theme.border.color.medium}`,
|
||||
borderRadius: theme.border.radius.sm,
|
||||
padding: '6px 10px',
|
||||
fontSize: theme.font.size.sm,
|
||||
color: theme.font.color.primary,
|
||||
boxShadow: theme.boxShadow.light,
|
||||
}}
|
||||
>
|
||||
<strong>{datum.id}</strong>: {formatNumber(datum.value)}{' '}
|
||||
{t`credits`}
|
||||
</div>
|
||||
<BillingChartTooltip
|
||||
label={String(datum.id)}
|
||||
value={`${formatNumber(datum.value)} ${t`credits`}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</StyledPieChartContainer>
|
||||
</BillingPieChartContainer>
|
||||
</SubscriptionInfoContainer>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
@@ -19,10 +19,8 @@ import { BillingMeteredProductUsageDTO } from 'src/engine/core-modules/billing/d
|
||||
import { BillingPlanDTO } from 'src/engine/core-modules/billing/dtos/billing-plan.dto';
|
||||
import { BillingSessionDTO } from 'src/engine/core-modules/billing/dtos/billing-session.dto';
|
||||
import { BillingUpdateDTO } from 'src/engine/core-modules/billing/dtos/billing-update.dto';
|
||||
import {
|
||||
BillingAnalyticsDTO,
|
||||
BillingAnalyticsInput,
|
||||
} from 'src/engine/core-modules/billing/dtos/billing-usage-breakdown.dto';
|
||||
import { BillingAnalyticsDTO } from 'src/engine/core-modules/billing/dtos/billing-analytics.dto';
|
||||
import { BillingAnalyticsInput } from 'src/engine/core-modules/billing/dtos/inputs/billing-analytics.input';
|
||||
import { BillingPlanKey } from 'src/engine/core-modules/billing/enums/billing-plan-key.enum';
|
||||
import {
|
||||
type BillingUsageBreakdownItem,
|
||||
@@ -350,22 +348,8 @@ export class BillingResolver {
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args('input', { nullable: true }) input?: BillingAnalyticsInput,
|
||||
): Promise<BillingAnalyticsDTO> {
|
||||
let defaultPeriodStart: Date;
|
||||
let defaultPeriodEnd: Date;
|
||||
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
);
|
||||
|
||||
defaultPeriodStart = subscription.currentPeriodStart;
|
||||
defaultPeriodEnd = subscription.currentPeriodEnd;
|
||||
} else {
|
||||
defaultPeriodEnd = new Date();
|
||||
defaultPeriodStart = new Date();
|
||||
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
|
||||
}
|
||||
const { defaultPeriodStart, defaultPeriodEnd } =
|
||||
await this.getDefaultAnalyticsPeriod(workspace.id);
|
||||
|
||||
const periodStart = input?.periodStart ?? defaultPeriodStart;
|
||||
const periodEnd = input?.periodEnd ?? defaultPeriodEnd;
|
||||
@@ -387,17 +371,15 @@ export class BillingResolver {
|
||||
this.billingAnalyticsService.getUsageTimeSeries(periodParams),
|
||||
]);
|
||||
|
||||
const resolvedUsageByUser =
|
||||
await this.resolveBreakdownKeys(
|
||||
usageByUser,
|
||||
(ids) => this.resolveUserNames(ids),
|
||||
);
|
||||
const resolvedUsageByUser = await this.resolveBreakdownKeys(
|
||||
usageByUser,
|
||||
(ids) => this.resolveUserNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const resolvedUsageByResource =
|
||||
await this.resolveBreakdownKeys(
|
||||
usageByResource,
|
||||
(ids) => this.resolveResourceNames(ids, workspace.id),
|
||||
);
|
||||
const resolvedUsageByResource = await this.resolveBreakdownKeys(
|
||||
usageByResource,
|
||||
(ids) => this.resolveResourceNames(ids, workspace.id),
|
||||
);
|
||||
|
||||
const result: BillingAnalyticsDTO = {
|
||||
usageByUser: resolvedUsageByUser,
|
||||
@@ -409,16 +391,23 @@ export class BillingResolver {
|
||||
};
|
||||
|
||||
if (input?.userWorkspaceId) {
|
||||
const dailyUsage =
|
||||
await this.billingAnalyticsService.getUsageByUserTimeSeries({
|
||||
...periodParams,
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
});
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: input.userWorkspaceId, workspaceId: workspace.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
result.userDailyUsage = {
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
dailyUsage,
|
||||
};
|
||||
if (isDefined(userWorkspace)) {
|
||||
const dailyUsage =
|
||||
await this.billingAnalyticsService.getUsageByUserTimeSeries({
|
||||
...periodParams,
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
});
|
||||
|
||||
result.userDailyUsage = {
|
||||
userWorkspaceId: input.userWorkspaceId,
|
||||
dailyUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -446,6 +435,29 @@ export class BillingResolver {
|
||||
};
|
||||
}
|
||||
|
||||
private async getDefaultAnalyticsPeriod(
|
||||
workspaceId: string,
|
||||
): Promise<{ defaultPeriodStart: Date; defaultPeriodEnd: Date }> {
|
||||
if (this.billingService.isBillingEnabled()) {
|
||||
const subscription =
|
||||
await this.billingSubscriptionService.getCurrentBillingSubscriptionOrThrow(
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
return {
|
||||
defaultPeriodStart: subscription.currentPeriodStart,
|
||||
defaultPeriodEnd: subscription.currentPeriodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultPeriodEnd = new Date();
|
||||
const defaultPeriodStart = new Date();
|
||||
|
||||
defaultPeriodStart.setDate(defaultPeriodStart.getDate() - 30);
|
||||
|
||||
return { defaultPeriodStart, defaultPeriodEnd };
|
||||
}
|
||||
|
||||
private async validateCanCheckoutSessionPermissionOrThrow({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
@@ -482,8 +494,6 @@ export class BillingResolver {
|
||||
PermissionsExceptionCode.PERMISSION_DENIED,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private async resolveBreakdownKeys(
|
||||
@@ -505,6 +515,7 @@ export class BillingResolver {
|
||||
|
||||
private async resolveUserNames(
|
||||
userWorkspaceIds: string[],
|
||||
workspaceId: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const nameMap = new Map<string, string>();
|
||||
|
||||
@@ -513,9 +524,12 @@ export class BillingResolver {
|
||||
}
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: { id: In(userWorkspaceIds) },
|
||||
where: { id: In(userWorkspaceIds), workspaceId },
|
||||
relations: ['user'],
|
||||
select: { id: true, user: { firstName: true, lastName: true, email: true } },
|
||||
select: {
|
||||
id: true,
|
||||
user: { firstName: true, lastName: true, email: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const userWorkspace of userWorkspaces) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingUsageBreakdownItemDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-breakdown-item.dto';
|
||||
import { BillingUsageTimeSeriesDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-time-series.dto';
|
||||
import { BillingUserDailyUsageDTO } from 'src/engine/core-modules/billing/dtos/billing-user-daily-usage.dto';
|
||||
|
||||
@ObjectType('BillingAnalytics')
|
||||
export class BillingAnalyticsDTO {
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByUser: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByResource: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByExecutionType: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
timeSeries: BillingUsageTimeSeriesDTO[];
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => BillingUserDailyUsageDTO, { nullable: true })
|
||||
userDailyUsage?: BillingUserDailyUsageDTO;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BillingUsageBreakdownItem')
|
||||
export class BillingUsageBreakdownItemDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, InputType, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BillingUsageBreakdownItem')
|
||||
export class BillingUsageBreakdownItemDTO {
|
||||
@Field(() => String)
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
|
||||
@ObjectType('BillingUsageTimeSeries')
|
||||
export class BillingUsageTimeSeriesDTO {
|
||||
@Field(() => String)
|
||||
date: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class BillingAnalyticsInput {
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodStart?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodEnd?: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
|
||||
@ObjectType('BillingUserDailyUsage')
|
||||
export class BillingUserDailyUsageDTO {
|
||||
@Field(() => String)
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
dailyUsage: BillingUsageTimeSeriesDTO[];
|
||||
}
|
||||
|
||||
@ObjectType('BillingAnalytics')
|
||||
export class BillingAnalyticsDTO {
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByUser: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByResource: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageBreakdownItemDTO])
|
||||
usageByExecutionType: BillingUsageBreakdownItemDTO[];
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
timeSeries: BillingUsageTimeSeriesDTO[];
|
||||
|
||||
@Field(() => Date)
|
||||
periodStart: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
periodEnd: Date;
|
||||
|
||||
@Field(() => BillingUserDailyUsageDTO, { nullable: true })
|
||||
userDailyUsage?: BillingUserDailyUsageDTO;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, Float, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('BillingUsageTimeSeries')
|
||||
export class BillingUsageTimeSeriesDTO {
|
||||
@Field(() => String)
|
||||
date: string;
|
||||
|
||||
@Field(() => Float)
|
||||
creditsUsed: number;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { BillingUsageTimeSeriesDTO } from 'src/engine/core-modules/billing/dtos/billing-usage-time-series.dto';
|
||||
|
||||
@ObjectType('BillingUserDailyUsage')
|
||||
export class BillingUserDailyUsageDTO {
|
||||
@Field(() => String)
|
||||
userWorkspaceId: string;
|
||||
|
||||
@Field(() => [BillingUsageTimeSeriesDTO])
|
||||
dailyUsage: BillingUsageTimeSeriesDTO[];
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
@InputType()
|
||||
export class BillingAnalyticsInput {
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodStart?: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
periodEnd?: Date;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId?: string;
|
||||
}
|
||||
+29
-16
@@ -1,6 +1,6 @@
|
||||
/* @license Enterprise */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
|
||||
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
|
||||
@@ -23,10 +23,18 @@ type PeriodParams = {
|
||||
periodEnd: Date;
|
||||
};
|
||||
|
||||
const ALLOWED_GROUP_BY_FIELDS = [
|
||||
'userWorkspaceId',
|
||||
'resourceId',
|
||||
'executionType',
|
||||
] as const;
|
||||
|
||||
type GroupByField = (typeof ALLOWED_GROUP_BY_FIELDS)[number];
|
||||
|
||||
const BREAKDOWN_QUERY_LIMIT = 50;
|
||||
|
||||
@Injectable()
|
||||
export class BillingAnalyticsService {
|
||||
private readonly logger = new Logger(BillingAnalyticsService.name);
|
||||
|
||||
constructor(private readonly clickHouseService: ClickHouseService) {}
|
||||
|
||||
async getUsageByUser(
|
||||
@@ -84,9 +92,9 @@ export class BillingAnalyticsService {
|
||||
periodEnd,
|
||||
groupByField,
|
||||
extraWhere = '',
|
||||
extraParams = {},
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
groupByField: string;
|
||||
groupByField: GroupByField;
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
}): Promise<BillingUsageBreakdownItem[]> {
|
||||
@@ -101,21 +109,20 @@ export class BillingAnalyticsService {
|
||||
${extraWhere}
|
||||
GROUP BY ${groupByField}
|
||||
ORDER BY creditsUsed DESC
|
||||
LIMIT 50
|
||||
LIMIT ${BREAKDOWN_QUERY_LIMIT}
|
||||
`;
|
||||
|
||||
const rows =
|
||||
await this.clickHouseService.select<BillingUsageBreakdownItem>(query, {
|
||||
const rows = await this.clickHouseService.select<BillingUsageBreakdownItem>(
|
||||
query,
|
||||
{
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...extraParams,
|
||||
});
|
||||
...(extraParams ?? {}),
|
||||
},
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsed),
|
||||
}));
|
||||
return this.mapToDisplayCredits(rows);
|
||||
}
|
||||
|
||||
private async queryTimeSeries({
|
||||
@@ -123,7 +130,7 @@ export class BillingAnalyticsService {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
extraWhere = '',
|
||||
extraParams = {},
|
||||
extraParams,
|
||||
}: PeriodParams & {
|
||||
extraWhere?: string;
|
||||
extraParams?: Record<string, unknown>;
|
||||
@@ -146,9 +153,15 @@ export class BillingAnalyticsService {
|
||||
workspaceId,
|
||||
periodStart: formatDateForClickHouse(periodStart),
|
||||
periodEnd: formatDateForClickHouse(periodEnd),
|
||||
...extraParams,
|
||||
...(extraParams ?? {}),
|
||||
});
|
||||
|
||||
return this.mapToDisplayCredits(rows);
|
||||
}
|
||||
|
||||
private mapToDisplayCredits<T extends { creditsUsed: number }>(
|
||||
rows: T[],
|
||||
): T[] {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
creditsUsed: toDisplayCredits(row.creditsUsed),
|
||||
|
||||
+1
-4
@@ -57,10 +57,7 @@ export class BillingEventWriterService {
|
||||
}));
|
||||
|
||||
this.clickHouseService.insert('billingEvent', rows).catch((error) => {
|
||||
this.logger.error(
|
||||
'Failed to write billing events to ClickHouse',
|
||||
error,
|
||||
);
|
||||
this.logger.error('Failed to write billing events to ClickHouse', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user