Commit 06194801 by CaIon

feat: limit dashboard flow nodes

parent d58029c6
......@@ -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 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>
<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 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>
)}
{isLoading && (
<Loader2 className='text-muted-foreground size-4 animate-spin' />
)}
</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'>
......
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',
......
......@@ -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),
}
}
......
......@@ -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 {
......
......@@ -625,6 +625,9 @@
"by": "by",
"By category": "By category",
"By model author": "By model author",
"By quota": "By quota",
"By requests": "By requests",
"By tokens": "By tokens",
"Cache": "Cache",
"Cache create (1h) price": "Cache create (1h) price",
"Cache create price": "Cache create price",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "Choose between system preference, light mode, or dark mode",
"Choose channels to sync upstream ratio configurations from": "Choose channels to sync upstream ratio configurations from",
"Choose Group": "Choose Group",
"Choose how flow widths are calculated.": "Choose how flow widths are calculated.",
"Choose how quota values are shown to users": "Choose how quota values are shown to users",
"Choose how the platform will operate": "Choose how the platform will operate",
"Choose how to filter domains": "Choose how to filter domains",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "Disk Hits",
"Disk Threshold (%)": "Disk Threshold (%)",
"Display in Currency": "Display in Currency",
"Display limit": "Display limit",
"Display Mode": "Display Mode",
"Display name": "Display name",
"Display Name": "Display Name",
......@@ -1868,6 +1873,7 @@
"Floating": "Floating",
"Flow": "Flow",
"Flow Filters": "Flow Filters",
"Flow width metric": "Flow width metric",
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.",
"Flush interval (minutes)": "Flush interval (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "More than 999 days left",
"More...": "More...",
"Most-used models in the selected period and category": "Most-used models in the selected period and category",
"Merge into Other": "Merge into Other",
"Move": "Move",
"Move a request header": "Move a request header",
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
......@@ -2936,6 +2943,12 @@
"org-...": "org-...",
"Original Model": "Original Model",
"Other": "Other",
"Other channels": "Other channels",
"Other groups": "Other groups",
"Other models": "Other models",
"Other nodes": "Other nodes",
"Other tokens": "Other tokens",
"Other users": "Other users",
"Outage": "Outage",
"Output": "Output",
"Output aspect ratio": "Output aspect ratio",
......@@ -2945,6 +2958,7 @@
"Output tokens": "Output tokens",
"Output Tokens": "Output Tokens",
"Overage limited": "Overage limited",
"Overflow items": "Overflow items",
"overall": "overall",
"Overnight range": "Overnight range",
"override": "override",
......
......@@ -625,6 +625,9 @@
"by": "par",
"By category": "Par catégorie",
"By model author": "Par auteur du modèle",
"By quota": "Par quota",
"By requests": "Par requêtes",
"By tokens": "Par jetons",
"Cache": "Cache",
"Cache create (1h) price": "Prix de création du cache (1 h)",
"Cache create price": "Prix de création du cache",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "Choisissez entre la préférence système, le mode clair ou le mode sombre",
"Choose channels to sync upstream ratio configurations from": "Choisissez les canaux à partir desquels synchroniser les configurations de ratio amont",
"Choose Group": "Choisir un groupe",
"Choose how flow widths are calculated.": "Choisissez comment l'épaisseur des flux est calculée.",
"Choose how quota values are shown to users": "Choisissez comment les valeurs de quota sont affichées aux utilisateurs",
"Choose how the platform will operate": "Choisissez le mode de fonctionnement de la plateforme",
"Choose how to filter domains": "Choisissez comment filtrer les domaines",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "Hits disque",
"Disk Threshold (%)": "Seuil disque (%)",
"Display in Currency": "Afficher en devise",
"Display limit": "Limite d'affichage",
"Display Mode": "Mode d'affichage",
"Display name": "Nom d'affichage",
"Display Name": "Nom d'affichage",
......@@ -1868,6 +1873,7 @@
"Floating": "Flottant",
"Flow": "Flux",
"Flow Filters": "Filtres de flux",
"Flow width metric": "Indicateur de largeur du flux",
"FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.",
"Flush interval (minutes)": "Intervalle d’écriture (minutes)",
"Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "Plus de 999 jours restants",
"More...": "Plus...",
"Most-used models in the selected period and category": "Modèles les plus utilisés dans la période et catégorie choisies",
"Merge into Other": "Fusionner dans Autres",
"Move": "Déplacer",
"Move a request header": "Déplacer un en-tête de requête",
"Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal",
......@@ -2936,6 +2943,12 @@
"org-...": "org-...",
"Original Model": "Modèle Original",
"Other": "Autre",
"Other channels": "Autres canaux",
"Other groups": "Autres groupes",
"Other models": "Autres modèles",
"Other nodes": "Autres nœuds",
"Other tokens": "Autres jetons",
"Other users": "Autres utilisateurs",
"Outage": "Interruption",
"Output": "Sortie",
"Output aspect ratio": "Format d'image de sortie",
......@@ -2945,6 +2958,7 @@
"Output tokens": "Jetons de sortie",
"Output Tokens": "Tokens de sortie",
"Overage limited": "Dépassement limité",
"Overflow items": "Éléments excédentaires",
"overall": "global",
"Overnight range": "Plage nocturne",
"override": "remplacer",
......
......@@ -625,6 +625,9 @@
"by": "によって",
"By category": "カテゴリ別",
"By model author": "モデル提供者別",
"By quota": "クォータ別",
"By requests": "リクエスト数別",
"By tokens": "トークン数別",
"Cache": "キャッシュ",
"Cache create (1h) price": "キャッシュ作成価格 (1時間)",
"Cache create price": "キャッシュ作成価格",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "システム設定、ライトモード、またはダークモードから選択します",
"Choose channels to sync upstream ratio configurations from": "アップストリームの比率設定を同期するチャネルを選択してください",
"Choose Group": "グループを選択",
"Choose how flow widths are calculated.": "フローの線幅をどの指標で計算するかを選択します。",
"Choose how quota values are shown to users": "クォータ値がユーザーにどのように表示されるかを選択してください",
"Choose how the platform will operate": "プラットフォームの運用方法を選択",
"Choose how to filter domains": "ドメインをフィルタリングする方法を選択してください",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "ディスクヒット",
"Disk Threshold (%)": "ディスク閾値 (%)",
"Display in Currency": "通貨で表示",
"Display limit": "表示数",
"Display Mode": "表示モード",
"Display name": "表示名",
"Display Name": "表示名",
......@@ -1868,6 +1873,7 @@
"Floating": "フローティング",
"Flow": "フロー",
"Flow Filters": "フローフィルター",
"Flow width metric": "線幅の指標",
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。",
"Flush interval (minutes)": "書き込み間隔(分)",
"Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "999日以上",
"More...": "その他...",
"Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル",
"Merge into Other": "その他にまとめる",
"Move": "移動",
"Move a request header": "リクエストヘッダーを移動",
"Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する",
......@@ -2936,6 +2943,12 @@
"org-...": "org-...",
"Original Model": "オリジナルモデル",
"Other": "その他",
"Other channels": "その他のチャネル",
"Other groups": "その他のグループ",
"Other models": "その他のモデル",
"Other nodes": "その他のノード",
"Other tokens": "その他のトークン",
"Other users": "その他のユーザー",
"Outage": "ダウンタイム",
"Output": "出力",
"Output aspect ratio": "出力アスペクト比",
......@@ -2945,6 +2958,7 @@
"Output tokens": "出力トークン",
"Output Tokens": "出力トークン",
"Overage limited": "超過利用制限中",
"Overflow items": "超過項目",
"overall": "全体",
"Overnight range": "日跨ぎ範囲",
"override": "上書き",
......
......@@ -625,6 +625,9 @@
"by": "от",
"By category": "По категориям",
"By model author": "По автору модели",
"By quota": "По квоте",
"By requests": "По запросам",
"By tokens": "По токенам",
"Cache": "Кэш",
"Cache create (1h) price": "Цена создания кэша (1 ч)",
"Cache create price": "Цена создания кэша",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "Выберите между системными настройками, светлым режимом или темным режимом",
"Choose channels to sync upstream ratio configurations from": "Выберите каналы для синхронизации конфигураций соотношений из вышестоящих источников",
"Choose Group": "Выбрать группу",
"Choose how flow widths are calculated.": "Выберите, как рассчитывается ширина потоков.",
"Choose how quota values are shown to users": "Выберите, как значения квоты отображаются пользователям",
"Choose how the platform will operate": "Выберите режим работы платформы",
"Choose how to filter domains": "Выберите, как фильтровать домены",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "Попаданий диска",
"Disk Threshold (%)": "Порог диска (%)",
"Display in Currency": "Отображать в валюте",
"Display limit": "Лимит отображения",
"Display Mode": "Режим отображения",
"Display name": "Отображаемое имя",
"Display Name": "Отображаемое имя",
......@@ -1868,6 +1873,7 @@
"Floating": "Плавающая",
"Flow": "Поток",
"Flow Filters": "Фильтры потока",
"Flow width metric": "Метрика ширины потока",
"FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.",
"Flush interval (minutes)": "Интервал записи (минуты)",
"Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "Более 999 дней",
"More...": "Подробнее...",
"Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории",
"Merge into Other": "Объединить в «Другое»",
"Move": "Переместить",
"Move a request header": "Переместить заголовок запроса",
"Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс",
......@@ -2936,6 +2943,12 @@
"org-...": "орг-...",
"Original Model": "Оригинальная модель",
"Other": "Другое",
"Other channels": "Другие каналы",
"Other groups": "Другие группы",
"Other models": "Другие модели",
"Other nodes": "Другие узлы",
"Other tokens": "Другие токены",
"Other users": "Другие пользователи",
"Outage": "Простой",
"Output": "Вывод",
"Output aspect ratio": "Соотношение сторон",
......@@ -2945,6 +2958,7 @@
"Output tokens": "Выходные токены",
"Output Tokens": "Выходные токены",
"Overage limited": "Ограничение перерасхода",
"Overflow items": "Элементы сверх лимита",
"overall": "всего",
"Overnight range": "Диапазон через полночь",
"override": "переопределить",
......
......@@ -625,6 +625,9 @@
"by": "by",
"By category": "Theo danh mục",
"By model author": "Theo nhà phát triển mô hình",
"By quota": "Theo hạn ngạch",
"By requests": "Theo số yêu cầu",
"By tokens": "Theo số token",
"Cache": "Bộ nhớ đệm",
"Cache create (1h) price": "Giá tạo cache (1 giờ)",
"Cache create price": "Giá tạo cache",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "Lựa chọn giữa tùy chọn hệ thống, chế độ sáng hoặc chế độ tối",
"Choose channels to sync upstream ratio configurations from": "Chọn các kênh để đồng bộ cấu hình tỷ lệ đường lên từ",
"Choose Group": "Chọn Nhóm",
"Choose how flow widths are calculated.": "Chọn cách tính độ rộng của các luồng.",
"Choose how quota values are shown to users": "Chọn cách hiển thị giá trị hạn ngạch cho người dùng",
"Choose how the platform will operate": "Chọn cách nền tảng sẽ hoạt động",
"Choose how to filter domains": "Chọn cách lọc tên miền",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "Lượt truy cập đĩa",
"Disk Threshold (%)": "Ngưỡng đĩa (%)",
"Display in Currency": "Hiển thị tiền tệ",
"Display limit": "Giới hạn hiển thị",
"Display Mode": "Chế độ hiển thị",
"Display name": "Tên hiển thị",
"Display Name": "Tên hiển thị",
......@@ -1868,6 +1873,7 @@
"Floating": "Nổi",
"Flow": "Luồng",
"Flow Filters": "Bộ lọc luồng",
"Flow width metric": "Chỉ số độ rộng luồng",
"FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.",
"Flush interval (minutes)": "Khoảng ghi xuống DB (phút)",
"Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "Hơn 999 ngày",
"More...": "Thêm...",
"Most-used models in the selected period and category": "Mô hình được dùng nhiều nhất trong khoảng thời gian và danh mục đã chọn",
"Merge into Other": "Gộp vào Khác",
"Move": "Di chuyển",
"Move a request header": "Di chuyển header yêu cầu",
"Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn",
......@@ -2936,6 +2943,12 @@
"org-...": "org-...",
"Original Model": "Nguyên mẫu",
"Other": "Khác",
"Other channels": "Kênh khác",
"Other groups": "Nhóm khác",
"Other models": "Mô hình khác",
"Other nodes": "Nút khác",
"Other tokens": "Token khác",
"Other users": "Người dùng khác",
"Outage": "Gián đoạn",
"Output": "Đầu ra",
"Output aspect ratio": "Tỉ lệ khung hình",
......@@ -2945,6 +2958,7 @@
"Output tokens": "Token đầu ra",
"Output Tokens": "Token đầu ra",
"Overage limited": "Đã giới hạn vượt mức",
"Overflow items": "Mục vượt giới hạn",
"overall": "tổng",
"Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè",
......
......@@ -625,6 +625,9 @@
"by": "由",
"By category": "按行业",
"By model author": "按模型厂商",
"By quota": "按额度",
"By requests": "按请求数",
"By tokens": "按 Token",
"Cache": "缓存",
"Cache create (1h) price": "1 小时缓存写入价格",
"Cache create price": "缓存写入价格",
......@@ -746,6 +749,7 @@
"Choose between system preference, light mode, or dark mode": "选择系统偏好、浅色模式或深色模式",
"Choose channels to sync upstream ratio configurations from": "选择要同步上游比例配置的渠道",
"Choose Group": "选择分组",
"Choose how flow widths are calculated.": "选择分流图连线宽度的计算方式。",
"Choose how quota values are shown to users": "选择如何向用户展示配额值",
"Choose how the platform will operate": "选择平台的运行模式",
"Choose how to filter domains": "选择如何过滤域名",
......@@ -1315,6 +1319,7 @@
"Disk Hits": "磁盘命中",
"Disk Threshold (%)": "磁盘阈值 (%)",
"Display in Currency": "以货币显示",
"Display limit": "显示数量",
"Display Mode": "显示模式",
"Display name": "显示名称",
"Display Name": "显示名称",
......@@ -1868,6 +1873,7 @@
"Floating": "浮动",
"Flow": "分流",
"Flow Filters": "分流筛选",
"Flow width metric": "连线粗细",
"FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。",
"Flush interval (minutes)": "刷库间隔(分钟)",
"Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。",
......@@ -2551,6 +2557,7 @@
"More than 999 days left": "剩余超过 999 天",
"More...": "更多...",
"Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型",
"Merge into Other": "合并为其他",
"Move": "移动",
"Move a request header": "移动请求头",
"Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额",
......@@ -2936,6 +2943,12 @@
"org-...": "org-...",
"Original Model": "原始模型",
"Other": "其他",
"Other channels": "其他渠道",
"Other groups": "其他分组",
"Other models": "其他模型",
"Other nodes": "其他节点",
"Other tokens": "其他令牌",
"Other users": "其他用户",
"Outage": "中断",
"Output": "输出",
"Output aspect ratio": "输出宽高比",
......@@ -2945,6 +2958,7 @@
"Output tokens": "输出 token",
"Output Tokens": "输出 Token",
"Overage limited": "超额受限",
"Overflow items": "超出项",
"overall": "总体",
"Overnight range": "跨日范围",
"override": "覆盖",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment