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:
+23
-1
@@ -17,7 +17,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import type { QuotaDataItem, UptimeGroupResult } from './types'
|
||||
import type {
|
||||
FlowQuotaDataItem,
|
||||
QuotaDataItem,
|
||||
UptimeGroupResult,
|
||||
} from './types'
|
||||
|
||||
// ============================================================================
|
||||
// Dashboard APIs
|
||||
@@ -61,6 +65,24 @@ export async function getUserQuotaDataByUsers(params: {
|
||||
return res.data
|
||||
}
|
||||
|
||||
export async function getFlowQuotaDates(
|
||||
params: {
|
||||
start_timestamp: number
|
||||
end_timestamp: number
|
||||
default_time?: string
|
||||
username?: string
|
||||
},
|
||||
isAdmin = false
|
||||
) {
|
||||
const endpoint = isAdmin ? '/api/data/flow' : '/api/data/flow/self'
|
||||
const res = await api.get<{
|
||||
success: boolean
|
||||
data?: FlowQuotaDataItem[]
|
||||
message?: string
|
||||
}>(endpoint, { params })
|
||||
return res.data
|
||||
}
|
||||
|
||||
// Get uptime monitoring status for all services
|
||||
export async function getUptimeStatus() {
|
||||
const res = await api.get<{ success: boolean; data: UptimeGroupResult[] }>(
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
+7
-2
@@ -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'
|
||||
|
||||
+29
-2
@@ -77,6 +77,12 @@ const LazyUserCharts = lazy(() =>
|
||||
}))
|
||||
)
|
||||
|
||||
const LazyFlowCharts = lazy(() =>
|
||||
import('./components/flow/flow-charts').then((m) => ({
|
||||
default: m.FlowCharts,
|
||||
}))
|
||||
)
|
||||
|
||||
function LogStatCardsFallback() {
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
@@ -137,6 +143,9 @@ const SECTION_META: Record<DashboardSectionId, { titleKey: string }> = {
|
||||
models: {
|
||||
titleKey: 'Model Call Analytics',
|
||||
},
|
||||
flow: {
|
||||
titleKey: 'Flow',
|
||||
},
|
||||
users: {
|
||||
titleKey: 'User Analytics',
|
||||
},
|
||||
@@ -217,6 +226,17 @@ export function Dashboard() {
|
||||
/>
|
||||
</>
|
||||
) : null
|
||||
const flowActions =
|
||||
activeSection === 'flow' ? (
|
||||
<ModelsFilter
|
||||
preferences={chartPreferences}
|
||||
onFilterChange={handleFilterChange}
|
||||
onReset={handleResetFilters}
|
||||
titleKey='Flow Filters'
|
||||
descriptionKey='Filter the traffic flow view by time range and user.'
|
||||
/>
|
||||
) : null
|
||||
const sectionActions = modelActions ?? flowActions
|
||||
|
||||
return (
|
||||
<SectionPageLayout>
|
||||
@@ -238,9 +258,9 @@ export function Dashboard() {
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{modelActions != null && (
|
||||
{sectionActions != null && (
|
||||
<div className='flex shrink-0 flex-wrap items-center gap-1.5 sm:gap-2'>
|
||||
{modelActions}
|
||||
{sectionActions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -298,6 +318,13 @@ export function Dashboard() {
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
)}
|
||||
{activeSection === 'flow' && (
|
||||
<FadeIn>
|
||||
<Suspense fallback={<ModelChartsFallback />}>
|
||||
<LazyFlowCharts filters={modelFilters} />
|
||||
</Suspense>
|
||||
</FadeIn>
|
||||
)}
|
||||
</div>
|
||||
</SectionPageLayout.Content>
|
||||
</SectionPageLayout>
|
||||
|
||||
+5
-3
@@ -38,13 +38,15 @@ type TooltipLineItem = {
|
||||
shapeSize?: number
|
||||
}
|
||||
|
||||
function getVChartDefaultColors(domainLength: number) {
|
||||
export function getDashboardChartColors(domainLength: number): string[] {
|
||||
const scheme =
|
||||
vchartDefaultDataScheme.find(
|
||||
(item) => !item.maxDomainLength || domainLength <= item.maxDomainLength
|
||||
) ?? vchartDefaultDataScheme[vchartDefaultDataScheme.length - 1]
|
||||
|
||||
return scheme.scheme
|
||||
return scheme.scheme.filter(
|
||||
(color): color is string => typeof color === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function renderQuotaCompat(rawQuota: number, digits = 4): string {
|
||||
@@ -259,7 +261,7 @@ export function processChartData(
|
||||
const sortedTimes = Array.from(timeModelMap.keys()).sort()
|
||||
const sortedModels = [...allModels].sort()
|
||||
const modelColorDomain = Array.from(new Set([...sortedModels, otherLabel]))
|
||||
const modelColorRange = getVChartDefaultColors(modelColorDomain.length)
|
||||
const modelColorRange = getDashboardChartColors(modelColorDomain.length)
|
||||
const otherColor = modelColorRange[modelColorDomain.indexOf(otherLabel)]
|
||||
const otherTooltipColor =
|
||||
typeof otherColor === 'string' ? otherColor : '#FF8A00'
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
import type { FlowUserFilterOption } from '../types'
|
||||
import {
|
||||
compactFlowSelectionLabel,
|
||||
flowDisplayState,
|
||||
requireSuccessfulFlowRows,
|
||||
visibleFlowUsers,
|
||||
} from './flow-selection'
|
||||
|
||||
const users: FlowUserFilterOption[] = [
|
||||
{
|
||||
value: 'user:1',
|
||||
label: 'dry',
|
||||
valueLabel: '100',
|
||||
valueRaw: 100,
|
||||
color: '#1664ff',
|
||||
},
|
||||
{
|
||||
value: 'user:2',
|
||||
label: 'jrc',
|
||||
valueLabel: '70',
|
||||
valueRaw: 70,
|
||||
color: '#1ac6ff',
|
||||
},
|
||||
]
|
||||
|
||||
describe('dashboard flow selection helpers', () => {
|
||||
test('limits user chips to currently visible users', () => {
|
||||
assert.deepEqual(
|
||||
visibleFlowUsers(users, []).map((user) => user.value),
|
||||
['user:1', 'user:2']
|
||||
)
|
||||
assert.deepEqual(
|
||||
visibleFlowUsers(users, ['user:2']).map((user) => user.value),
|
||||
['user:2']
|
||||
)
|
||||
})
|
||||
|
||||
test('filters visible users without mutating the source options', () => {
|
||||
const visible = visibleFlowUsers(users, ['user:1'])
|
||||
|
||||
assert.deepEqual(
|
||||
visible.map((user) => user.value),
|
||||
['user:1']
|
||||
)
|
||||
assert.deepEqual(
|
||||
users.map((user) => user.value),
|
||||
['user:1', 'user:2']
|
||||
)
|
||||
})
|
||||
|
||||
test('formats compact selected counts for flow multiselect summaries', () => {
|
||||
assert.equal(compactFlowSelectionLabel(0), '*')
|
||||
assert.equal(compactFlowSelectionLabel(1), '1')
|
||||
assert.equal(compactFlowSelectionLabel(23), '23')
|
||||
})
|
||||
|
||||
test('prioritizes loading and error states before empty flow data', () => {
|
||||
assert.equal(
|
||||
flowDisplayState({
|
||||
isLoading: true,
|
||||
isError: true,
|
||||
linkCount: 0,
|
||||
themeReady: true,
|
||||
}),
|
||||
'loading'
|
||||
)
|
||||
assert.equal(
|
||||
flowDisplayState({
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
linkCount: 0,
|
||||
themeReady: true,
|
||||
}),
|
||||
'error'
|
||||
)
|
||||
assert.equal(
|
||||
flowDisplayState({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
linkCount: 0,
|
||||
themeReady: true,
|
||||
}),
|
||||
'empty'
|
||||
)
|
||||
assert.equal(
|
||||
flowDisplayState({
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
linkCount: 1,
|
||||
themeReady: false,
|
||||
}),
|
||||
'loading'
|
||||
)
|
||||
})
|
||||
|
||||
test('throws unsuccessful flow responses instead of treating them as empty data', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
requireSuccessfulFlowRows(
|
||||
{ success: false, data: [], message: 'database unavailable' },
|
||||
'Failed to load'
|
||||
),
|
||||
/database unavailable/
|
||||
)
|
||||
assert.deepEqual(
|
||||
requireSuccessfulFlowRows(
|
||||
{ success: true, data: [{ user_id: 1, quota: 10 }] },
|
||||
'Failed to load'
|
||||
),
|
||||
[{ user_id: 1, quota: 10 }]
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 type {
|
||||
FlowQuotaDataItem,
|
||||
FlowUserFilterOption,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
|
||||
|
||||
export interface FlowResponse {
|
||||
success: boolean
|
||||
data?: FlowQuotaDataItem[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function requireSuccessfulFlowRows(
|
||||
response: FlowResponse,
|
||||
fallbackMessage: string
|
||||
): FlowQuotaDataItem[] {
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || fallbackMessage)
|
||||
}
|
||||
return response.data ?? []
|
||||
}
|
||||
|
||||
export function flowDisplayState(options: {
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
linkCount: number
|
||||
themeReady: boolean
|
||||
}): FlowDisplayState {
|
||||
if (options.isLoading) return 'loading'
|
||||
if (options.isError) return 'error'
|
||||
if (options.linkCount === 0) return 'empty'
|
||||
if (!options.themeReady) return 'loading'
|
||||
return 'chart'
|
||||
}
|
||||
|
||||
export function compactFlowSelectionLabel(count: number): string {
|
||||
return count > 0 ? String(count) : '*'
|
||||
}
|
||||
|
||||
export function visibleFlowUsers(
|
||||
users: FlowUserFilterOption[],
|
||||
selectedUsers: string[]
|
||||
): FlowUserFilterOption[] {
|
||||
if (selectedUsers.length === 0) return users
|
||||
const selected = new Set(selectedUsers)
|
||||
return users.filter((user) => selected.has(user.value))
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
import type { FlowQuotaDataItem } from '../types'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
buildFlowFilterOptions,
|
||||
buildFlowSankeySpec,
|
||||
} from './flow'
|
||||
|
||||
const rows: FlowQuotaDataItem[] = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: 'alice',
|
||||
node_name: 'node-a',
|
||||
token_id: 11,
|
||||
token_name: 'primary',
|
||||
use_group: 'vip',
|
||||
channel_id: 101,
|
||||
channel_name: 'east',
|
||||
model_name: 'gpt-4.1',
|
||||
quota: 100,
|
||||
token_used: 40,
|
||||
count: 2,
|
||||
},
|
||||
{
|
||||
user_id: 1,
|
||||
username: 'alice',
|
||||
node_name: 'node-a',
|
||||
token_id: 11,
|
||||
token_name: 'primary',
|
||||
use_group: 'vip',
|
||||
channel_id: 102,
|
||||
channel_name: 'west',
|
||||
model_name: 'gpt-4.1',
|
||||
quota: 50,
|
||||
token_used: 20,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: 'bob',
|
||||
node_name: 'node-b',
|
||||
token_id: 22,
|
||||
token_name: 'backup',
|
||||
use_group: 'default',
|
||||
channel_id: 101,
|
||||
channel_name: 'east',
|
||||
model_name: 'claude-4-sonnet',
|
||||
quota: 70,
|
||||
token_used: 30,
|
||||
count: 3,
|
||||
},
|
||||
]
|
||||
|
||||
describe('dashboard flow data', () => {
|
||||
test('builds normal user token-group-model flow', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', {
|
||||
role: 'user',
|
||||
})
|
||||
|
||||
assert.equal(result.summary.quota, 150)
|
||||
assert.equal(result.summary.tokens, 60)
|
||||
assert.equal(result.summary.requests, 3)
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['group:vip', 'model:gpt-4.1', 150],
|
||||
['token:11', 'group:vip', 150],
|
||||
]
|
||||
)
|
||||
assert.equal(
|
||||
result.flow.nodes.some((node) => node.kind === 'channel'),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('builds admin user-group-model-channel flow', () => {
|
||||
const result = buildDashboardFlowData(rows, 'quota', {
|
||||
role: 'admin',
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['group:default', 'model:claude-4-sonnet', 70],
|
||||
['group:vip', 'model:gpt-4.1', 150],
|
||||
['model:claude-4-sonnet', 'channel:101', 70],
|
||||
['model:gpt-4.1', 'channel:101', 100],
|
||||
['model:gpt-4.1', 'channel:102', 50],
|
||||
['user:1', 'group:vip', 150],
|
||||
['user:2', 'group:default', 70],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('builds root user-node-token-group-model-channel flow', () => {
|
||||
const result = buildDashboardFlowData(rows, 'requests', {
|
||||
role: 'root',
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['group:default', 'model:claude-4-sonnet', 3],
|
||||
['group:vip', 'model:gpt-4.1', 3],
|
||||
['model:claude-4-sonnet', 'channel:101', 3],
|
||||
['model:gpt-4.1', 'channel:101', 2],
|
||||
['model:gpt-4.1', 'channel:102', 1],
|
||||
['node:node-a', 'token:11', 3],
|
||||
['node:node-b', 'token:22', 3],
|
||||
['token:11', 'group:vip', 3],
|
||||
['token:22', 'group:default', 3],
|
||||
['user:1', 'node:node-a', 3],
|
||||
['user:2', 'node:node-b', 3],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('filters by selected users', () => {
|
||||
const result = buildDashboardFlowData(rows, 'quota', {
|
||||
role: 'admin',
|
||||
selectedUsers: ['user:2'],
|
||||
})
|
||||
|
||||
assert.equal(result.summary.quota, 70)
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['group:default', 'model:claude-4-sonnet', 70],
|
||||
['model:claude-4-sonnet', 'channel:101', 70],
|
||||
['user:2', 'group:default', 70],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('reconnects links when a middle stage is hidden', () => {
|
||||
const result = buildDashboardFlowData(rows, 'quota', {
|
||||
role: 'admin',
|
||||
visibleStages: ['user', 'model', 'channel'],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['model:claude-4-sonnet', 'channel:101', 70],
|
||||
['model:gpt-4.1', 'channel:101', 100],
|
||||
['model:gpt-4.1', 'channel:102', 50],
|
||||
['user:1', 'model:gpt-4.1', 150],
|
||||
['user:2', 'model:claude-4-sonnet', 70],
|
||||
]
|
||||
)
|
||||
assert.equal(
|
||||
result.flow.nodes.some((node) => node.kind === 'group'),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('ignores stage filters that would leave fewer than two columns', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', {
|
||||
role: 'user',
|
||||
visibleStages: ['model'],
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['group:vip', 'model:gpt-4.1', 150],
|
||||
['token:11', 'group:vip', 150],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('builds user filter options with stable values', () => {
|
||||
const options = buildFlowFilterOptions(rows, 'quota')
|
||||
|
||||
assert.deepEqual(
|
||||
options.users.map((user) => [user.value, user.label, user.valueLabel]),
|
||||
[
|
||||
['user:1', 'alice', '150'],
|
||||
['user:2', 'bob', '70'],
|
||||
]
|
||||
)
|
||||
assert.notEqual(options.users[0].color, options.users[1].color)
|
||||
})
|
||||
|
||||
test('builds Sankey spec with quota token request tooltips', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', {
|
||||
role: 'root',
|
||||
})
|
||||
const flowSpec = buildFlowSankeySpec(result.flow, 'Flow')
|
||||
const values = flowSpec.data[0].values[0]
|
||||
const aliceNode = values.nodes.find(
|
||||
(node: Record<string, unknown>) => node.key === 'user:1'
|
||||
)
|
||||
const userNodeLink = values.links.find(
|
||||
(link: Record<string, unknown>) =>
|
||||
link.source === 'user:1' && link.target === 'node:node-a'
|
||||
)
|
||||
|
||||
assert.equal(flowSpec.type, 'sankey')
|
||||
assert.equal(flowSpec.title.text, 'Flow')
|
||||
assert.equal(flowSpec.tooltip.mark.visible({ datum: aliceNode }), true)
|
||||
assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true)
|
||||
assert.equal(flowSpec.animation, false)
|
||||
assert.equal(values.nodes.length, 6)
|
||||
assert.equal(values.links.length, 5)
|
||||
assert.equal(aliceNode.name, 'alice')
|
||||
assert.match(userNodeLink.linkColor, /^rgba\(/)
|
||||
|
||||
const tooltipRows = flowSpec.tooltip.mark.content
|
||||
assert.deepEqual(
|
||||
tooltipRows
|
||||
.filter((row: Record<string, unknown>) =>
|
||||
typeof row.visible === 'function'
|
||||
? row.visible({ datum: userNodeLink })
|
||||
: true
|
||||
)
|
||||
.map((row: Record<string, unknown>) => [
|
||||
row.key,
|
||||
typeof row.value === 'function'
|
||||
? row.value({ datum: userNodeLink })
|
||||
: row.value,
|
||||
]),
|
||||
[
|
||||
['Quota', '100'],
|
||||
['Tokens', '40'],
|
||||
['Requests', '2'],
|
||||
['Share', '100.0%'],
|
||||
]
|
||||
)
|
||||
})
|
||||
})
|
||||
+778
@@ -0,0 +1,778 @@
|
||||
/*
|
||||
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 type {
|
||||
DashboardFlowGraph,
|
||||
DashboardFlowLink,
|
||||
DashboardFlowNode,
|
||||
FlowBuildOptions,
|
||||
FlowFilterOptions,
|
||||
FlowMetric,
|
||||
FlowNodeKind,
|
||||
FlowQuotaDataItem,
|
||||
FlowRole,
|
||||
FlowSummary,
|
||||
ProcessedFlowData,
|
||||
} from '@/features/dashboard/types'
|
||||
import { getDashboardChartColors } from './charts'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type VChartSpec = Record<string, any>
|
||||
|
||||
type FlowMetrics = {
|
||||
quota: number
|
||||
tokens: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
type FlowSankeyLabels = {
|
||||
quota: string
|
||||
tokens: string
|
||||
requests: string
|
||||
share: string
|
||||
}
|
||||
|
||||
type FlowPathNode = {
|
||||
id: string
|
||||
label: string
|
||||
kind: FlowNodeKind
|
||||
}
|
||||
|
||||
type FlowPathContext = {
|
||||
deletedTokenLabel?: (tokenId: number) => string
|
||||
}
|
||||
|
||||
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
|
||||
|
||||
const DEFAULT_FLOW_ROLE: FlowRole = 'user'
|
||||
|
||||
const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
|
||||
quota: 'Quota',
|
||||
tokens: 'Tokens',
|
||||
requests: 'Requests',
|
||||
share: 'Share',
|
||||
}
|
||||
|
||||
const DEFAULT_FLOW_CHART_COLOR = '#1664FF'
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
function rowMetrics(row: FlowQuotaDataItem): FlowMetrics {
|
||||
return {
|
||||
quota: numberValue(row.quota),
|
||||
tokens: numberValue(row.token_used),
|
||||
requests: numberValue(row.count),
|
||||
}
|
||||
}
|
||||
|
||||
function metricValue(metrics: FlowMetrics, metric: FlowMetric): number {
|
||||
if (metric === 'requests') return metrics.requests
|
||||
if (metric === 'tokens') return metrics.tokens
|
||||
return metrics.quota
|
||||
}
|
||||
|
||||
function userNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
const userID = numberValue(row.user_id)
|
||||
return {
|
||||
id: userID > 0 ? `user:${userID}` : `user:${row.username || 'unknown'}`,
|
||||
label: row.username || (userID > 0 ? `user-${userID}` : 'Unknown User'),
|
||||
kind: 'user',
|
||||
}
|
||||
}
|
||||
|
||||
function nodeNameNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
const nodeName = row.node_name || 'default-node'
|
||||
return {
|
||||
id: `node:${nodeName}`,
|
||||
label: nodeName,
|
||||
kind: 'node',
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNode(
|
||||
row: FlowQuotaDataItem,
|
||||
ctx: FlowPathContext
|
||||
): FlowPathNode {
|
||||
const tokenID = numberValue(row.token_id)
|
||||
return {
|
||||
id: tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
|
||||
label: row.token_name || deletedTokenLabel(tokenID, ctx),
|
||||
kind: 'token',
|
||||
}
|
||||
}
|
||||
|
||||
function deletedTokenLabel(tokenID: number, ctx: FlowPathContext): string {
|
||||
if (tokenID <= 0) return 'Unknown Token'
|
||||
return ctx.deletedTokenLabel?.(tokenID) ?? `token-${tokenID}`
|
||||
}
|
||||
|
||||
function groupNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
const useGroup = row.use_group || 'unknown'
|
||||
return {
|
||||
id: `group:${useGroup}`,
|
||||
label: useGroup,
|
||||
kind: 'group',
|
||||
}
|
||||
}
|
||||
|
||||
function modelNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
const model = row.model_name || 'unknown'
|
||||
return {
|
||||
id: `model:${model}`,
|
||||
label: row.model_name || 'Unknown Model',
|
||||
kind: 'model',
|
||||
}
|
||||
}
|
||||
|
||||
function channelNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
const channelID = numberValue(row.channel_id)
|
||||
return {
|
||||
id:
|
||||
channelID > 0
|
||||
? `channel:${channelID}`
|
||||
: `channel:${row.channel_name || 'unknown'}`,
|
||||
label:
|
||||
row.channel_name || (channelID > 0 ? `channel-${channelID}` : 'Unknown'),
|
||||
kind: 'channel',
|
||||
}
|
||||
}
|
||||
|
||||
const NODE_BUILDERS: Record<
|
||||
FlowNodeKind,
|
||||
(row: FlowQuotaDataItem, ctx: FlowPathContext) => FlowPathNode
|
||||
> = {
|
||||
user: userNode,
|
||||
node: nodeNameNode,
|
||||
token: tokenNode,
|
||||
group: groupNode,
|
||||
model: modelNode,
|
||||
channel: channelNode,
|
||||
}
|
||||
|
||||
const ROLE_FLOW_STAGES: Record<FlowRole, FlowNodeKind[]> = {
|
||||
root: ['user', 'node', 'token', 'group', 'model', 'channel'],
|
||||
admin: ['user', 'group', 'model', 'channel'],
|
||||
user: ['token', 'group', 'model'],
|
||||
}
|
||||
|
||||
// A Sankey needs at least two columns to draw any link, so hiding stages can
|
||||
// never collapse the path below this many columns.
|
||||
const MIN_FLOW_STAGES = 2
|
||||
|
||||
export function getFlowStages(role: FlowRole): FlowNodeKind[] {
|
||||
return ROLE_FLOW_STAGES[role] ?? ROLE_FLOW_STAGES.user
|
||||
}
|
||||
|
||||
function resolveVisibleStages(
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[]
|
||||
): FlowNodeKind[] {
|
||||
const stages = getFlowStages(role)
|
||||
if (!visibleStages) return stages
|
||||
const visible = new Set(visibleStages)
|
||||
const filtered = stages.filter((stage) => visible.has(stage))
|
||||
return filtered.length >= MIN_FLOW_STAGES ? filtered : stages
|
||||
}
|
||||
|
||||
function flowPath(
|
||||
row: FlowQuotaDataItem,
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
): FlowPathNode[] {
|
||||
return resolveVisibleStages(role, visibleStages).map((stage) =>
|
||||
NODE_BUILDERS[stage](row, ctx)
|
||||
)
|
||||
}
|
||||
|
||||
function colorAt(index: number, palette?: readonly string[]): string {
|
||||
const colors =
|
||||
palette && palette.length > 0 ? palette : getDashboardChartColors(index + 1)
|
||||
if (colors.length === 0) return DEFAULT_FLOW_CHART_COLOR
|
||||
return colors[index % colors.length] ?? DEFAULT_FLOW_CHART_COLOR
|
||||
}
|
||||
|
||||
function colorPalette(
|
||||
colorCount: number,
|
||||
palette?: readonly string[]
|
||||
): readonly string[] {
|
||||
if (palette && palette.length > 0) return palette
|
||||
const colors = getDashboardChartColors(colorCount)
|
||||
return colors.length > 0 ? colors : [DEFAULT_FLOW_CHART_COLOR]
|
||||
}
|
||||
|
||||
function alphaColor(
|
||||
color: string,
|
||||
alpha: number
|
||||
): { color: string; alpha: number } {
|
||||
const normalized = color.trim()
|
||||
const hex = normalized.startsWith('#') ? normalized.slice(1) : normalized
|
||||
if (!/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
return { color: normalized, alpha }
|
||||
}
|
||||
|
||||
const value = Number.parseInt(hex, 16)
|
||||
const red = (value >> 16) & 255
|
||||
const green = (value >> 8) & 255
|
||||
const blue = value & 255
|
||||
return {
|
||||
color: `rgba(${red}, ${green}, ${blue}, ${alpha.toFixed(2)})`,
|
||||
alpha: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function stableColorMap(
|
||||
keys: string[],
|
||||
palette?: readonly string[]
|
||||
): Map<string, string> {
|
||||
const map = new Map<string, string>()
|
||||
const uniqueKeys = Array.from(new Set(keys))
|
||||
const colors = colorPalette(uniqueKeys.length, palette)
|
||||
uniqueKeys.forEach((key, index) => {
|
||||
map.set(key, colorAt(index, colors))
|
||||
})
|
||||
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 = {}
|
||||
): FlowQuotaDataItem[] {
|
||||
const selectedUsers = new Set(options.selectedUsers ?? [])
|
||||
if (selectedUsers.size === 0) return rows
|
||||
return rows.filter((row) => selectedUsers.has(userNode(row).id))
|
||||
}
|
||||
|
||||
function addNode(
|
||||
map: Map<string, DashboardFlowNode>,
|
||||
pathNode: FlowPathNode,
|
||||
metrics: FlowMetrics,
|
||||
metric: FlowMetric,
|
||||
color: string,
|
||||
colorKey: string
|
||||
): void {
|
||||
const previous = map.get(pathNode.id) ?? {
|
||||
id: pathNode.id,
|
||||
label: pathNode.label,
|
||||
kind: pathNode.kind,
|
||||
value: 0,
|
||||
requests: 0,
|
||||
quota: 0,
|
||||
tokens: 0,
|
||||
color,
|
||||
colorKey,
|
||||
}
|
||||
previous.value += metricValue(metrics, metric)
|
||||
previous.requests += metrics.requests
|
||||
previous.quota += metrics.quota
|
||||
previous.tokens += metrics.tokens
|
||||
map.set(pathNode.id, previous)
|
||||
}
|
||||
|
||||
function addLink(
|
||||
map: Map<string, DashboardFlowLink>,
|
||||
source: FlowPathNode,
|
||||
target: FlowPathNode,
|
||||
metrics: FlowMetrics,
|
||||
metric: FlowMetric,
|
||||
color: string,
|
||||
colorKey: string
|
||||
): void {
|
||||
const key = `${source.id}\u0000${target.id}`
|
||||
const previous = map.get(key) ?? {
|
||||
source: source.id,
|
||||
target: target.id,
|
||||
value: 0,
|
||||
requests: 0,
|
||||
quota: 0,
|
||||
tokens: 0,
|
||||
sourceLabel: source.label,
|
||||
targetLabel: target.label,
|
||||
color,
|
||||
linkColor: color,
|
||||
linkAlpha: 1,
|
||||
hoverColor: color,
|
||||
colorKey,
|
||||
share: 0,
|
||||
}
|
||||
previous.value += metricValue(metrics, metric)
|
||||
previous.requests += metrics.requests
|
||||
previous.quota += metrics.quota
|
||||
previous.tokens += metrics.tokens
|
||||
map.set(key, previous)
|
||||
}
|
||||
|
||||
function assignLinkDisplayColors(links: DashboardFlowLink[]): void {
|
||||
const linksBySource = new Map<string, DashboardFlowLink[]>()
|
||||
for (const link of links) {
|
||||
const sourceLinks = linksBySource.get(link.source) ?? []
|
||||
sourceLinks.push(link)
|
||||
linksBySource.set(link.source, sourceLinks)
|
||||
}
|
||||
|
||||
for (const sourceLinks of linksBySource.values()) {
|
||||
const sortedLinks = [...sourceLinks].sort(
|
||||
(a, b) =>
|
||||
b.value - a.value || linkStableKey(a).localeCompare(linkStableKey(b))
|
||||
)
|
||||
const denominator = Math.max(sortedLinks.length - 1, 1)
|
||||
sortedLinks.forEach((link, index) => {
|
||||
const alpha =
|
||||
sortedLinks.length === 1 ? 0.34 : 0.24 + (index / denominator) * 0.2
|
||||
const displayColor = alphaColor(link.color, alpha)
|
||||
link.linkColor = displayColor.color
|
||||
link.linkAlpha = displayColor.alpha
|
||||
link.hoverColor = link.color
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function byValueThenLabel<T extends { value: number; label: string }>(
|
||||
a: T,
|
||||
b: T
|
||||
): number {
|
||||
return b.value - a.value || a.label.localeCompare(b.label)
|
||||
}
|
||||
|
||||
function linkStableKey(link: Pick<DashboardFlowLink, 'source' | 'target'>) {
|
||||
return `${link.source}\u0000${link.target}`
|
||||
}
|
||||
|
||||
function byLinkDrawPriority(
|
||||
a: DashboardFlowLink,
|
||||
b: DashboardFlowLink
|
||||
): number {
|
||||
return b.value - a.value || linkStableKey(a).localeCompare(linkStableKey(b))
|
||||
}
|
||||
|
||||
function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary {
|
||||
return rows.reduce<FlowSummary>(
|
||||
(summary, row) => {
|
||||
const metrics = rowMetrics(row)
|
||||
summary.quota += metrics.quota
|
||||
summary.tokens += metrics.tokens
|
||||
summary.requests += metrics.requests
|
||||
return summary
|
||||
},
|
||||
{
|
||||
quota: 0,
|
||||
tokens: 0,
|
||||
requests: 0,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function buildFlowGraph(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric,
|
||||
role: FlowRole,
|
||||
palette?: readonly string[],
|
||||
visibleStages?: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
): DashboardFlowGraph {
|
||||
const nodes = new Map<string, DashboardFlowNode>()
|
||||
const links = new Map<string, DashboardFlowLink>()
|
||||
const colors = stableColorMap(
|
||||
rootColorKeys(rows, role, visibleStages),
|
||||
palette
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const path = flowPath(row, role, visibleStages, ctx)
|
||||
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) {
|
||||
addNode(nodes, node, metrics, metric, color, root.id)
|
||||
}
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const source = path[i]
|
||||
const target = path[i + 1]
|
||||
if (!source || !target) continue
|
||||
addLink(links, source, target, metrics, metric, color, root.id)
|
||||
}
|
||||
}
|
||||
|
||||
const flowLinks = Array.from(links.values()).sort(
|
||||
(a, b) =>
|
||||
a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
|
||||
)
|
||||
const firstStepSources = new Set(
|
||||
rows
|
||||
.map((row) => flowPath(row, role, visibleStages)[0]?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
const total = flowLinks
|
||||
.filter((link) => firstStepSources.has(link.source))
|
||||
.reduce((sum, link) => sum + link.value, 0)
|
||||
for (const link of flowLinks) {
|
||||
link.share = total > 0 ? link.value / total : 0
|
||||
}
|
||||
assignLinkDisplayColors(flowLinks)
|
||||
|
||||
return {
|
||||
nodes: Array.from(nodes.values()).sort(byValueThenLabel),
|
||||
links: flowLinks,
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
|
||||
value
|
||||
)
|
||||
}
|
||||
|
||||
export function buildFlowFilterOptions(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric = 'quota',
|
||||
palette?: readonly string[]
|
||||
): FlowFilterOptions {
|
||||
const users = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
>()
|
||||
const colors = stableColorMap(
|
||||
rows.map((row) => userNode(row).id).sort((a, b) => a.localeCompare(b)),
|
||||
palette
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const user = userNode(row)
|
||||
if (!row.user_id && !row.username) continue
|
||||
const metrics = rowMetrics(row)
|
||||
const value = metricValue(metrics, metric)
|
||||
const current = users.get(user.id) ?? {
|
||||
label: user.label,
|
||||
value: 0,
|
||||
color: colors.get(user.id) ?? colorAt(0, palette),
|
||||
}
|
||||
current.value += value
|
||||
users.set(user.id, current)
|
||||
}
|
||||
|
||||
return {
|
||||
users: Array.from(users.entries())
|
||||
.map(([value, user]) => ({
|
||||
value,
|
||||
label: user.label,
|
||||
valueLabel: formatNumber(user.value),
|
||||
valueRaw: user.value,
|
||||
color: user.color,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDashboardFlowData(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric = 'quota',
|
||||
options: FlowBuildOptions = {}
|
||||
): ProcessedFlowData {
|
||||
const role = options.role ?? DEFAULT_FLOW_ROLE
|
||||
const filteredRows = filterRows(rows, options)
|
||||
const palette = options.colorPalette
|
||||
|
||||
return {
|
||||
summary: buildSummary(filteredRows),
|
||||
flow: buildFlowGraph(filteredRows, metric, role, palette, options.visibleStages, {
|
||||
deletedTokenLabel: options.deletedTokenLabel,
|
||||
}),
|
||||
filterOptions: buildFlowFilterOptions(rows, metric, palette),
|
||||
}
|
||||
}
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function sankeyDatumSource(
|
||||
datum: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const nested = datum.datum
|
||||
if (Array.isArray(nested)) {
|
||||
const depth = numberValue(datum.depth)
|
||||
return recordValue(nested[depth]) ?? recordValue(nested[0]) ?? datum
|
||||
}
|
||||
return recordValue(nested) ?? datum
|
||||
}
|
||||
|
||||
function sankeyDatumValue(
|
||||
datum: Record<string, unknown>,
|
||||
key: string
|
||||
): unknown {
|
||||
if (datum[key] !== undefined) return datum[key]
|
||||
return sankeyDatumSource(datum)[key]
|
||||
}
|
||||
|
||||
function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
|
||||
return (
|
||||
sankeyDatumValue(datum, 'source') !== undefined &&
|
||||
sankeyDatumValue(datum, 'target') !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
function tooltipMetricLines(
|
||||
valueFormatter: (value: number) => string,
|
||||
labels: FlowSankeyLabels
|
||||
) {
|
||||
const metricValue = (datum: Record<string, unknown>, key: string) =>
|
||||
numberValue(sankeyDatumValue(datum, key))
|
||||
const formattedNumber = (datum: Record<string, unknown>, key: string) =>
|
||||
formatNumber(metricValue(datum, key))
|
||||
const hasMetric = (datum: Record<string, unknown>, key: string) =>
|
||||
metricValue(datum, key) > 0
|
||||
|
||||
return [
|
||||
{
|
||||
key: labels.quota,
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
valueFormatter(metricValue(datum, 'quota')),
|
||||
},
|
||||
{
|
||||
key: labels.tokens,
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formattedNumber(datum, 'tokens'),
|
||||
},
|
||||
{
|
||||
key: labels.requests,
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
formattedNumber(datum, 'requests'),
|
||||
},
|
||||
{
|
||||
key: labels.share,
|
||||
value: (datum: Record<string, unknown>) =>
|
||||
`${(metricValue(datum, 'share') * 100).toFixed(1)}%`,
|
||||
visible: (datum: Record<string, unknown>) => hasMetric(datum, 'share'),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildFlowSankeySpec(
|
||||
flow: DashboardFlowGraph,
|
||||
title: string,
|
||||
valueFormatter: (value: number) => string = formatNumber,
|
||||
labels: FlowSankeyLabels = DEFAULT_FLOW_SANKEY_LABELS
|
||||
): VChartSpec {
|
||||
return {
|
||||
type: 'sankey',
|
||||
data: [
|
||||
{
|
||||
id: 'flow',
|
||||
values: [
|
||||
{
|
||||
nodes: flow.nodes.map((node) => ({
|
||||
key: node.id,
|
||||
name: node.label,
|
||||
rawLabel: node.label,
|
||||
kind: node.kind,
|
||||
value: node.value,
|
||||
requests: node.requests,
|
||||
quota: node.quota,
|
||||
tokens: node.tokens,
|
||||
color: node.color,
|
||||
colorKey: node.colorKey,
|
||||
})),
|
||||
links: flow.links
|
||||
.filter((link) => link.value > 0)
|
||||
.sort(byLinkDrawPriority)
|
||||
.map((link, index) => ({
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
linkKey: linkStableKey(link),
|
||||
sourceLabel: link.sourceLabel,
|
||||
targetLabel: link.targetLabel,
|
||||
value: link.value,
|
||||
requests: link.requests,
|
||||
quota: link.quota,
|
||||
tokens: link.tokens,
|
||||
color: link.color,
|
||||
linkColor: link.linkColor,
|
||||
linkAlpha: link.linkAlpha,
|
||||
hoverColor: link.hoverColor,
|
||||
colorKey: link.colorKey,
|
||||
share: link.share,
|
||||
zIndex: index,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
categoryField: 'name',
|
||||
sourceField: 'source',
|
||||
targetField: 'target',
|
||||
valueField: 'value',
|
||||
nodeKey: 'key',
|
||||
direction: 'horizontal',
|
||||
nodeAlign: 'justify',
|
||||
crossNodeAlign: 'middle',
|
||||
linkSortBy: (
|
||||
a: { value?: number; source?: string; target?: string; index?: number },
|
||||
b: { value?: number; source?: string; target?: string; index?: number }
|
||||
) =>
|
||||
numberValue(b.value) - numberValue(a.value) ||
|
||||
`${a.source ?? ''}\u0000${a.target ?? ''}`.localeCompare(
|
||||
`${b.source ?? ''}\u0000${b.target ?? ''}`
|
||||
) ||
|
||||
numberValue(a.index) - numberValue(b.index),
|
||||
nodeGap: 14,
|
||||
nodeWidth: 16,
|
||||
minLinkHeight: 2,
|
||||
minNodeHeight: 8,
|
||||
title: {
|
||||
visible: false,
|
||||
text: title,
|
||||
},
|
||||
legends: { visible: false },
|
||||
label: {
|
||||
visible: true,
|
||||
position: 'outside',
|
||||
limit: 220,
|
||||
interactive: false,
|
||||
style: {
|
||||
fill: '#475569',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
node: {
|
||||
interactive: true,
|
||||
style: {
|
||||
fill: (datum: Record<string, unknown>) =>
|
||||
String(sankeyDatumValue(datum, 'color') ?? colorAt(0)),
|
||||
fillOpacity: 0.92,
|
||||
stroke: 'rgba(148, 163, 184, 0.45)',
|
||||
lineWidth: 1,
|
||||
cursor: 'pointer',
|
||||
pickMode: 'accurate',
|
||||
},
|
||||
state: {
|
||||
hover: {
|
||||
fillOpacity: 1,
|
||||
stroke: 'rgba(15, 23, 42, 0.68)',
|
||||
lineWidth: 1.5,
|
||||
},
|
||||
selected: {
|
||||
fillOpacity: 1,
|
||||
stroke: 'rgba(15, 23, 42, 0.68)',
|
||||
lineWidth: 1.5,
|
||||
},
|
||||
blur: {
|
||||
fillOpacity: 0.22,
|
||||
},
|
||||
},
|
||||
},
|
||||
link: {
|
||||
interactive: true,
|
||||
style: {
|
||||
fill: (datum: Record<string, unknown>) =>
|
||||
String(
|
||||
sankeyDatumValue(datum, 'linkColor') ??
|
||||
sankeyDatumValue(datum, 'color') ??
|
||||
colorAt(0)
|
||||
),
|
||||
fillOpacity: (datum: Record<string, unknown>) =>
|
||||
numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1,
|
||||
cursor: 'pointer',
|
||||
pickMode: 'accurate',
|
||||
boundsMode: 'accurate',
|
||||
zIndex: (datum: Record<string, unknown>) => {
|
||||
const zIndex = sankeyDatumValue(datum, 'zIndex')
|
||||
if (zIndex !== undefined) return numberValue(zIndex)
|
||||
return 1_000_000_000 - numberValue(sankeyDatumValue(datum, 'value'))
|
||||
},
|
||||
},
|
||||
state: {
|
||||
hover: {
|
||||
fill: (datum: Record<string, unknown>) =>
|
||||
String(
|
||||
sankeyDatumValue(datum, 'hoverColor') ??
|
||||
sankeyDatumValue(datum, 'color') ??
|
||||
colorAt(0)
|
||||
),
|
||||
fillOpacity: 0.9,
|
||||
},
|
||||
selected: {
|
||||
fill: (datum: Record<string, unknown>) =>
|
||||
String(
|
||||
sankeyDatumValue(datum, 'hoverColor') ??
|
||||
sankeyDatumValue(datum, 'color') ??
|
||||
colorAt(0)
|
||||
),
|
||||
fillOpacity: 0.9,
|
||||
},
|
||||
blur: {
|
||||
fillOpacity: 0.22,
|
||||
},
|
||||
},
|
||||
},
|
||||
emphasis: { enable: false, trigger: 'hover', effect: 'self' },
|
||||
tooltip: {
|
||||
trigger: 'hover',
|
||||
activeType: 'mark',
|
||||
dimension: { visible: false },
|
||||
group: { visible: false },
|
||||
mark: {
|
||||
checkOverlap: true,
|
||||
positionMode: 'pointer',
|
||||
visible: (datum: Record<string, unknown>) =>
|
||||
isSankeyLinkDatum(datum) ||
|
||||
sankeyDatumValue(datum, 'key') !== undefined,
|
||||
title: {
|
||||
value: (datum: Record<string, unknown>) => {
|
||||
const source = sankeyDatumValue(datum, 'source')
|
||||
const target = sankeyDatumValue(datum, 'target')
|
||||
if (source && target) {
|
||||
const sourceLabel = sankeyDatumValue(datum, 'sourceLabel')
|
||||
const targetLabel = sankeyDatumValue(datum, 'targetLabel')
|
||||
return `${sourceLabel ?? source} -> ${targetLabel ?? target}`
|
||||
}
|
||||
return `${sankeyDatumValue(datum, 'name') ?? sankeyDatumValue(datum, 'rawLabel') ?? ''}`
|
||||
},
|
||||
},
|
||||
content: tooltipMetricLines(valueFormatter, labels),
|
||||
},
|
||||
},
|
||||
background: { fill: 'transparent' },
|
||||
animation: false,
|
||||
}
|
||||
}
|
||||
@@ -33,5 +33,10 @@ export {
|
||||
getDefaultPingStatus,
|
||||
} from './api-info'
|
||||
export { processChartData, processUserChartData } from './charts'
|
||||
export {
|
||||
buildDashboardFlowData,
|
||||
buildFlowSankeySpec,
|
||||
getFlowStages,
|
||||
} from './flow'
|
||||
export { safeDivide, calculateDashboardStats } from './stats'
|
||||
export { getPreviewText } from './text'
|
||||
|
||||
@@ -33,6 +33,11 @@ const DASHBOARD_SECTIONS = [
|
||||
titleKey: 'Model Call Analytics',
|
||||
build: () => null,
|
||||
},
|
||||
{
|
||||
id: 'flow',
|
||||
titleKey: 'Flow',
|
||||
build: () => null,
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
titleKey: 'User Analytics',
|
||||
|
||||
+95
@@ -33,6 +33,101 @@ export interface QuotaDataItem {
|
||||
quota?: number
|
||||
}
|
||||
|
||||
export interface FlowQuotaDataItem {
|
||||
user_id?: number
|
||||
username?: string
|
||||
node_name?: string
|
||||
use_group?: string
|
||||
token_id?: number
|
||||
token_name?: string
|
||||
channel_id?: number
|
||||
channel_name?: string
|
||||
model_name?: string
|
||||
token_used?: number
|
||||
count?: number
|
||||
quota?: number
|
||||
}
|
||||
|
||||
export type FlowMetric = 'quota' | 'tokens' | 'requests'
|
||||
|
||||
export type FlowRole = 'user' | 'admin' | 'root'
|
||||
|
||||
export type FlowNodeKind =
|
||||
| 'user'
|
||||
| 'node'
|
||||
| 'token'
|
||||
| 'group'
|
||||
| 'model'
|
||||
| 'channel'
|
||||
|
||||
export interface FlowBuildOptions {
|
||||
role?: FlowRole
|
||||
selectedUsers?: string[]
|
||||
colorPalette?: readonly string[]
|
||||
visibleStages?: FlowNodeKind[]
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface DashboardFlowNode {
|
||||
id: string
|
||||
label: string
|
||||
kind: FlowNodeKind
|
||||
value: number
|
||||
requests: number
|
||||
quota: number
|
||||
tokens: number
|
||||
color: string
|
||||
colorKey: string
|
||||
}
|
||||
|
||||
export interface DashboardFlowLink {
|
||||
source: string
|
||||
target: string
|
||||
value: number
|
||||
requests: number
|
||||
quota: number
|
||||
tokens: number
|
||||
sourceLabel: string
|
||||
targetLabel: string
|
||||
color: string
|
||||
linkColor: string
|
||||
linkAlpha: number
|
||||
hoverColor: string
|
||||
colorKey: string
|
||||
share: number
|
||||
}
|
||||
|
||||
export interface DashboardFlowGraph {
|
||||
nodes: DashboardFlowNode[]
|
||||
links: DashboardFlowLink[]
|
||||
}
|
||||
|
||||
export interface FlowUserFilterOption {
|
||||
value: string
|
||||
label: string
|
||||
valueLabel: string
|
||||
valueRaw: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface FlowFilterOptions {
|
||||
users: FlowUserFilterOption[]
|
||||
}
|
||||
|
||||
export interface FlowSummary {
|
||||
quota: number
|
||||
tokens: number
|
||||
requests: number
|
||||
}
|
||||
|
||||
export interface ProcessedFlowData {
|
||||
summary: FlowSummary
|
||||
flow: DashboardFlowGraph
|
||||
filterOptions: FlowFilterOptions
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Uptime Monitoring Types
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user