feat(event-logs): add billing events table to audit logs viewer
- Add BILLING_EVENT to EventLogTable enum (shared, generated types) - Handle billingEvent ClickHouse table in EventLogsService with proper column mapping (eventType→event, userWorkspaceId→userId, metadata→properties) - Add "Billing Events" option to EventLogTableSelector - Add BILLING_EVENT_COLUMNS config for results table - Fix CSS alphabetical ordering lint errors in SettingsBillingAnalyticsSection - Replace navigate() with Link for usage page navigation (lint fix) https://claude.ai/code/session_01Y1EqrX6PFq3EJxJq89h7DF
This commit is contained in:
@@ -1698,6 +1698,7 @@ export type EventLogRecord = {
|
||||
};
|
||||
|
||||
export enum EventLogTable {
|
||||
BILLING_EVENT = 'BILLING_EVENT',
|
||||
OBJECT_EVENT = 'OBJECT_EVENT',
|
||||
PAGEVIEW = 'PAGEVIEW',
|
||||
WORKSPACE_EVENT = 'WORKSPACE_EVENT'
|
||||
|
||||
+20
-24
@@ -25,10 +25,10 @@ const StyledBarRow = styled.div`
|
||||
`;
|
||||
|
||||
const StyledClickableBarRow = styled(StyledBarRow)`
|
||||
cursor: pointer;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
padding: ${themeCssVariables.spacing[1]};
|
||||
cursor: pointer;
|
||||
margin: -${themeCssVariables.spacing[1]};
|
||||
padding: ${themeCssVariables.spacing[1]};
|
||||
|
||||
&:hover {
|
||||
background-color: ${themeCssVariables.background.transparent.light};
|
||||
@@ -36,19 +36,19 @@ const StyledClickableBarRow = styled(StyledBarRow)`
|
||||
`;
|
||||
|
||||
const StyledBarLabel = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const StyledLabelText = styled.span`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
max-width: 60%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 60%;
|
||||
`;
|
||||
|
||||
const StyledValueText = styled.span`
|
||||
@@ -58,8 +58,8 @@ const StyledValueText = styled.span`
|
||||
`;
|
||||
|
||||
const StyledTimeSeriesContainer = styled.div`
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
@@ -132,7 +132,14 @@ const StyledBackButton = styled.button`
|
||||
`;
|
||||
|
||||
const getBarColors = (theme: {
|
||||
color: { blue: string; purple: string; green: string; orange: string; turquoise: string; pink: string };
|
||||
color: {
|
||||
blue: string;
|
||||
purple: string;
|
||||
green: string;
|
||||
orange: string;
|
||||
turquoise: string;
|
||||
pink: string;
|
||||
};
|
||||
}): string[] => [
|
||||
theme.color.blue,
|
||||
theme.color.purple,
|
||||
@@ -219,9 +226,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
);
|
||||
|
||||
const displayTimeSeries =
|
||||
selectedUserId && userDailyUsage
|
||||
? userDailyUsage.dailyUsage
|
||||
: timeSeries;
|
||||
selectedUserId && userDailyUsage ? userDailyUsage.dailyUsage : timeSeries;
|
||||
|
||||
const maxTimeSeriesValue = Math.max(
|
||||
...displayTimeSeries.map((point) => point.creditsUsed),
|
||||
@@ -278,15 +283,10 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
label={t`Usage by Type`}
|
||||
value={`${formatNumber(totalCredits)} total`}
|
||||
/>
|
||||
<HorizontalSeparator
|
||||
noMargin
|
||||
color={theme.background.tertiary}
|
||||
/>
|
||||
<HorizontalSeparator noMargin color={theme.background.tertiary} />
|
||||
{usageByExecutionType.map((item, index) => {
|
||||
const percentage =
|
||||
totalCredits > 0
|
||||
? (item.creditsUsed / totalCredits) * 100
|
||||
: 0;
|
||||
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
|
||||
|
||||
return (
|
||||
<StyledBarRow key={item.key}>
|
||||
@@ -295,8 +295,8 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
{EXECUTION_TYPE_LABELS[item.key] ?? item.key}
|
||||
</StyledLabelText>
|
||||
<StyledValueText>
|
||||
{formatNumber(item.creditsUsed)} (
|
||||
{Math.round(percentage)}%)
|
||||
{formatNumber(item.creditsUsed)} ({Math.round(percentage)}
|
||||
%)
|
||||
</StyledValueText>
|
||||
</StyledBarLabel>
|
||||
<ProgressBar
|
||||
@@ -369,9 +369,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
<SubscriptionInfoContainer>
|
||||
{usageByUser.map((item, index) => {
|
||||
const percentage =
|
||||
totalCredits > 0
|
||||
? (item.creditsUsed / totalCredits) * 100
|
||||
: 0;
|
||||
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
|
||||
|
||||
return (
|
||||
<StyledClickableBarRow
|
||||
@@ -406,9 +404,7 @@ export const SettingsBillingAnalyticsSection = () => {
|
||||
<SubscriptionInfoContainer>
|
||||
{usageByResource.map((item, index) => {
|
||||
const percentage =
|
||||
totalCredits > 0
|
||||
? (item.creditsUsed / totalCredits) * 100
|
||||
: 0;
|
||||
totalCredits > 0 ? (item.creditsUsed / totalCredits) * 100 : 0;
|
||||
|
||||
return (
|
||||
<StyledBarRow key={item.key}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { SettingsBillingAnalyticsSection } from '@/billing/components/SettingsBillingAnalyticsSection';
|
||||
@@ -10,7 +10,12 @@ import { SettingsPageContainer } from '@/settings/components/SettingsPageContain
|
||||
import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
|
||||
import { isDefined, getSettingsPath } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { H2Title, IconChartBar, IconCircleX, IconCreditCard } from 'twenty-ui/display';
|
||||
import {
|
||||
H2Title,
|
||||
IconChartBar,
|
||||
IconCircleX,
|
||||
IconCreditCard,
|
||||
} from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import {
|
||||
@@ -24,7 +29,6 @@ export const SettingsBillingContent = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { redirect } = useRedirect();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
@@ -85,12 +89,13 @@ export const SettingsBillingContent = () => {
|
||||
title={t`Usage`}
|
||||
description={t`View detailed usage analytics for your workspace`}
|
||||
/>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
onClick={() => navigate(getSettingsPath(SettingsPath.Usage))}
|
||||
/>
|
||||
<Link to={getSettingsPath(SettingsPath.Usage)}>
|
||||
<Button
|
||||
Icon={IconChartBar}
|
||||
title={t`View usage`}
|
||||
variant="secondary"
|
||||
/>
|
||||
</Link>
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
|
||||
+23
-3
@@ -79,6 +79,23 @@ const OBJECT_EVENT_COLUMNS: ColumnConfig[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const BILLING_EVENT_COLUMNS: ColumnConfig[] = [
|
||||
{ id: 'event', label: msg`Event Type`, minWidth: 100, defaultWidth: 160 },
|
||||
{
|
||||
id: 'timestamp',
|
||||
label: msg`Timestamp`,
|
||||
minWidth: 100,
|
||||
defaultWidth: 140,
|
||||
},
|
||||
{ id: 'userId', label: msg`User`, minWidth: 100, defaultWidth: 130 },
|
||||
{
|
||||
id: 'properties',
|
||||
label: msg`Details`,
|
||||
minWidth: 200,
|
||||
defaultWidth: 400,
|
||||
},
|
||||
];
|
||||
|
||||
const StyledScrollWrapperContainer = styled.div`
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
@@ -159,9 +176,12 @@ export const EventLogResultsTable = ({
|
||||
const { t } = useLingui();
|
||||
|
||||
const showObjectEventColumns = selectedTable === EventLogTable.OBJECT_EVENT;
|
||||
const baseColumns = showObjectEventColumns
|
||||
? OBJECT_EVENT_COLUMNS
|
||||
: DEFAULT_COLUMNS;
|
||||
const baseColumns =
|
||||
selectedTable === EventLogTable.OBJECT_EVENT
|
||||
? OBJECT_EVENT_COLUMNS
|
||||
: selectedTable === EventLogTable.BILLING_EVENT
|
||||
? BILLING_EVENT_COLUMNS
|
||||
: DEFAULT_COLUMNS;
|
||||
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() =>
|
||||
Object.fromEntries(baseColumns.map((col) => [col.id, col.defaultWidth])),
|
||||
|
||||
+4
@@ -27,6 +27,10 @@ export const EventLogTableSelector = ({
|
||||
value: EventLogTable.OBJECT_EVENT,
|
||||
label: t`Object Events`,
|
||||
},
|
||||
{
|
||||
value: EventLogTable.BILLING_EVENT,
|
||||
label: t`Billing Events`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.BILLING_EVENT]: 'billingEvent',
|
||||
};
|
||||
|
||||
export type EventLogCleanupParams = {
|
||||
|
||||
@@ -36,6 +36,17 @@ type ClickHouseEventRecord = {
|
||||
isCustom?: boolean;
|
||||
};
|
||||
|
||||
type ClickHouseBillingEventRecord = {
|
||||
timestamp: string;
|
||||
userWorkspaceId?: string;
|
||||
eventType?: string;
|
||||
executionType?: string;
|
||||
creditsUsed?: number;
|
||||
resourceId?: string;
|
||||
resourceContext?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const ALLOWED_TABLES = Object.values(EventLogTable);
|
||||
const MAX_LIMIT = 10000;
|
||||
|
||||
@@ -43,6 +54,7 @@ const CLICKHOUSE_TABLE_NAMES: Record<EventLogTable, string> = {
|
||||
[EventLogTable.WORKSPACE_EVENT]: 'workspaceEvent',
|
||||
[EventLogTable.PAGEVIEW]: 'pageview',
|
||||
[EventLogTable.OBJECT_EVENT]: 'objectEvent',
|
||||
[EventLogTable.BILLING_EVENT]: 'billingEvent',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -67,7 +79,11 @@ export class EventLogsService {
|
||||
const limit = Math.min(input.first ?? 100, MAX_LIMIT);
|
||||
const tableName = CLICKHOUSE_TABLE_NAMES[input.table];
|
||||
const eventFieldName =
|
||||
input.table === EventLogTable.PAGEVIEW ? 'name' : 'event';
|
||||
input.table === EventLogTable.BILLING_EVENT
|
||||
? 'eventType'
|
||||
: input.table === EventLogTable.PAGEVIEW
|
||||
? 'name'
|
||||
: 'event';
|
||||
|
||||
const whereClauses: string[] = ['"workspaceId" = {workspaceId:String}'];
|
||||
const params: Record<string, unknown> = { workspaceId };
|
||||
@@ -179,14 +195,19 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
if (isDefined(filters.userWorkspaceId)) {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
if (table === EventLogTable.BILLING_EVENT) {
|
||||
whereClauses.push('"userWorkspaceId" = {userWorkspaceId:String}');
|
||||
params.userWorkspaceId = filters.userWorkspaceId;
|
||||
} else {
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: { id: filters.userWorkspaceId },
|
||||
select: ['userId'],
|
||||
});
|
||||
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
if (isDefined(userWorkspace)) {
|
||||
whereClauses.push('"userId" = {userId:String}');
|
||||
params.userId = userWorkspace.userId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +243,25 @@ export class EventLogsService {
|
||||
}
|
||||
|
||||
private normalizeRecords(
|
||||
records: ClickHouseEventRecord[],
|
||||
records: ClickHouseEventRecord[] | ClickHouseBillingEventRecord[],
|
||||
table: EventLogTable,
|
||||
): EventLogRecord[] {
|
||||
return records.map((record) => {
|
||||
if (table === EventLogTable.BILLING_EVENT) {
|
||||
return (records as ClickHouseBillingEventRecord[]).map((record) => ({
|
||||
event: record.eventType ?? '',
|
||||
timestamp: new Date(record.timestamp),
|
||||
userId: record.userWorkspaceId,
|
||||
properties: {
|
||||
executionType: record.executionType,
|
||||
creditsUsed: record.creditsUsed,
|
||||
resourceId: record.resourceId,
|
||||
resourceContext: record.resourceContext,
|
||||
...(record.metadata ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return (records as ClickHouseEventRecord[]).map((record) => {
|
||||
const eventName =
|
||||
table === EventLogTable.PAGEVIEW
|
||||
? (record.name ?? '')
|
||||
|
||||
@@ -2,4 +2,5 @@ export enum EventLogTable {
|
||||
WORKSPACE_EVENT = 'WORKSPACE_EVENT',
|
||||
PAGEVIEW = 'PAGEVIEW',
|
||||
OBJECT_EVENT = 'OBJECT_EVENT',
|
||||
BILLING_EVENT = 'BILLING_EVENT',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user