Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({
totals: vi.fn(),
series: vi.fn(),
breakdown: vi.fn(),
entities: vi.fn(),
logs: vi.fn(),
Unauthenticated: class extends Error {},
}))
Expand Down Expand Up @@ -58,7 +59,7 @@ vi.mock('@/lib/billing/core/usage-analytics-queries', () => ({
readUsageTotals: mocks.totals,
readUsageTimeSeries: mocks.series,
readUsageGroups: mocks.breakdown,
readUsageEntityNames: vi.fn().mockResolvedValue(new Map()),
readUsageEntities: mocks.entities,
}))
vi.mock('@/lib/billing/core/usage-log', () => ({ getBillingEntityUsageLogs: mocks.logs }))

Expand Down Expand Up @@ -134,6 +135,7 @@ beforeEach(() => {
mocks.totals.mockResolvedValue({ cost: 1 })
mocks.series.mockResolvedValue([])
mocks.breakdown.mockResolvedValue([])
mocks.entities.mockResolvedValue(new Map())
mocks.logs.mockResolvedValue({ logs: [], pagination: { hasMore: false, nextCursorKeys: null } })
})
afterEach(() => vi.useRealTimers())
Expand Down Expand Up @@ -424,6 +426,17 @@ describe('organization usage API authorization and bounds', () => {
expect(mocks.totals).not.toHaveBeenCalled()
})

it('keeps member avatars out of the public breakdown', async () => {
admin()
mocks.breakdown.mockResolvedValue([{ key: 'user-1', cost: 1, events: 1 }])
mocks.entities.mockResolvedValue(new Map([['user-1', { name: 'Ada', image: 'a.png' }]]))
const response = await breakdown(request('usage/breakdown?dimension=member'), usageContext)
expect(response.status).toBe(200)
const { data } = await response.json()
expect(data.rows).toEqual([expect.objectContaining({ id: 'user-1', label: 'Ada' })])
expect(data.rows[0]).not.toHaveProperty('image')
})

it('reports an oversized breakdown instead of presenting a partial total', async () => {
admin()
mocks.breakdown.mockResolvedValue(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function ActivityTable({ rows, dimension, onSelectWorkspace }: ActivityTa
</Chip>
) : (
<div className='flex min-w-0 items-center gap-2.5'>
{isMember && <MemberAvatar name={row.label} image={null} />}
{isMember && <MemberAvatar name={row.label} image={row.image ?? null} />}
<OverflowText label={row.label} className='text-[var(--text-body)] text-sm' />
</div>
)}
Expand Down
21 changes: 11 additions & 10 deletions apps/sim/ee/organization-usage/components/usage-consumers.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import type { ComponentType, ReactNode } from 'react'
import type { ComponentType } from 'react'
import { cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn'
import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
import {
Expand Down Expand Up @@ -34,6 +34,7 @@ import type {
OrganizationUsageBreakdownRow,
UsageBreakdownDimension,
} from '@/lib/api/contracts/organization-usage'
import { MemberAvatar } from '@/app/workspace/[workspaceId]/settings/components/member-list'
import {
type RowAction,
RowActionsMenu,
Expand Down Expand Up @@ -89,8 +90,8 @@ export const USAGE_PROVIDER_ICON_IDS = Object.keys(PROVIDER_ICONS)

interface UsageConsumerRowProps {
row: OrganizationUsageBreakdownRow
/** Replaces the provider mark, e.g. with a member's avatar. */
leading?: ReactNode
/** Member rows lead with the member's avatar where other rows show a provider mark. */
isMember: boolean
/** BYOK rows carry no cost, so tokens are the only usage they can show. */
showTokensOnly: boolean
onSelect?: (row: OrganizationUsageBreakdownRow) => void
Expand Down Expand Up @@ -128,7 +129,7 @@ export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p-
*/
function UsageConsumerRow({
row,
leading,
isMember,
showTokensOnly,
onSelect,
actions,
Expand All @@ -151,8 +152,11 @@ function UsageConsumerRow({
onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]'
)}
>
{leading ??
(ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />)}
{isMember ? (
<MemberAvatar name={row.label} image={row.image ?? null} />
) : (
ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
)}
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>{row.label}</span>
<div
className='h-[4px] w-[64px] shrink-0 overflow-hidden rounded-full bg-[var(--border)]'
Expand Down Expand Up @@ -189,8 +193,6 @@ interface UsageConsumersProps {
onSelectRow?: (row: OrganizationUsageBreakdownRow) => void
/** Set on Members, where a row can open the shared manage-credits modal. */
rowActions?: (row: OrganizationUsageBreakdownRow) => RowAction[]
/** Leading visual per row, in place of the provider mark. */
renderLeading?: (row: OrganizationUsageBreakdownRow) => ReactNode
/**
* Opens the truncated tail. Omitted when the list is already showing everything the
* API will return, which is the one case where the `Other` row has nothing to open.
Expand All @@ -206,7 +208,6 @@ export function UsageConsumers({
isPlaceholderData,
onSelectRow,
rowActions,
renderLeading,
onExpandOther,
}: UsageConsumersProps) {
if (isError) {
Expand Down Expand Up @@ -245,7 +246,7 @@ export function UsageConsumers({
<UsageConsumerRow
key={`${dimension}-${row.id}`}
row={row}
leading={renderLeading?.(row)}
isMember={dimension === 'member'}
showTokensOnly={showTokensOnly}
{...(onExpandOther && trailingSlot ? { reservedTrailing: trailingSlot } : {})}
{...(onSelectRow && row.id ? { onSelect: onSelectRow } : {})}
Expand Down
24 changes: 0 additions & 24 deletions apps/sim/ee/organization-usage/components/usage-member-avatar.tsx

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { ActivityPanel } from '@/ee/organization-usage/components/activity-panel
import { OrganizationActivityOverview } from '@/ee/organization-usage/components/activity-summary'
import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers'
import { UsageCredits } from '@/ee/organization-usage/components/usage-credits'
import { UsageMemberAvatar } from '@/ee/organization-usage/components/usage-member-avatar'
import { UsageTopCards } from '@/ee/organization-usage/components/usage-top-cards'
import {
COLLAPSED_ROW_COUNT,
Expand Down Expand Up @@ -378,11 +377,6 @@ export function UsageMonitoring({
void setState({ workspace: row.id, expanded: null }, { history: 'push' }),
}
: {})}
{...(tab === 'member'
? {
renderLeading: (row) => <UsageMemberAvatar id={row.id} name={row.label} />,
}
: {})}
{...(canManageCredits
? {
rowActions: (row) => [
Expand Down
5 changes: 0 additions & 5 deletions apps/sim/ee/organization-usage/components/usage-top-cards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@/lib/api/contracts/organization-usage'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers'
import { UsageMemberAvatar } from '@/ee/organization-usage/components/usage-member-avatar'
import type { UsageTab } from '@/ee/organization-usage/constants'
import { useOrganizationUsageBreakdown } from '@/hooks/queries/organization-usage'
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'
Expand Down Expand Up @@ -64,7 +63,6 @@ export function UsageTopCards({
onViewAll,
}: UsageTopCardsProps) {
const models = useOrganizationUsageBreakdown(organizationId, window, 'model')
const images = new Map(overview?.members.rows.map((row) => [row.id, row.image]))

return (
<div className='grid grid-cols-1 gap-7 md:grid-cols-2 md:gap-x-8'>
Expand Down Expand Up @@ -94,9 +92,6 @@ export function UsageTopCards({
isLoading={isOverviewLoading}
isError={isOverviewError}
isPlaceholderData={isOverviewPlaceholderData}
renderLeading={(row) => (
<UsageMemberAvatar id={row.id} name={row.label} image={images.get(row.id)} />
)}
/>
</SettingsSection>
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/organization-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const organizationActivityBreakdownSchema = z.object({
label: z.string(),
workspaceId: workspaceIdSchema.nullable(),
workspaceName: z.string().nullable(),
/** Member rows only, when the member has one. */
image: z.string().optional(),
})
)
.max(ACTIVITY_PAGE_SIZE),
Expand Down
25 changes: 18 additions & 7 deletions apps/sim/lib/api/contracts/organization-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,6 @@ export const organizationUsageBreakdownRowSchema = z.object({
/** Model dimensions only; BYOK rows carry no cost, so this is their only usage figure. */
tokens: z.number().int().optional().describe('Input and output tokens for model or BYOK groups.'),
})
export type OrganizationUsageBreakdownRow = z.output<typeof organizationUsageBreakdownRowSchema>

export const organizationUsageBreakdownResponseSchema = z.object({
dimension: usageBreakdownDimensionSchema,
rows: z
Expand All @@ -244,7 +242,20 @@ export const organizationUsageBreakdownResponseSchema = z.object({
'Whole credits represented by this breakdown; workflow includes only workflow-attributed usage.'
),
})
export type OrganizationUsageBreakdown = z.output<typeof organizationUsageBreakdownResponseSchema>

/**
* The dashboard's breakdown row: member rows add an avatar, which the public v2
* breakdown (the shared schema above) omits.
*/
const organizationUsageBreakdownViewRowSchema = organizationUsageBreakdownRowSchema.extend({
image: z.string().optional(),
})
export type OrganizationUsageBreakdownRow = z.output<typeof organizationUsageBreakdownViewRowSchema>

const organizationUsageBreakdownViewSchema = organizationUsageBreakdownResponseSchema.extend({
rows: z.array(organizationUsageBreakdownViewRowSchema),
})
export type OrganizationUsageBreakdown = z.output<typeof organizationUsageBreakdownViewSchema>

/** Rows on each overview card; the matching tab holds the full ranking. */
export const ORGANIZATION_USAGE_OVERVIEW_ROW_LIMIT = 5
Expand All @@ -270,10 +281,10 @@ export const organizationUsageOverviewResponseSchema = organizationUsageSummaryR
)
.max(1000)
.describe('Chronological buckets, including buckets with no usage.'),
/** The Members tab's ranking, cut to the card, with each member's avatar. */
members: organizationUsageBreakdownResponseSchema.extend({
/** The Members tab's ranking, cut to the card. */
members: organizationUsageBreakdownViewSchema.extend({
rows: z
.array(organizationUsageBreakdownRowSchema.extend({ image: z.string().nullable() }))
.array(organizationUsageBreakdownViewRowSchema)
.max(ORGANIZATION_USAGE_OVERVIEW_ROW_LIMIT),
}),
})
Expand Down Expand Up @@ -310,7 +321,7 @@ export const getOrganizationUsageBreakdownContract = defineRouteContract({
path: '/api/organizations/[id]/usage/breakdown',
params: z.object({ id: organizationIdSchema }),
query: organizationUsageBreakdownQuerySchema,
response: { mode: 'json', schema: organizationUsageBreakdownResponseSchema },
response: { mode: 'json', schema: organizationUsageBreakdownViewSchema },
})

export const listOrganizationUsageEventsContract = defineRouteContract({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import { organizationUsageOperations } from '@/lib/billing/application/organizat
import {
foldUsageBreakdown,
mergeRowsByKey,
rankUsageRows,
resolveUsageAnalyticsWindow,
USAGE_NULL_KEY_LABELS,
type UsageAnalyticsWindow,
type UsageBreakdownDimension,
type UsageGroupRow,
type UsageWindowPreset,
} from '@/lib/billing/core/usage-analytics'
import { readUsageEntityNames, readUsageGroups } from '@/lib/billing/core/usage-analytics-queries'
import {
readUsageEntities,
readUsageGroups,
type UsageEntity,
} from '@/lib/billing/core/usage-analytics-queries'
import type { BillingEntity } from '@/lib/billing/core/usage-log'
import { apportionCredits, dollarsToCredits } from '@/lib/billing/credits/conversion'
import {
Expand Down Expand Up @@ -47,6 +52,8 @@ export interface OrganizationUsageBreakdownRow {
share: number
providerId?: string
tokens?: number
/** Member rows only, when the member has one. */
image?: string
}

export interface OrganizationUsageBreakdownResult {
Expand Down Expand Up @@ -132,20 +139,19 @@ export async function buildUsageBreakdown({
* Names are hydrated for the surviving keys only — joining inside the aggregate
* would break the index-only scan the member dimension depends on.
*
* Sorted before slicing: the breakdown query only groups, so Postgres returns its
* aggregate in arbitrary order. Slicing that directly hydrated an arbitrary subset
* while the fold below ranks by cost, so a top row whose name was never fetched
* fell through to `?? key` and rendered a raw id. The margin over `limit` covers
* the fold's label tiebreak pulling in a row just past the cut.
* Cut with the fold's own ranking, so the rows read here are exactly the rows it
* keeps: the breakdown query only groups, and Postgres returns its aggregate in
* arbitrary order. The ranking breaks ties by key, never by a name not yet read, so
* the read is bounded by `limit` however large a tie at the cutoff.
*/
const rankedIds = [...rows]
.sort((left, right) => right.cost - left.cost)
.slice(0, limit * 2)
const rankBy = dimension === 'byok' ? 'tokens' : 'cost'
const rankedIds = rankUsageRows(rows, rankBy)
.slice(0, limit)
.map((row) => row.key)
.filter((key): key is string => Boolean(key))
const names = NAMED_DIMENSIONS.has(dimension)
? await readUsageEntityNames(dimension, rankedIds)
: new Map<string, string>()
const entities = NAMED_DIMENSIONS.has(dimension)
? await readUsageEntities(dimension, rankedIds)
Comment thread
waleedlatif1 marked this conversation as resolved.
: new Map<string, UsageEntity>()

const labelFor = (key: string | null): string => {
/**
Expand All @@ -163,18 +169,12 @@ export async function buildUsageBreakdown({
if (dimension === 'model') return key
// A deleted workspace or workflow nulls its id on the ledger row, so a key that
// resolves to no name is a live entity we could not read — not a deleted one.
return names.get(key) ?? key
return entities.get(key)?.name ?? key
}

// BYOK is denominated in tokens and every row costs zero, so ranking it by cost
// would order the list alphabetically and call the result "top providers".
const fold = foldUsageBreakdown(
rows,
totalCost,
labelFor,
limit,
dimension === 'byok' ? 'tokens' : 'cost'
)
const fold = foldUsageBreakdown(rows, totalCost, labelFor, limit, rankBy)
const tokensByKey = new Map(
rows.map((row) => [row.key ?? '', (row.inputTokens ?? 0) + (row.outputTokens ?? 0)])
)
Expand All @@ -201,6 +201,7 @@ export async function buildUsageBreakdown({
dimension: dimension,
rows: fold.rows.map((row, index) => {
const tokens = tokensByKey.get(row.id) ?? 0
const image = entities.get(row.id)?.image
return {
id: row.id,
label: row.label,
Expand All @@ -210,6 +211,7 @@ export async function buildUsageBreakdown({
...(isModelDimension && tokens > 0 ? { tokens } : {}),
...(dimension === 'byok' ? { providerId: row.id } : {}),
...(dimension === 'model' && row.id ? { providerId: getProviderFromModel(row.id) } : {}),
...(image ? { image } : {}),
}
}),
other: {
Expand Down
Loading
Loading