feat: limit dashboard flow nodes
This commit is contained in:
+165
-109
@@ -32,13 +32,8 @@ import {
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Empty,
|
||||
@@ -56,7 +51,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { getFlowQuotaDates } from '@/features/dashboard/api'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
@@ -74,23 +68,34 @@ import type {
|
||||
DashboardFilters,
|
||||
FlowMetric,
|
||||
FlowNodeKind,
|
||||
FlowOverflowMode,
|
||||
FlowRole,
|
||||
FlowSummary,
|
||||
} from '@/features/dashboard/types'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
interface FlowChartsProps {
|
||||
filters?: DashboardFilters
|
||||
}
|
||||
|
||||
interface FlowStatsProps {
|
||||
summary: FlowSummary
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const FLOW_METRIC_OPTIONS = [
|
||||
{ value: 'quota', labelKey: 'Quota', icon: WalletCards },
|
||||
{ value: 'tokens', labelKey: 'Tokens', icon: Hash },
|
||||
{ value: 'requests', labelKey: 'Requests', icon: Activity },
|
||||
{ value: 'quota', labelKey: 'By quota', icon: WalletCards },
|
||||
{ value: 'tokens', labelKey: 'By tokens', icon: Hash },
|
||||
{ value: 'requests', labelKey: 'By requests', icon: Activity },
|
||||
] as const
|
||||
|
||||
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
|
||||
|
||||
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
|
||||
|
||||
const FLOW_OVERFLOW_MODE_OPTIONS = [
|
||||
{ value: 'aggregate', labelKey: 'Merge into Other' },
|
||||
{ value: 'hide', labelKey: 'Hide' },
|
||||
] as const
|
||||
|
||||
// A Sankey needs at least two columns to render any link.
|
||||
@@ -126,58 +131,13 @@ const FLOW_STAGE_META: Record<
|
||||
},
|
||||
}
|
||||
|
||||
function FlowStats(props: FlowStatsProps) {
|
||||
const { t } = useTranslation()
|
||||
const items = [
|
||||
{
|
||||
key: 'quota',
|
||||
title: 'Quota',
|
||||
value: formatQuota(props.summary.quota),
|
||||
icon: WalletCards,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
title: 'Tokens',
|
||||
value: formatNumber(props.summary.tokens),
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
title: 'Requests',
|
||||
value: formatNumber(props.summary.requests),
|
||||
icon: Activity,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-3 divide-x'>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.key} className='px-3 py-2.5 sm:px-5 sm:py-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
{t(item.title)}
|
||||
</div>
|
||||
</div>
|
||||
{props.loading ? (
|
||||
<div className='mt-2 flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-7 w-20' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
|
||||
{item.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
|
||||
user: 'Other users',
|
||||
node: 'Other nodes',
|
||||
token: 'Other tokens',
|
||||
group: 'Other groups',
|
||||
model: 'Other models',
|
||||
channel: 'Other channels',
|
||||
}
|
||||
|
||||
export function FlowCharts(props: FlowChartsProps) {
|
||||
@@ -188,6 +148,9 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
|
||||
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user'
|
||||
const [metric, setMetric] = useState<FlowMetric>('quota')
|
||||
const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT)
|
||||
const [overflowMode, setOverflowMode] =
|
||||
useState<FlowOverflowMode>('aggregate')
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
|
||||
|
||||
@@ -247,9 +210,22 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
role: flowRole,
|
||||
selectedUsers,
|
||||
visibleStages,
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }),
|
||||
otherNodeLabel: (kind) => t(FLOW_OTHER_NODE_LABEL_KEYS[kind]),
|
||||
}),
|
||||
[flowRole, flowRows, isLoading, metric, selectedUsers, visibleStages, t]
|
||||
[
|
||||
flowRole,
|
||||
flowRows,
|
||||
isLoading,
|
||||
metric,
|
||||
overflowMode,
|
||||
selectedUsers,
|
||||
topNodeLimit,
|
||||
visibleStages,
|
||||
t,
|
||||
]
|
||||
)
|
||||
const userFilterOptions = useMemo(
|
||||
() =>
|
||||
@@ -273,6 +249,8 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light'
|
||||
const chartKey = [
|
||||
metric,
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
flowRole,
|
||||
selectedUsers.join(','),
|
||||
visibleStages.join(','),
|
||||
@@ -292,46 +270,124 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FlowStats summary={flowData.summary} loading={isLoading} />
|
||||
|
||||
<div className='flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Tabs
|
||||
value={metric}
|
||||
onValueChange={(value) => setMetric(value as FlowMetric)}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList>
|
||||
{FLOW_METRIC_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className='flex min-w-0 flex-col gap-2 sm:flex-row lg:w-[min(24rem,34vw)]'>
|
||||
<MultiSelect
|
||||
options={userFilterOptions}
|
||||
selected={selectedUsers}
|
||||
onChange={setSelectedUsers}
|
||||
placeholder={t('All users')}
|
||||
emptyText={t('No users')}
|
||||
maxVisibleChips={2}
|
||||
renderSelectedSummary={(values) =>
|
||||
compactFlowSelectionLabel(values.length)
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col gap-2 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-end gap-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Flow width metric')}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground/60 hover:text-foreground flex size-5 shrink-0 items-center justify-center rounded-md'
|
||||
aria-label={t('Flow width metric')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className='size-3.5' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-[14rem]'>
|
||||
{t('Choose how flow widths are calculated.')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Tabs
|
||||
value={metric}
|
||||
onValueChange={(value) => setMetric(value as FlowMetric)}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Flow width metric')}>
|
||||
{FLOW_METRIC_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='gap-1.5 px-2.5 text-xs'
|
||||
>
|
||||
<Icon data-icon='inline-start' aria-hidden='true' />
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Display limit')}
|
||||
</span>
|
||||
<Tabs
|
||||
value={String(topNodeLimit)}
|
||||
onValueChange={(value) => setTopNodeLimit(Number(value))}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Display limit')}>
|
||||
{FLOW_TOP_LIMIT_OPTIONS.map((limit) => (
|
||||
<TabsTrigger
|
||||
key={limit}
|
||||
value={String(limit)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t('Top {{count}}', { count: limit })}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Overflow items')}
|
||||
</span>
|
||||
<Tabs
|
||||
value={overflowMode}
|
||||
onValueChange={(value) =>
|
||||
setOverflowMode(value as FlowOverflowMode)
|
||||
}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Overflow items')}>
|
||||
{FLOW_OVERFLOW_MODE_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 items-center gap-2 xl:justify-end'>
|
||||
{isAdmin && (
|
||||
<div className='flex min-w-0 flex-col gap-2 sm:flex-row xl:w-[min(24rem,34vw)]'>
|
||||
<MultiSelect
|
||||
options={userFilterOptions}
|
||||
selected={selectedUsers}
|
||||
onChange={setSelectedUsers}
|
||||
placeholder={t('All users')}
|
||||
emptyText={t('No users')}
|
||||
maxVisibleChips={2}
|
||||
renderSelectedSummary={(values) =>
|
||||
compactFlowSelectionLabel(values.length)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
|
||||
+144
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import type { FlowQuotaDataItem } from '../types'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
@@ -52,6 +53,42 @@ const rows: FlowQuotaDataItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const topLimitRows: FlowQuotaDataItem[] = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: 'alpha',
|
||||
use_group: 'vip',
|
||||
channel_id: 201,
|
||||
channel_name: 'channel-a',
|
||||
model_name: 'model-a',
|
||||
quota: 100,
|
||||
token_used: 1_000,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: 'beta',
|
||||
use_group: 'default',
|
||||
channel_id: 202,
|
||||
channel_name: 'channel-b',
|
||||
model_name: 'model-b',
|
||||
quota: 80,
|
||||
token_used: 10,
|
||||
count: 20,
|
||||
},
|
||||
{
|
||||
user_id: 3,
|
||||
username: 'gamma',
|
||||
use_group: 'free',
|
||||
channel_id: 203,
|
||||
channel_name: 'channel-c',
|
||||
model_name: 'model-c',
|
||||
quota: 10,
|
||||
token_used: 2_000,
|
||||
count: 5,
|
||||
},
|
||||
]
|
||||
|
||||
describe('dashboard flow data', () => {
|
||||
test('builds normal user token-group-model flow', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', {
|
||||
@@ -183,6 +220,113 @@ describe('dashboard flow data', () => {
|
||||
assert.notEqual(options.users[0].color, options.users[1].color)
|
||||
})
|
||||
|
||||
test('aggregates overflow nodes into per-column Other buckets', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 2,
|
||||
overflowMode: 'aggregate',
|
||||
otherNodeLabel: (kind) => `Other ${kind}`,
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
const otherUser = result.flow.nodes.find(
|
||||
(node) => node.id === 'user:__other__'
|
||||
)
|
||||
const otherFirstStepLink = result.flow.links.find(
|
||||
(link) =>
|
||||
link.source === 'user:__other__' && link.target === 'group:__other__'
|
||||
)
|
||||
const firstStepTotal = result.flow.links
|
||||
.filter((link) => link.source.startsWith('user:'))
|
||||
.reduce((sum, link) => sum + link.value, 0)
|
||||
|
||||
assert.equal(result.summary.quota, 190)
|
||||
assert.equal(firstStepTotal, 190)
|
||||
assert.equal(otherUser?.label, 'Other user')
|
||||
assert.equal(otherFirstStepLink?.value, 10)
|
||||
assert.equal(nodeIds.includes('user:3'), false)
|
||||
assert.equal(nodeIds.includes('group:free'), false)
|
||||
assert.equal(nodeIds.includes('model:model-c'), false)
|
||||
assert.equal(nodeIds.includes('channel:203'), false)
|
||||
assert.equal(nodeIds.includes('user:__other__'), true)
|
||||
assert.equal(nodeIds.includes('group:__other__'), true)
|
||||
assert.equal(nodeIds.includes('model:__other__'), true)
|
||||
assert.equal(nodeIds.includes('channel:__other__'), true)
|
||||
})
|
||||
|
||||
test('hides overflow paths when overflow mode is hide', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 2,
|
||||
overflowMode: 'hide',
|
||||
otherNodeLabel: (kind) => `Other ${kind}`,
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
const firstStepTotal = result.flow.links
|
||||
.filter((link) => link.source.startsWith('user:'))
|
||||
.reduce((sum, link) => sum + link.value, 0)
|
||||
|
||||
assert.equal(result.summary.quota, 190)
|
||||
assert.equal(firstStepTotal, 180)
|
||||
assert.equal(nodeIds.includes('user:3'), false)
|
||||
assert.equal(nodeIds.includes('user:__other__'), false)
|
||||
assert.equal(nodeIds.includes('model:__other__'), false)
|
||||
})
|
||||
|
||||
test('ranks top nodes using the selected flow metric', () => {
|
||||
const byQuota = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const byRequests = buildDashboardFlowData(topLimitRows, 'requests', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const byTokens = buildDashboardFlowData(topLimitRows, 'tokens', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
byQuota.flow.nodes.some((node) => node.id === 'user:1'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
byRequests.flow.nodes.some((node) => node.id === 'user:2'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
byTokens.flow.nodes.some((node) => node.id === 'user:3'),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('applies top limits only to visible stages', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
visibleStages: ['user', 'model'],
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
|
||||
assert.equal(nodeIds.includes('user:1'), true)
|
||||
assert.equal(nodeIds.includes('user:__other__'), true)
|
||||
assert.equal(nodeIds.includes('model:model-a'), true)
|
||||
assert.equal(nodeIds.includes('model:__other__'), true)
|
||||
assert.equal(nodeIds.includes('group:__other__'), false)
|
||||
assert.equal(nodeIds.includes('channel:__other__'), false)
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['user:__other__', 'model:__other__', 90],
|
||||
['user:1', 'model:model-a', 100],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('builds Sankey spec with quota token request tooltips', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', {
|
||||
role: 'root',
|
||||
|
||||
+177
-33
@@ -24,11 +24,13 @@ import type {
|
||||
FlowFilterOptions,
|
||||
FlowMetric,
|
||||
FlowNodeKind,
|
||||
FlowOverflowMode,
|
||||
FlowQuotaDataItem,
|
||||
FlowRole,
|
||||
FlowSummary,
|
||||
ProcessedFlowData,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
import { getDashboardChartColors } from './charts'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -53,14 +55,32 @@ type FlowPathNode = {
|
||||
kind: FlowNodeKind
|
||||
}
|
||||
|
||||
type PreparedFlowPath = {
|
||||
path: FlowPathNode[]
|
||||
metrics: FlowMetrics
|
||||
}
|
||||
|
||||
type FlowNodeRank = {
|
||||
node: FlowPathNode
|
||||
value: number
|
||||
}
|
||||
|
||||
type FlowPathContext = {
|
||||
deletedTokenLabel?: (tokenId: number) => string
|
||||
}
|
||||
|
||||
type FlowGraphOptions = {
|
||||
topNodeLimit?: number
|
||||
overflowMode?: FlowOverflowMode
|
||||
otherNodeLabel?: (kind: FlowNodeKind) => string
|
||||
}
|
||||
|
||||
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
|
||||
|
||||
const DEFAULT_FLOW_ROLE: FlowRole = 'user'
|
||||
|
||||
const DEFAULT_FLOW_OVERFLOW_MODE: FlowOverflowMode = 'aggregate'
|
||||
|
||||
const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
|
||||
quota: 'Quota',
|
||||
tokens: 'Tokens',
|
||||
@@ -70,6 +90,24 @@ const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
|
||||
|
||||
const DEFAULT_FLOW_CHART_COLOR = '#1664FF'
|
||||
|
||||
const OTHER_FLOW_NODE_IDS: Record<FlowNodeKind, string> = {
|
||||
user: 'user:__other__',
|
||||
node: 'node:__other__',
|
||||
token: 'token:__other__',
|
||||
group: 'group:__other__',
|
||||
model: 'model:__other__',
|
||||
channel: 'channel:__other__',
|
||||
}
|
||||
|
||||
const DEFAULT_OTHER_FLOW_NODE_LABELS: Record<FlowNodeKind, string> = {
|
||||
user: 'Other users',
|
||||
node: 'Other nodes',
|
||||
token: 'Other tokens',
|
||||
group: 'Other groups',
|
||||
model: 'Other models',
|
||||
channel: 'Other channels',
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
@@ -107,13 +145,11 @@ function nodeNameNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNode(
|
||||
row: FlowQuotaDataItem,
|
||||
ctx: FlowPathContext
|
||||
): FlowPathNode {
|
||||
function tokenNode(row: FlowQuotaDataItem, ctx: FlowPathContext): FlowPathNode {
|
||||
const tokenID = numberValue(row.token_id)
|
||||
return {
|
||||
id: tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
|
||||
id:
|
||||
tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
|
||||
label: row.token_name || deletedTokenLabel(tokenID, ctx),
|
||||
kind: 'token',
|
||||
}
|
||||
@@ -192,15 +228,12 @@ function resolveVisibleStages(
|
||||
return filtered.length >= MIN_FLOW_STAGES ? filtered : stages
|
||||
}
|
||||
|
||||
function flowPath(
|
||||
function flowPathForStages(
|
||||
row: FlowQuotaDataItem,
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[],
|
||||
stages: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
): FlowPathNode[] {
|
||||
return resolveVisibleStages(role, visibleStages).map((stage) =>
|
||||
NODE_BUILDERS[stage](row, ctx)
|
||||
)
|
||||
return stages.map((stage) => NODE_BUILDERS[stage](row, ctx))
|
||||
}
|
||||
|
||||
function colorAt(index: number, palette?: readonly string[]): string {
|
||||
@@ -252,18 +285,6 @@ function stableColorMap(
|
||||
return map
|
||||
}
|
||||
|
||||
function rootColorKeys(
|
||||
rows: FlowQuotaDataItem[],
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[]
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows.map((row) => flowPath(row, role, visibleStages)[0]?.id ?? 'unknown')
|
||||
)
|
||||
).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function filterRows(
|
||||
rows: FlowQuotaDataItem[],
|
||||
options: FlowBuildOptions = {}
|
||||
@@ -392,26 +413,137 @@ function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTopNodeLimit(limit?: number): number | undefined {
|
||||
if (limit === undefined) return undefined
|
||||
const parsed = Math.floor(limit)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function otherFlowNode(
|
||||
kind: FlowNodeKind,
|
||||
labeler?: (kind: FlowNodeKind) => string
|
||||
): FlowPathNode {
|
||||
return {
|
||||
id: OTHER_FLOW_NODE_IDS[kind],
|
||||
label: labeler?.(kind) ?? DEFAULT_OTHER_FLOW_NODE_LABELS[kind],
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopNodeSets(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric,
|
||||
stages: FlowNodeKind[],
|
||||
limit: number | undefined,
|
||||
ctx: FlowPathContext
|
||||
): Map<FlowNodeKind, Set<string>> | undefined {
|
||||
if (!limit) return undefined
|
||||
|
||||
const totals = new Map<FlowNodeKind, Map<string, FlowNodeRank>>()
|
||||
for (const stage of stages) {
|
||||
totals.set(stage, new Map())
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const metrics = rowMetrics(row)
|
||||
const value = metricValue(metrics, metric)
|
||||
const path = flowPathForStages(row, stages, ctx)
|
||||
for (const node of path) {
|
||||
const stageTotals = totals.get(node.kind)
|
||||
if (!stageTotals) continue
|
||||
const current = stageTotals.get(node.id) ?? { node, value: 0 }
|
||||
current.value += value
|
||||
stageTotals.set(node.id, current)
|
||||
}
|
||||
}
|
||||
|
||||
const topSets = new Map<FlowNodeKind, Set<string>>()
|
||||
for (const [kind, stageTotals] of totals) {
|
||||
const topIds = Array.from(stageTotals.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.value - a.value ||
|
||||
a.node.label.localeCompare(b.node.label) ||
|
||||
a.node.id.localeCompare(b.node.id)
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((rank) => rank.node.id)
|
||||
topSets.set(kind, new Set(topIds))
|
||||
}
|
||||
|
||||
return topSets
|
||||
}
|
||||
|
||||
function isTopFlowNode(
|
||||
node: FlowPathNode,
|
||||
topNodeSets?: Map<FlowNodeKind, Set<string>>
|
||||
): boolean {
|
||||
const topNodes = topNodeSets?.get(node.kind)
|
||||
return !topNodes || topNodes.has(node.id)
|
||||
}
|
||||
|
||||
function applyTopNodeLimit(
|
||||
path: FlowPathNode[],
|
||||
topNodeSets: Map<FlowNodeKind, Set<string>> | undefined,
|
||||
mode: FlowOverflowMode,
|
||||
labeler?: (kind: FlowNodeKind) => string
|
||||
): FlowPathNode[] | undefined {
|
||||
if (!topNodeSets) return path
|
||||
const containsOverflowNode = path.some(
|
||||
(node) => !isTopFlowNode(node, topNodeSets)
|
||||
)
|
||||
if (!containsOverflowNode) return path
|
||||
if (mode === 'hide') return undefined
|
||||
return path.map((node) =>
|
||||
isTopFlowNode(node, topNodeSets) ? node : otherFlowNode(node.kind, labeler)
|
||||
)
|
||||
}
|
||||
|
||||
function buildFlowGraph(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric,
|
||||
role: FlowRole,
|
||||
palette?: readonly string[],
|
||||
visibleStages?: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT,
|
||||
options: FlowGraphOptions = {}
|
||||
): DashboardFlowGraph {
|
||||
const stages = resolveVisibleStages(role, visibleStages)
|
||||
const topNodeSets = buildTopNodeSets(
|
||||
rows,
|
||||
metric,
|
||||
stages,
|
||||
normalizeTopNodeLimit(options.topNodeLimit),
|
||||
ctx
|
||||
)
|
||||
const overflowMode = options.overflowMode ?? DEFAULT_FLOW_OVERFLOW_MODE
|
||||
const preparedPaths: PreparedFlowPath[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const path = applyTopNodeLimit(
|
||||
flowPathForStages(row, stages, ctx),
|
||||
topNodeSets,
|
||||
overflowMode,
|
||||
options.otherNodeLabel
|
||||
)
|
||||
if (!path) continue
|
||||
preparedPaths.push({ path, metrics: rowMetrics(row) })
|
||||
}
|
||||
|
||||
const nodes = new Map<string, DashboardFlowNode>()
|
||||
const links = new Map<string, DashboardFlowLink>()
|
||||
const colors = stableColorMap(
|
||||
rootColorKeys(rows, role, visibleStages),
|
||||
preparedPaths
|
||||
.map((prepared) => prepared.path[0]?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
palette
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const path = flowPath(row, role, visibleStages, ctx)
|
||||
for (const prepared of preparedPaths) {
|
||||
const { path, metrics } = prepared
|
||||
const root = path[0]
|
||||
if (!root) continue
|
||||
const metrics = rowMetrics(row)
|
||||
const color = colors.get(root.id) ?? colorAt(0, palette)
|
||||
|
||||
for (const node of path) {
|
||||
@@ -430,8 +562,8 @@ function buildFlowGraph(
|
||||
a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
|
||||
)
|
||||
const firstStepSources = new Set(
|
||||
rows
|
||||
.map((row) => flowPath(row, role, visibleStages)[0]?.id)
|
||||
preparedPaths
|
||||
.map((prepared) => prepared.path[0]?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
const total = flowLinks
|
||||
@@ -512,9 +644,21 @@ export function buildDashboardFlowData(
|
||||
|
||||
return {
|
||||
summary: buildSummary(filteredRows),
|
||||
flow: buildFlowGraph(filteredRows, metric, role, palette, options.visibleStages, {
|
||||
deletedTokenLabel: options.deletedTokenLabel,
|
||||
}),
|
||||
flow: buildFlowGraph(
|
||||
filteredRows,
|
||||
metric,
|
||||
role,
|
||||
palette,
|
||||
options.visibleStages,
|
||||
{
|
||||
deletedTokenLabel: options.deletedTokenLabel,
|
||||
},
|
||||
{
|
||||
topNodeLimit: options.topNodeLimit,
|
||||
overflowMode: options.overflowMode,
|
||||
otherNodeLabel: options.otherNodeLabel,
|
||||
}
|
||||
),
|
||||
filterOptions: buildFlowFilterOptions(rows, metric, palette),
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -50,6 +50,8 @@ export interface FlowQuotaDataItem {
|
||||
|
||||
export type FlowMetric = 'quota' | 'tokens' | 'requests'
|
||||
|
||||
export type FlowOverflowMode = 'aggregate' | 'hide'
|
||||
|
||||
export type FlowRole = 'user' | 'admin' | 'root'
|
||||
|
||||
export type FlowNodeKind =
|
||||
@@ -65,9 +67,12 @@ export interface FlowBuildOptions {
|
||||
selectedUsers?: string[]
|
||||
colorPalette?: readonly string[]
|
||||
visibleStages?: FlowNodeKind[]
|
||||
topNodeLimit?: number
|
||||
overflowMode?: FlowOverflowMode
|
||||
// Resolves the label for a token whose record no longer exists (deleted).
|
||||
// Lets the caller inject a localized string such as "Deleted (123)".
|
||||
deletedTokenLabel?: (tokenId: number) => string
|
||||
otherNodeLabel?: (kind: FlowNodeKind) => string
|
||||
}
|
||||
|
||||
export interface DashboardFlowNode {
|
||||
|
||||
Reference in New Issue
Block a user