feat: limit dashboard flow nodes

This commit is contained in:
CaIon
2026-06-20 22:09:03 +08:00
parent d58029c637
commit 061948011c
10 changed files with 575 additions and 142 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {