feat(dashboard): add traffic flow sankey chart (#5465)

* feat(dashboard): add traffic flow sankey chart

Add dashboard flow APIs and a Sankey-based flow view with user, optional API key, model, and channel layers.\n\nReuse the dashboard VChart palette, add precise link/node tooltips and interactions, and cover filtering, layer ordering, color stability, and error states with tests.

* feat: build flow chart from quota data

---------

Co-authored-by: CaIon <i@caion.me>
This commit is contained in:
Quaternijkon
2026-06-20 18:57:47 +08:00
committed by GitHub
co-authored by CaIon
parent f9e508bdae
commit a68041f7d6
29 changed files with 2659 additions and 77 deletions
@@ -0,0 +1,429 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Fragment, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart'
import {
Activity,
ChevronRight,
CircleAlert,
EyeOff,
GitBranch,
Hash,
Info,
Loader2,
Route,
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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Toggle } from '@/components/ui/toggle'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { MultiSelect } from '@/components/multi-select'
import { getFlowQuotaDates } from '@/features/dashboard/api'
import {
buildDashboardFlowData,
buildFlowSankeySpec,
buildQueryParams,
getDefaultDays,
getFlowStages,
} from '@/features/dashboard/lib'
import {
compactFlowSelectionLabel,
flowDisplayState,
requireSuccessfulFlowRows,
} from '@/features/dashboard/lib/flow-selection'
import type {
DashboardFilters,
FlowMetric,
FlowNodeKind,
FlowRole,
FlowSummary,
} from '@/features/dashboard/types'
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 },
] as const
// A Sankey needs at least two columns to render any link.
const MIN_VISIBLE_STAGES = 2
const FLOW_STAGE_META: Record<
FlowNodeKind,
{ labelKey: string; descKey: string }
> = {
user: {
labelKey: 'User',
descKey: 'The user who made the requests',
},
node: {
labelKey: 'Node',
descKey: 'The deployment node that handled the requests',
},
token: {
labelKey: 'Token',
descKey: 'The API key used for the requests',
},
group: {
labelKey: 'Group',
descKey: 'The user group applied to the requests',
},
model: {
labelKey: 'Model',
descKey: 'The model that was requested',
},
channel: {
labelKey: 'Channel',
descKey: 'The upstream channel that served the requests',
},
}
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>
)
}
export function FlowCharts(props: FlowChartsProps) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const user = useAuthStore((state) => state.auth.user)
const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN)
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user'
const [metric, setMetric] = useState<FlowMetric>('quota')
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
const visibleStages = useMemo(
() => stages.filter((stage) => !hiddenStages.includes(stage)),
[stages, hiddenStages]
)
const toggleStage = (stage: FlowNodeKind) => {
setHiddenStages((prev) => {
const hidden = new Set(prev)
if (hidden.has(stage)) {
hidden.delete(stage)
} else {
const remaining = stages.filter((item) => !hidden.has(item)).length
if (remaining <= MIN_VISIBLE_STAGES) return prev
hidden.add(stage)
}
return stages.filter((item) => hidden.has(item))
})
}
const timeRange = useMemo(
() =>
computeTimeRange(
getDefaultDays(props.filters?.time_granularity),
props.filters?.start_timestamp,
props.filters?.end_timestamp
),
[
props.filters?.end_timestamp,
props.filters?.start_timestamp,
props.filters?.time_granularity,
]
)
const flowQueryParams = useMemo(
() => buildQueryParams(timeRange, props.filters),
[props.filters, timeRange]
)
const {
data: flowRows,
error: flowError,
isError,
isLoading,
} = useQuery({
queryKey: ['dashboard', 'flow', flowQueryParams, flowRole],
queryFn: () => getFlowQuotaDates(flowQueryParams, isAdmin),
select: (res) =>
requireSuccessfulFlowRows(res, t('Please try again later.')),
staleTime: 60_000,
})
const flowData = useMemo(
() =>
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
role: flowRole,
selectedUsers,
visibleStages,
deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }),
}),
[flowRole, flowRows, isLoading, metric, selectedUsers, visibleStages, t]
)
const userFilterOptions = useMemo(
() =>
flowData.filterOptions.users.map((user) => ({
label: `${user.label} · ${user.valueLabel}`,
value: user.value,
})),
[flowData.filterOptions.users]
)
const chartTitle = t('Flow')
const flowSpec = useMemo(
() =>
buildFlowSankeySpec(flowData.flow, chartTitle, formatQuota, {
quota: t('Quota'),
tokens: t('Tokens'),
requests: t('Requests'),
share: t('Share'),
}),
[chartTitle, flowData.flow, t]
)
const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light'
const chartKey = [
metric,
flowRole,
selectedUsers.join(','),
visibleStages.join(','),
flowRows?.length ?? 0,
resolvedTheme,
].join('-')
const displayState = flowDisplayState({
isLoading,
isError,
linkCount: flowData.flow.links.length,
themeReady,
})
const flowErrorMessage =
flowError instanceof Error
? flowError.message
: t('Please try again later.')
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>
)}
{isLoading && (
<Loader2 className='text-muted-foreground size-4 animate-spin' />
)}
</div>
<div className='overflow-hidden rounded-lg border'>
<div className='flex w-full flex-col gap-2 border-b px-3 py-2 sm:px-5 sm:py-3 lg:flex-row lg:items-center lg:justify-between'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='text-muted-foreground/60 size-4 shrink-0' />
<div className='text-sm font-semibold'>{chartTitle}</div>
</div>
<TooltipProvider>
<div className='flex min-w-0 items-center gap-1 overflow-x-auto pb-1 lg:justify-end lg:pb-0'>
<Tooltip>
<TooltipTrigger
render={
<button
type='button'
className='text-muted-foreground/60 hover:text-foreground flex size-6 shrink-0 items-center justify-center rounded-md'
aria-label={t('Show or hide flow columns')}
/>
}
>
<Info className='size-3.5' />
</TooltipTrigger>
<TooltipContent className='max-w-[16rem]'>
{t('Click a stage to show or hide that column')}
</TooltipContent>
</Tooltip>
{stages.map((stage, index) => {
const meta = FLOW_STAGE_META[stage]
const visible = !hiddenStages.includes(stage)
return (
<Fragment key={stage}>
{index > 0 && (
<ChevronRight className='text-muted-foreground/40 size-3.5 shrink-0' />
)}
<Tooltip>
<TooltipTrigger
render={
<Toggle
variant='outline'
size='sm'
pressed={visible}
onPressedChange={() => toggleStage(stage)}
aria-label={t(meta.labelKey)}
className={cn('shrink-0', !visible && 'opacity-50')}
/>
}
>
{!visible && <EyeOff className='size-3' />}
{t(meta.labelKey)}
</TooltipTrigger>
<TooltipContent>{t(meta.descKey)}</TooltipContent>
</Tooltip>
</Fragment>
)
})}
</div>
</TooltipProvider>
</div>
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
{displayState === 'loading' ? (
<Skeleton className='h-full w-full' />
) : displayState === 'error' ? (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
) : displayState === 'empty' ? (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div>
</div>
</div>
)
}
@@ -53,6 +53,8 @@ interface ModelsFilterProps {
preferences: DashboardChartPreferences
onFilterChange: (filters: DashboardFilters) => void
onReset: () => void
titleKey?: string
descriptionKey?: string
}
/**
@@ -145,8 +147,11 @@ export function ModelsFilter(props: ModelsFilterProps) {
{t('Filter')}
</Button>
}
title={t('Model Analytics Filters')}
description={t('Filter the model analytics view by time range and user.')}
title={t(props.titleKey ?? 'Model Analytics Filters')}
description={t(
props.descriptionKey ??
'Filter the model analytics view by time range and user.'
)}
contentClassName='max-sm:h-dvh max-sm:w-screen max-sm:max-w-none max-sm:rounded-none max-sm:p-4 sm:max-w-lg'
contentHeight='min(48vh, 460px)'
footerClassName='grid grid-cols-2 gap-2 sm:flex'