feat(dashboard): interactive sankey highlighting and persistent filters

- Highlight full paths through a clicked node or link in the flow Sankey,
  dimming unrelated nodes/links instead of removing them
- Disable VChart built-in emphasis to avoid crash, use custom highlight sets
- Initialize models filter dialog from currently applied filters so manual
  time ranges are not overridden by preferences; auto-pick granularity by range
- Lift user charts time range/granularity/limit to dashboard as controlled state
This commit is contained in:
CaIon
2026-06-20 22:09:03 +08:00
parent 061948011c
commit 8ad83bf62f
15 changed files with 1550 additions and 152 deletions
@@ -16,9 +16,17 @@ 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 {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart'
import type { EventParamsDefinition, IVChart } from '@visactor/vchart'
import {
Activity,
ChevronRight,
@@ -56,6 +64,8 @@ import {
buildDashboardFlowData,
buildFlowSankeySpec,
buildQueryParams,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getDefaultDays,
getFlowStages,
} from '@/features/dashboard/lib'
@@ -66,7 +76,9 @@ import {
} from '@/features/dashboard/lib/flow-selection'
import type {
DashboardFilters,
FlowLinkSelection,
FlowMetric,
FlowNodeFilter,
FlowNodeKind,
FlowOverflowMode,
FlowRole,
@@ -78,6 +90,7 @@ import { useChartTheme } from '@/lib/use-chart-theme'
import { cn } from '@/lib/utils'
import { VCHART_OPTION } from '@/lib/vchart'
import { useAuthStore } from '@/stores/auth-store'
import { FlowNodeFilterControl } from './flow-node-filter'
interface FlowChartsProps {
filters?: DashboardFilters
@@ -89,6 +102,12 @@ const FLOW_METRIC_OPTIONS = [
{ value: 'requests', labelKey: 'By requests', icon: Activity },
] as const
const FLOW_METRIC_LABEL_KEYS: Record<FlowMetric, string> = {
quota: 'Quota',
tokens: 'Tokens',
requests: 'Requests',
}
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
@@ -131,6 +150,15 @@ const FLOW_STAGE_META: Record<
},
}
const FLOW_STAGE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: FLOW_STAGE_META.user.labelKey,
node: FLOW_STAGE_META.node.labelKey,
token: FLOW_STAGE_META.token.labelKey,
group: FLOW_STAGE_META.group.labelKey,
model: FLOW_STAGE_META.model.labelKey,
channel: FLOW_STAGE_META.channel.labelKey,
}
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: 'Other users',
node: 'Other nodes',
@@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
channel: 'Other channels',
}
type FlowChartPointerEvent = EventParamsDefinition['pointerdown']
function chartRecordValue(
value: unknown
): Record<string, unknown> | undefined {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function looksLikeFlowDatum(value: unknown): boolean {
const record = chartRecordValue(value)
if (!record) return false
return (
(record.key !== undefined && record.kind !== undefined) ||
(record.source !== undefined && record.target !== undefined)
)
}
function chartGraphicDatum(value: unknown): unknown {
const record = chartRecordValue(value)
const context = chartRecordValue(record?.context)
const data = context?.data
if (Array.isArray(data)) return data[0]
return data
}
function flowChartEventDatum(event: FlowChartPointerEvent): unknown {
const record = chartRecordValue(event)
if (!record) return undefined
if (record.datum !== undefined && record.datum !== null) return record.datum
const itemRecord = chartRecordValue(record.item)
if (itemRecord?.datum !== undefined && itemRecord.datum !== null) {
return itemRecord.datum
}
const graphicDatum = chartGraphicDatum(record.item)
if (graphicDatum !== undefined && graphicDatum !== null) return graphicDatum
const itemData = itemRecord?.data
if (Array.isArray(itemData)) return itemData[0]
if (itemData !== undefined && itemData !== null) return itemData
return looksLikeFlowDatum(record) ? record : undefined
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
function isSameFlowNodeFilter(
a: FlowNodeFilter | undefined,
b: FlowNodeFilter
): boolean {
return Boolean(a && a.kind === b.kind && a.id === b.id)
}
function toggleSelectedValue(values: string[], value: string): string[] {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
function toggleSelectedNodeFilter(
filters: FlowNodeFilter[],
filter: FlowNodeFilter
): FlowNodeFilter[] {
const key = flowNodeFilterKey(filter)
const hasFilter = filters.some((item) => flowNodeFilterKey(item) === key)
return hasFilter
? filters.filter((item) => flowNodeFilterKey(item) !== key)
: [...filters, filter]
}
function formatFlowMetricNumber(value: number): string {
return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
value
)
}
export function FlowCharts(props: FlowChartsProps) {
const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme()
const chartInstanceRef = useRef<IVChart | null>(null)
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'
let flowRole: FlowRole = 'user'
if (isRoot) {
flowRole = 'root'
} else if (isAdmin) {
flowRole = 'admin'
}
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 [selectedNodes, setSelectedNodes] = useState<FlowNodeFilter[]>([])
const [activeFlowNode, setActiveFlowNode] = useState<
FlowNodeFilter | undefined
>()
const [activeFlowLink, setActiveFlowLink] = useState<
FlowLinkSelection | undefined
>()
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
@@ -159,6 +282,19 @@ export function FlowCharts(props: FlowChartsProps) {
() => stages.filter((stage) => !hiddenStages.includes(stage)),
[stages, hiddenStages]
)
useEffect(() => {
const visible = new Set(visibleStages)
setSelectedNodes((prev) => {
const next = prev.filter((filter) => visible.has(filter.kind))
return next.length === prev.length ? prev : next
})
setActiveFlowNode((prev) =>
prev && visible.has(prev.kind) ? prev : undefined
)
// The graph reshapes when columns are toggled, so any highlighted edge may
// no longer exist. Drop the link selection rather than leave it dangling.
setActiveFlowLink(undefined)
}, [visibleStages])
const toggleStage = (stage: FlowNodeKind) => {
setHiddenStages((prev) => {
const hidden = new Set(prev)
@@ -209,6 +345,9 @@ export function FlowCharts(props: FlowChartsProps) {
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
role: flowRole,
selectedUsers,
selectedNodes,
activeNode: activeFlowNode,
activeLink: activeFlowLink,
visibleStages,
topNodeLimit,
overflowMode,
@@ -221,6 +360,9 @@ export function FlowCharts(props: FlowChartsProps) {
isLoading,
metric,
overflowMode,
activeFlowNode,
activeFlowLink,
selectedNodes,
selectedUsers,
topNodeLimit,
visibleStages,
@@ -235,6 +377,75 @@ export function FlowCharts(props: FlowChartsProps) {
})),
[flowData.filterOptions.users]
)
const nodeFilterStages = useMemo(
() => visibleStages.filter((stage) => stage !== 'user'),
[visibleStages]
)
const nodeFilterOptions = useMemo(
() =>
flowData.filterOptions.nodes.filter((option) => option.kind !== 'user'),
[flowData.filterOptions.nodes]
)
const metricLabel = t(FLOW_METRIC_LABEL_KEYS[metric])
const formatNodeMetricValue = useCallback(
(value: number) =>
metric === 'quota' ? formatQuota(value) : formatFlowMetricNumber(value),
[metric]
)
// Explicit filters (the chips/dropdown control) narrow the rows that feed the
// chart. They are intentionally independent from the click-to-highlight state
// below so selecting a filter never dims a node, it removes unrelated rows.
const toggleFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
if (filter.kind === 'user') {
setSelectedUsers((prev) => toggleSelectedValue(prev, filter.id))
return
}
setSelectedNodes((prev) => toggleSelectedNodeFilter(prev, filter))
}, [])
const removeFlowNodeFilter = useCallback((filter: FlowNodeFilter) => {
if (filter.kind === 'user') {
setSelectedUsers((prev) => prev.filter((item) => item !== filter.id))
return
}
const key = flowNodeFilterKey(filter)
setSelectedNodes((prev) =>
prev.filter((item) => flowNodeFilterKey(item) !== key)
)
}, [])
const clearFlowNodeFilters = useCallback(() => {
setSelectedNodes([])
}, [])
// Clicking a node only drives the highlight: keep every node/link on screen
// but emphasize the full paths through the clicked node and dim the rest.
// Clicking the active node again, or clicking empty space, clears it.
const handleChartPointerDown = useCallback((event: FlowChartPointerEvent) => {
const datum = flowChartEventDatum(event)
const filter = flowNodeFilterFromSankeyDatum(datum)
if (filter) {
setActiveFlowLink(undefined)
setActiveFlowNode((prev) =>
isSameFlowNodeFilter(prev, filter) ? undefined : filter
)
return
}
const source = flowSankeyDatumValue(datum, 'source')
const target = flowSankeyDatumValue(datum, 'target')
if (typeof source === 'string' && typeof target === 'string') {
setActiveFlowNode(undefined)
setActiveFlowLink((prev) =>
prev && prev.source === source && prev.target === target
? undefined
: { source, target }
)
return
}
setActiveFlowNode(undefined)
setActiveFlowLink(undefined)
chartInstanceRef.current?.clearState('selected')
chartInstanceRef.current?.clearState('blur')
}, [])
const chartTitle = t('Flow')
const flowSpec = useMemo(
() =>
@@ -252,6 +463,9 @@ export function FlowCharts(props: FlowChartsProps) {
topNodeLimit,
overflowMode,
flowRole,
activeFlowNode ? flowNodeFilterKey(activeFlowNode) : '',
activeFlowLink ? `${activeFlowLink.source}\u0000${activeFlowLink.target}` : '',
selectedNodes.map(flowNodeFilterKey).join(','),
selectedUsers.join(','),
visibleStages.join(','),
flowRows?.length ?? 0,
@@ -267,6 +481,46 @@ export function FlowCharts(props: FlowChartsProps) {
flowError instanceof Error
? flowError.message
: t('Please try again later.')
let chartContent = (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
onReady={(instance: IVChart) => {
chartInstanceRef.current = instance
}}
onPointerDown={handleChartPointerDown}
/>
)
if (displayState === 'loading') {
chartContent = <Skeleton className='h-full w-full' />
} else if (displayState === 'error') {
chartContent = (
<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>
)
} else if (displayState === 'empty') {
chartContent = (
<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>
)
}
return (
<div className='flex flex-col gap-3'>
@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) {
</TabsList>
</Tabs>
</div>
<FlowNodeFilterControl
stages={nodeFilterStages}
stageLabels={FLOW_STAGE_LABEL_KEYS}
metricLabel={metricLabel}
formatMetricValue={formatNodeMetricValue}
options={nodeFilterOptions}
selectedNodes={selectedNodes}
onToggleNode={toggleFlowNodeFilter}
onRemoveNode={removeFlowNodeFilter}
onClearNodes={clearFlowNodeFilters}
/>
</div>
<div className='flex min-w-0 items-center gap-2 xl:justify-end'>
@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) {
</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}
/>
)}
{chartContent}
</div>
</div>
</div>
@@ -0,0 +1,235 @@
/*
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 { useMemo } from 'react'
import { Filter, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import type {
FlowNodeFilter,
FlowNodeFilterOption,
FlowNodeKind,
} from '@/features/dashboard/types'
interface FlowNodeFilterControlProps {
stages: FlowNodeKind[]
stageLabels: Record<FlowNodeKind, string>
metricLabel: string
formatMetricValue: (value: number) => string
options: FlowNodeFilterOption[]
selectedNodes: FlowNodeFilter[]
onToggleNode: (filter: FlowNodeFilter) => void
onRemoveNode: (filter: FlowNodeFilter) => void
onClearNodes: () => void
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
const { t } = useTranslation()
const selectedKeys = useMemo(
() => new Set(props.selectedNodes.map(flowNodeFilterKey)),
[props.selectedNodes]
)
const optionLabels = useMemo(() => {
const labels = new Map<string, FlowNodeFilterOption>()
for (const option of props.options) {
labels.set(
flowNodeFilterKey({ kind: option.kind, id: option.value }),
option
)
}
return labels
}, [props.options])
const optionsByStage = useMemo(
() =>
props.stages
.map((stage) => ({
stage,
options: props.options.filter((option) => option.kind === stage),
}))
.filter((group) => group.options.length > 0),
[props.options, props.stages]
)
const selectedOptions = props.selectedNodes.map((filter) => {
const option = optionLabels.get(flowNodeFilterKey(filter))
return {
...filter,
label: option?.label ?? filter.id,
}
})
const selectedCount = selectedOptions.length
return (
<div className='flex min-w-0 flex-col gap-1.5'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Node filters')}
</span>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Popover>
<PopoverTrigger
render={
<Button
type='button'
variant='outline'
size='sm'
aria-label={t('Filter by node')}
/>
}
>
<Filter data-icon='inline-start' aria-hidden='true' />
{selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
{selectedCount > 0 && (
<Badge variant='secondary' className='rounded-sm px-1'>
{selectedCount}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
className='w-[min(28rem,calc(100vw-2rem))] p-0'
align='start'
>
<PopoverHeader className='px-3 pt-3'>
<PopoverTitle>{t('Node filters')}</PopoverTitle>
<PopoverDescription>
{t('Value metric')}: {props.metricLabel}
</PopoverDescription>
</PopoverHeader>
<Command>
<CommandInput placeholder={t('Filter by node')} />
<CommandList>
<CommandEmpty>{t('No nodes')}</CommandEmpty>
{optionsByStage.map((group) => {
const stageLabel = t(props.stageLabels[group.stage])
return (
<CommandGroup key={group.stage} heading={stageLabel}>
{group.options.map((option) => {
const key = flowNodeFilterKey({
kind: option.kind,
id: option.value,
})
const metricValueLabel = props.formatMetricValue(
option.valueRaw
)
return (
<CommandItem
key={key}
value={`${stageLabel} ${option.label} ${props.metricLabel} ${metricValueLabel}`}
data-checked={selectedKeys.has(key)}
onSelect={() =>
props.onToggleNode({
kind: option.kind,
id: option.value,
})
}
>
<span
className='size-2.5 shrink-0 rounded-full'
style={{ backgroundColor: option.color }}
aria-hidden='true'
/>
<span className='min-w-0 flex-1 truncate'>
{option.label}
</span>
<span className='text-muted-foreground flex shrink-0 items-center gap-1 text-xs'>
<span>{props.metricLabel}</span>
<span className='font-mono'>
{metricValueLabel}
</span>
</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
{selectedCount > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={props.onClearNodes}
className='justify-center text-center'
>
{t('Clear node filters')}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedOptions.map((option) => (
<Badge
key={flowNodeFilterKey(option)}
variant='secondary'
className='max-w-[14rem] rounded-sm pr-1'
>
<span className='truncate'>
{t(props.stageLabels[option.kind])}: {option.label}
</span>
<button
type='button'
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
aria-label={t('Remove node filter')}
onClick={() =>
props.onRemoveNode({ kind: option.kind, id: option.id })
}
>
<X aria-hidden='true' />
</button>
</Badge>
))}
{selectedCount > 1 && (
<Button
type='button'
variant='ghost'
size='xs'
onClick={props.onClearNodes}
>
{t('Clear')}
</Button>
)}
</div>
</div>
)
}
@@ -51,12 +51,36 @@ import type {
interface ModelsFilterProps {
preferences: DashboardChartPreferences
// The filters currently applied to the dashboard. The dialog edits a copy of
// these so reopening it never discards a manually picked range.
currentFilters: DashboardFilters
onFilterChange: (filters: DashboardFilters) => void
onReset: () => void
titleKey?: string
descriptionKey?: string
}
// Quick-range presets imply a sensible granularity (matching the app's
// range<->granularity pairing), so picking "7 Days" requests daily buckets
// instead of leaving the granularity on its previous value (e.g. hourly).
function granularityForRangeDays(days: number): TimeGranularity {
if (days <= 1) return 'hour'
if (days >= 29) return 'week'
return 'day'
}
// Highlights the matching quick-range button when the applied range spans an
// exact preset; custom ranges leave every quick button unselected.
function detectQuickRangeDays(
filters: DashboardFilters | undefined
): number | null {
const start = filters?.start_timestamp
const end = filters?.end_timestamp
if (!start || !end) return null
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000)
return TIME_RANGE_PRESETS.some((preset) => preset.days === days) ? days : null
}
/**
* Section divider component for better visual organization
*/
@@ -78,20 +102,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
const isAdmin = user?.role && user.role >= 10
const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(props.preferences)
const [filters, setFilters] = useState<DashboardFilters>(
() => props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
)
const [selectedRange, setSelectedRange] = useState<number | null>(
() => props.preferences.defaultTimeRangeDays
const [selectedRange, setSelectedRange] = useState<number | null>(() =>
detectQuickRangeDays(props.currentFilters)
)
const resetFiltersFromPreferences = () => {
setFilters(buildDefaultDashboardFilters(props.preferences))
setSelectedRange(props.preferences.defaultTimeRangeDays)
}
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) resetFiltersFromPreferences()
// Sync the editing state from the applied filters every time the dialog
// opens so a previously applied manual range is preserved.
if (nextOpen) {
const applied =
props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
setFilters(applied)
setSelectedRange(detectQuickRangeDays(applied))
}
setOpen(nextOpen)
}
@@ -133,6 +159,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
...prev,
start_timestamp: start,
end_timestamp: end,
time_granularity: granularityForRangeDays(days),
}))
setSelectedRange(days)
}
@@ -33,11 +33,13 @@ import {
} from '@/features/dashboard/constants'
import {
getDefaultDays,
getSavedGranularity,
saveGranularity,
processUserChartData,
} from '@/features/dashboard/lib'
import type { ProcessedUserChartData } from '@/features/dashboard/types'
import type {
ProcessedUserChartData,
UserChartsFilters,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager']
@@ -62,7 +64,12 @@ const USER_CHARTS: {
const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50]
export function UserCharts() {
interface UserChartsProps {
filters: UserChartsFilters
onFiltersChange: (filters: UserChartsFilters) => void
}
export function UserCharts(props: UserChartsProps) {
const { t } = useTranslation()
const { resolvedTheme } = useTheme()
const [themeReady, setThemeReady] = useState(false)
@@ -70,41 +77,45 @@ export function UserCharts() {
(typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null)
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() =>
getSavedGranularity()
)
const [selectedRange, setSelectedRange] = useState<number>(() =>
getDefaultDays(timeGranularity)
)
const [topUserLimit, setTopUserLimit] = useState(10)
const [timeRange, setTimeRange] = useState(() => {
const days = getDefaultDays(timeGranularity)
const { start, end } = getRollingDateRange(days)
// The selection is owned by the dashboard parent so it persists across
// sub-section switches; the rolling window is derived from the chosen range.
const timeGranularity = props.filters.timeGranularity
const selectedRange = props.filters.selectedRange
const topUserLimit = props.filters.topUserLimit
const onFiltersChange = props.onFiltersChange
const timeRange = useMemo(() => {
const { start, end } = getRollingDateRange(selectedRange)
return {
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
}
})
}, [selectedRange])
const handleRangeChange = useCallback((days: number) => {
setSelectedRange(days)
const { start, end } = getRollingDateRange(days)
setTimeRange({
start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000),
})
}, [])
const handleRangeChange = useCallback(
(days: number) => {
onFiltersChange({ ...props.filters, selectedRange: days })
},
[onFiltersChange, props.filters]
)
const handleGranularityChange = useCallback(
(g: TimeGranularity) => {
setTimeGranularity(g)
saveGranularity(g)
const days = getDefaultDays(g)
if (days !== selectedRange) {
handleRangeChange(days)
}
onFiltersChange({
...props.filters,
timeGranularity: g,
selectedRange: getDefaultDays(g),
})
},
[selectedRange, handleRangeChange]
[onFiltersChange, props.filters]
)
const handleTopUserLimitChange = useCallback(
(limit: number) => {
onFiltersChange({ ...props.filters, topUserLimit: limit })
},
[onFiltersChange, props.filters]
)
useEffect(() => {
@@ -184,7 +195,7 @@ export function UserCharts() {
<Tabs
value={String(topUserLimit)}
onValueChange={(value) => setTopUserLimit(Number(value))}
onValueChange={(value) => handleTopUserLimitChange(Number(value))}
className='shrink-0'
>
<TabsList>
+19 -1
View File
@@ -31,7 +31,9 @@ import { OverviewDashboard } from './components/overview/overview-dashboard'
import { DEFAULT_TIME_GRANULARITY } from './constants'
import {
buildDefaultDashboardFilters,
getDefaultDays,
getSavedChartPreferences,
getSavedGranularity,
saveChartPreferences,
} from './lib'
import {
@@ -43,6 +45,7 @@ import {
type DashboardChartPreferences,
type DashboardFilters,
type QuotaDataItem,
type UserChartsFilters,
} from './types'
const route = getRouteApi('/_authenticated/dashboard/$section')
@@ -166,6 +169,16 @@ export function Dashboard() {
const [modelFilters, setModelFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(getSavedChartPreferences())
)
const [userChartsFilters, setUserChartsFilters] = useState<UserChartsFilters>(
() => {
const granularity = getSavedGranularity()
return {
timeGranularity: granularity,
selectedRange: getDefaultDays(granularity),
topUserLimit: 10,
}
}
)
const handleFilterChange = useCallback((filters: DashboardFilters) => {
setModelFilters(filters)
@@ -221,6 +234,7 @@ export function Dashboard() {
/>
<ModelsFilter
preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
/>
@@ -230,6 +244,7 @@ export function Dashboard() {
activeSection === 'flow' ? (
<ModelsFilter
preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange}
onReset={handleResetFilters}
titleKey='Flow Filters'
@@ -314,7 +329,10 @@ export function Dashboard() {
{activeSection === 'users' && (
<FadeIn>
<Suspense fallback={<ModelChartsFallback />}>
<LazyUserCharts />
<LazyUserCharts
filters={userChartsFilters}
onFiltersChange={setUserChartsFilters}
/>
</Suspense>
</FadeIn>
)}
+443 -20
View File
@@ -170,6 +170,74 @@ describe('dashboard flow data', () => {
)
})
test('filters rows by selected flow nodes', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }],
})
assert.equal(result.summary.quota, 150)
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['group:vip', 'model:gpt-4.1', 150],
['model:gpt-4.1', 'channel:101', 100],
['model:gpt-4.1', 'channel:102', 50],
['user:1', 'group:vip', 150],
]
)
})
test('combines node filters with OR inside a column and AND across columns', () => {
const sameColumn = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedNodes: [
{ kind: 'model', id: 'model:gpt-4.1' },
{ kind: 'model', id: 'model:claude-4-sonnet' },
],
})
const crossColumn = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedNodes: [
{ kind: 'model', id: 'model:gpt-4.1' },
{ kind: 'channel', id: 'channel:101' },
],
})
assert.equal(sameColumn.summary.quota, 220)
assert.equal(crossColumn.summary.quota, 100)
assert.deepEqual(
crossColumn.flow.links.map((link) => [
link.source,
link.target,
link.value,
]),
[
['group:vip', 'model:gpt-4.1', 100],
['model:gpt-4.1', 'channel:101', 100],
['user:1', 'group:vip', 100],
]
)
})
test('combines user and node filters', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedUsers: ['user:1'],
selectedNodes: [{ kind: 'channel', id: 'channel:101' }],
})
assert.equal(result.summary.quota, 100)
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['group:vip', 'model:gpt-4.1', 100],
['model:gpt-4.1', 'channel:101', 100],
['user:1', 'group:vip', 100],
]
)
})
test('reconnects links when a middle stage is hidden', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
@@ -220,6 +288,116 @@ describe('dashboard flow data', () => {
assert.notEqual(options.users[0].color, options.users[1].color)
})
test('builds node filter options without applying top limits', () => {
const result = buildDashboardFlowData(topLimitRows, 'quota', {
role: 'admin',
topNodeLimit: 1,
overflowMode: 'aggregate',
})
assert.equal(
result.filterOptions.nodes.some(
(option) =>
option.kind === 'model' && option.value === 'model:model-c'
),
true
)
assert.deepEqual(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
.map((option) => [option.value, option.valueLabel]),
[
['model:model-a', '100'],
['model:model-b', '80'],
['model:model-c', '10'],
]
)
})
test('facets node filter options by selected nodes from other columns', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
selectedNodes: [{ kind: 'node', id: 'node:node-a' }],
})
const nodeOptions = result.filterOptions.nodes
assert.deepEqual(
nodeOptions
.filter((option) => option.kind === 'node')
.map((option) => [option.value, option.valueLabel]),
[
['node:node-a', '150'],
['node:node-b', '70'],
]
)
assert.deepEqual(
nodeOptions
.filter((option) => option.kind === 'token')
.map((option) => [option.value, option.valueLabel]),
[['token:11', '150']]
)
assert.deepEqual(
nodeOptions
.filter((option) => option.kind === 'channel')
.map((option) => [option.value, option.valueLabel]),
[
['channel:101', '100'],
['channel:102', '50'],
]
)
})
test('keeps same-column node options available for OR filtering', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
selectedNodes: [{ kind: 'model', id: 'model:gpt-4.1' }],
})
assert.deepEqual(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
.map((option) => [option.value, option.valueLabel]),
[
['model:gpt-4.1', '150'],
['model:claude-4-sonnet', '70'],
]
)
assert.deepEqual(
result.filterOptions.nodes
.filter((option) => option.kind === 'channel')
.map((option) => [option.value, option.valueLabel]),
[
['channel:101', '100'],
['channel:102', '50'],
]
)
})
test('combines user filters with faceted node filter options', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
selectedUsers: ['user:1'],
selectedNodes: [{ kind: 'channel', id: 'channel:101' }],
})
assert.equal(result.summary.quota, 100)
assert.deepEqual(
result.filterOptions.nodes
.filter((option) => option.kind === 'model')
.map((option) => [option.value, option.valueLabel]),
[['model:gpt-4.1', '100']]
)
assert.deepEqual(
result.filterOptions.nodes
.filter((option) => option.kind === 'channel')
.map((option) => [option.value, option.valueLabel]),
[
['channel:101', '100'],
['channel:102', '50'],
]
)
})
test('aggregates overflow nodes into per-column Other buckets', () => {
const result = buildDashboardFlowData(topLimitRows, 'quota', {
role: 'admin',
@@ -227,7 +405,7 @@ describe('dashboard flow data', () => {
overflowMode: 'aggregate',
otherNodeLabel: (kind) => `Other ${kind}`,
})
const nodeIds = result.flow.nodes.map((node) => node.id)
const nodeIds = new Set(result.flow.nodes.map((node) => node.id))
const otherUser = result.flow.nodes.find(
(node) => node.id === 'user:__other__'
)
@@ -243,14 +421,14 @@ describe('dashboard flow data', () => {
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)
assert.equal(nodeIds.has('user:3'), false)
assert.equal(nodeIds.has('group:free'), false)
assert.equal(nodeIds.has('model:model-c'), false)
assert.equal(nodeIds.has('channel:203'), false)
assert.equal(nodeIds.has('user:__other__'), true)
assert.equal(nodeIds.has('group:__other__'), true)
assert.equal(nodeIds.has('model:__other__'), true)
assert.equal(nodeIds.has('channel:__other__'), true)
})
test('hides overflow paths when overflow mode is hide', () => {
@@ -260,16 +438,16 @@ describe('dashboard flow data', () => {
overflowMode: 'hide',
otherNodeLabel: (kind) => `Other ${kind}`,
})
const nodeIds = result.flow.nodes.map((node) => node.id)
const nodeIds = new Set(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)
assert.equal(nodeIds.has('user:3'), false)
assert.equal(nodeIds.has('user:__other__'), false)
assert.equal(nodeIds.has('model:__other__'), false)
})
test('ranks top nodes using the selected flow metric', () => {
@@ -310,14 +488,14 @@ describe('dashboard flow data', () => {
topNodeLimit: 1,
overflowMode: 'aggregate',
})
const nodeIds = result.flow.nodes.map((node) => node.id)
const nodeIds = new Set(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.equal(nodeIds.has('user:1'), true)
assert.equal(nodeIds.has('user:__other__'), true)
assert.equal(nodeIds.has('model:model-a'), true)
assert.equal(nodeIds.has('model:__other__'), true)
assert.equal(nodeIds.has('group:__other__'), false)
assert.equal(nodeIds.has('channel:__other__'), false)
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
@@ -327,6 +505,213 @@ describe('dashboard flow data', () => {
)
})
test('applies top limits after node filters', () => {
const result = buildDashboardFlowData(topLimitRows, 'quota', {
role: 'admin',
selectedNodes: [{ kind: 'model', id: 'model:model-c' }],
topNodeLimit: 1,
overflowMode: 'aggregate',
})
const nodeIds = new Set(result.flow.nodes.map((node) => node.id))
assert.equal(result.summary.quota, 10)
assert.equal(nodeIds.has('model:model-c'), true)
assert.equal(nodeIds.has('model:__other__'), false)
assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]),
[
['group:free', 'model:model-c', 10],
['model:model-c', 'channel:203', 10],
['user:3', 'group:free', 10],
]
)
})
test('ignores selected node filters for hidden stages', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
visibleStages: ['user', 'model', 'channel'],
selectedNodes: [{ kind: 'group', id: 'group:vip' }],
})
assert.equal(result.summary.quota, 220)
assert.equal(
result.flow.nodes.some((node) => node.id === 'group:vip'),
false
)
})
test('highlights full paths that contain the active user node', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
activeNode: { kind: 'user', id: 'user:1' },
})
const nodeState = new Map(
result.flow.nodes.map((node) => [
node.id,
{ highlighted: node.highlighted, dimmed: node.dimmed },
])
)
const linkState = new Map(
result.flow.links.map((link) => [
`${link.source}->${link.target}`,
{ highlighted: link.highlighted, dimmed: link.dimmed },
])
)
assert.deepEqual(nodeState.get('user:1'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('node:node-a'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('model:gpt-4.1'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('channel:101'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('user:2'), {
highlighted: false,
dimmed: true,
})
assert.deepEqual(linkState.get('user:1->node:node-a'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(linkState.get('model:claude-4-sonnet->channel:101'), {
highlighted: false,
dimmed: true,
})
})
test('highlights full paths that traverse the active link', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
activeLink: { source: 'model:gpt-4.1', target: 'channel:101' },
})
const nodeState = new Map(
result.flow.nodes.map((node) => [
node.id,
{ highlighted: node.highlighted, dimmed: node.dimmed },
])
)
const linkState = new Map(
result.flow.links.map((link) => [
`${link.source}->${link.target}`,
{ highlighted: link.highlighted, dimmed: link.dimmed },
])
)
assert.deepEqual(linkState.get('model:gpt-4.1->channel:101'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(linkState.get('model:gpt-4.1->channel:102'), {
highlighted: false,
dimmed: true,
})
assert.deepEqual(nodeState.get('user:1'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('node:node-a'), {
highlighted: true,
dimmed: false,
})
assert.deepEqual(nodeState.get('user:2'), {
highlighted: false,
dimmed: true,
})
})
test('highlights shared aggregate edges when they contain an active path', () => {
const sharedRows: FlowQuotaDataItem[] = [
{
user_id: 1,
username: 'alice',
use_group: 'vip',
channel_id: 101,
channel_name: 'east',
model_name: 'gpt-4.1',
quota: 100,
token_used: 40,
count: 2,
},
{
user_id: 2,
username: 'bob',
use_group: 'vip',
channel_id: 101,
channel_name: 'east',
model_name: 'gpt-4.1',
quota: 50,
token_used: 20,
count: 1,
},
]
const result = buildDashboardFlowData(sharedRows, 'quota', {
role: 'admin',
activeNode: { kind: 'user', id: 'user:1' },
})
const sharedLink = result.flow.links.find(
(link) => link.source === 'group:vip' && link.target === 'model:gpt-4.1'
)
const inactiveUserLink = result.flow.links.find(
(link) => link.source === 'user:2' && link.target === 'group:vip'
)
assert.equal(sharedLink?.value, 150)
assert.equal(sharedLink?.highlighted, true)
assert.equal(sharedLink?.dimmed, false)
assert.equal(inactiveUserLink?.highlighted, false)
assert.equal(inactiveUserLink?.dimmed, true)
})
test('does not emit highlight states without a visible active node', () => {
const withoutActive = buildDashboardFlowData(rows, 'quota', {
role: 'root',
})
const hiddenActive = buildDashboardFlowData(rows, 'quota', {
role: 'root',
visibleStages: ['node', 'token'],
activeNode: { kind: 'user', id: 'user:1' },
})
assert.equal(
withoutActive.flow.nodes.every(
(node) => node.highlighted === undefined && node.dimmed === undefined
),
true
)
assert.equal(
withoutActive.flow.links.every(
(link) => link.highlighted === undefined && link.dimmed === undefined
),
true
)
assert.equal(
hiddenActive.flow.nodes.every(
(node) => node.highlighted === undefined && node.dimmed === undefined
),
true
)
assert.equal(
hiddenActive.flow.links.every(
(link) => link.highlighted === undefined && link.dimmed === undefined
),
true
)
})
test('builds Sankey spec with quota token request tooltips', () => {
const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', {
role: 'root',
@@ -343,6 +728,7 @@ describe('dashboard flow data', () => {
assert.equal(flowSpec.type, 'sankey')
assert.equal(flowSpec.title.text, 'Flow')
assert.deepEqual(flowSpec.emphasis, { enable: false })
assert.equal(flowSpec.tooltip.mark.visible({ datum: aliceNode }), true)
assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true)
assert.equal(flowSpec.animation, false)
@@ -373,4 +759,41 @@ describe('dashboard flow data', () => {
]
)
})
test('maps active flow highlight states into the Sankey spec', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
activeNode: { kind: 'user', id: 'user:1' },
})
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 bobNode = values.nodes.find(
(node: Record<string, unknown>) => node.key === 'user:2'
)
const highlightedLink = values.links.find(
(link: Record<string, unknown>) =>
link.source === 'model:gpt-4.1' && link.target === 'channel:101'
)
const dimmedLink = values.links.find(
(link: Record<string, unknown>) =>
link.source === 'model:claude-4-sonnet' &&
link.target === 'channel:101'
)
const nodeOpacity = flowSpec.node.style.fillOpacity
const linkOpacity = flowSpec.link.style.fillOpacity
assert.deepEqual(flowSpec.emphasis, { enable: false })
assert.equal(aliceNode.highlighted, true)
assert.equal(bobNode.dimmed, true)
assert.equal(highlightedLink.highlighted, true)
assert.equal(dimmedLink.dimmed, true)
assert.equal(nodeOpacity(aliceNode), 1)
assert.equal(nodeOpacity(bobNode), 0.18)
assert.equal(linkOpacity(highlightedLink), 0.86)
assert.equal(linkOpacity(dimmedLink), 0.08)
assert.equal(highlightedLink.zIndex > dimmedLink.zIndex, true)
})
})
+410 -47
View File
@@ -22,7 +22,9 @@ import type {
DashboardFlowNode,
FlowBuildOptions,
FlowFilterOptions,
FlowLinkSelection,
FlowMetric,
FlowNodeFilter,
FlowNodeKind,
FlowOverflowMode,
FlowQuotaDataItem,
@@ -73,6 +75,13 @@ type FlowGraphOptions = {
topNodeLimit?: number
overflowMode?: FlowOverflowMode
otherNodeLabel?: (kind: FlowNodeKind) => string
activeNode?: FlowNodeFilter
activeLink?: FlowLinkSelection
}
type FlowHighlightSets = {
nodes: Set<string>
links: Set<string>
}
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
@@ -90,6 +99,16 @@ const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
const DEFAULT_FLOW_CHART_COLOR = '#1664FF'
const FLOW_NODE_KINDS: readonly FlowNodeKind[] = [
'user',
'node',
'token',
'group',
'model',
'channel',
]
const FLOW_NODE_KIND_SET = new Set<FlowNodeKind>(FLOW_NODE_KINDS)
const OTHER_FLOW_NODE_IDS: Record<FlowNodeKind, string> = {
user: 'user:__other__',
node: 'node:__other__',
@@ -113,6 +132,10 @@ function numberValue(value: unknown): number {
return Number.isFinite(n) ? n : 0
}
function isFlowNodeKind(value: unknown): value is FlowNodeKind {
return typeof value === 'string' && FLOW_NODE_KIND_SET.has(value as FlowNodeKind)
}
function rowMetrics(row: FlowQuotaDataItem): FlowMetrics {
return {
quota: numberValue(row.quota),
@@ -277,7 +300,7 @@ function stableColorMap(
palette?: readonly string[]
): Map<string, string> {
const map = new Map<string, string>()
const uniqueKeys = Array.from(new Set(keys))
const uniqueKeys = [...new Set(keys)]
const colors = colorPalette(uniqueKeys.length, palette)
uniqueKeys.forEach((key, index) => {
map.set(key, colorAt(index, colors))
@@ -294,6 +317,79 @@ function filterRows(
return rows.filter((row) => selectedUsers.has(userNode(row).id))
}
function nodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
function normalizeSelectedNodeFilters(
selectedNodes: readonly FlowNodeFilter[] | undefined,
stages: readonly FlowNodeKind[]
): Map<FlowNodeKind, Set<string>> {
const visibleKinds = new Set(stages)
const filters = new Map<FlowNodeKind, Set<string>>()
for (const filter of selectedNodes ?? []) {
if (!visibleKinds.has(filter.kind)) continue
const selected = filters.get(filter.kind) ?? new Set<string>()
selected.add(filter.id)
filters.set(filter.kind, selected)
}
return filters
}
function pathMatchesNodeFilters(
path: FlowPathNode[],
filters: Map<FlowNodeKind, Set<string>>
): boolean {
if (filters.size === 0) return true
const pathNodesByKind = new Map<FlowNodeKind, Set<string>>()
for (const node of path) {
const ids = pathNodesByKind.get(node.kind) ?? new Set<string>()
ids.add(node.id)
pathNodesByKind.set(node.kind, ids)
}
for (const [kind, selectedIds] of filters) {
const pathIds = pathNodesByKind.get(kind)
if (!pathIds) return false
let hasSelectedNode = false
for (const id of selectedIds) {
if (pathIds.has(id)) {
hasSelectedNode = true
break
}
}
if (!hasSelectedNode) return false
}
return true
}
function filterRowsByNodes(
rows: FlowQuotaDataItem[],
selectedNodes: readonly FlowNodeFilter[] | undefined,
stages: FlowNodeKind[],
ctx: FlowPathContext
): FlowQuotaDataItem[] {
const filters = normalizeSelectedNodeFilters(selectedNodes, stages)
if (filters.size === 0) return rows
return rows.filter((row) =>
pathMatchesNodeFilters(flowPathForStages(row, stages, ctx), filters)
)
}
function selectedNodeFiltersExceptKind(
selectedNodes: readonly FlowNodeFilter[] | undefined,
kind: FlowNodeKind
): FlowNodeFilter[] | undefined {
const filtered = (selectedNodes ?? []).filter((filter) => filter.kind !== kind)
return filtered.length > 0 ? filtered : undefined
}
function addNode(
map: Map<string, DashboardFlowNode>,
pathNode: FlowPathNode,
@@ -389,11 +485,20 @@ function linkStableKey(link: Pick<DashboardFlowLink, 'source' | 'target'>) {
return `${link.source}\u0000${link.target}`
}
function pathLinkKey(source: FlowPathNode, target: FlowPathNode): string {
return `${source.id}\u0000${target.id}`
}
function byLinkDrawPriority(
a: DashboardFlowLink,
b: DashboardFlowLink
): number {
return b.value - a.value || linkStableKey(a).localeCompare(linkStableKey(b))
return (
Number(a.dimmed) - Number(b.dimmed) ||
Number(b.highlighted) - Number(a.highlighted) ||
b.value - a.value ||
linkStableKey(a).localeCompare(linkStableKey(b))
)
}
function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary {
@@ -459,7 +564,7 @@ function buildTopNodeSets(
const topSets = new Map<FlowNodeKind, Set<string>>()
for (const [kind, stageTotals] of totals) {
const topIds = Array.from(stageTotals.values())
const topIds = [...stageTotals.values()]
.sort(
(a, b) =>
b.value - a.value ||
@@ -499,6 +604,83 @@ function applyTopNodeLimit(
)
}
function pathContainsFlowNode(
path: FlowPathNode[],
filter: FlowNodeFilter
): boolean {
return path.some((node) => node.kind === filter.kind && node.id === filter.id)
}
function pathContainsFlowLink(
path: FlowPathNode[],
link: FlowLinkSelection
): boolean {
for (let i = 0; i < path.length - 1; i++) {
if (path[i]?.id === link.source && path[i + 1]?.id === link.target) {
return true
}
}
return false
}
function buildFlowHighlightSets(
preparedPaths: PreparedFlowPath[],
activeNode: FlowNodeFilter | undefined,
activeLink: FlowLinkSelection | undefined,
stages: FlowNodeKind[]
): FlowHighlightSets | undefined {
const nodeActive = Boolean(activeNode && stages.includes(activeNode.kind))
if (!nodeActive && !activeLink) return undefined
// A link selection highlights paths that traverse that exact edge; otherwise
// fall back to highlighting paths that pass through the active node.
const matchesPath = (path: FlowPathNode[]): boolean => {
if (activeLink) return pathContainsFlowLink(path, activeLink)
return activeNode ? pathContainsFlowNode(path, activeNode) : false
}
const highlightedNodes = new Set<string>()
const highlightedLinks = new Set<string>()
for (const prepared of preparedPaths) {
const { path } = prepared
if (!matchesPath(path)) continue
for (const node of path) {
highlightedNodes.add(node.id)
}
for (let i = 0; i < path.length - 1; i++) {
const source = path[i]
const target = path[i + 1]
if (!source || !target) continue
highlightedLinks.add(pathLinkKey(source, target))
}
}
if (highlightedNodes.size === 0) return undefined
return {
nodes: highlightedNodes,
links: highlightedLinks,
}
}
function applyFlowHighlights(
nodes: Iterable<DashboardFlowNode>,
links: Iterable<DashboardFlowLink>,
highlightSets: FlowHighlightSets | undefined
): void {
if (!highlightSets) return
for (const node of nodes) {
node.highlighted = highlightSets.nodes.has(node.id)
node.dimmed = !node.highlighted
}
for (const link of links) {
const highlighted = highlightSets.links.has(linkStableKey(link))
link.highlighted = highlighted
link.dimmed = !highlighted
}
}
function buildFlowGraph(
rows: FlowQuotaDataItem[],
metric: FlowMetric,
@@ -556,8 +738,18 @@ function buildFlowGraph(
addLink(links, source, target, metrics, metric, color, root.id)
}
}
applyFlowHighlights(
nodes.values(),
links.values(),
buildFlowHighlightSets(
preparedPaths,
options.activeNode,
options.activeLink,
stages
)
)
const flowLinks = Array.from(links.values()).sort(
const flowLinks = [...links.values()].sort(
(a, b) =>
a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
)
@@ -575,7 +767,7 @@ function buildFlowGraph(
assignLinkDisplayColors(flowLinks)
return {
nodes: Array.from(nodes.values()).sort(byValueThenLabel),
nodes: [...nodes.values()].sort(byValueThenLabel),
links: flowLinks,
}
}
@@ -586,11 +778,11 @@ function formatNumber(value: number): string {
)
}
export function buildFlowFilterOptions(
function buildUserFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
palette?: readonly string[]
): FlowFilterOptions {
): FlowFilterOptions['users'] {
const users = new Map<
string,
{
@@ -618,18 +810,107 @@ export function buildFlowFilterOptions(
users.set(user.id, current)
}
return [...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))
}
function buildNodeFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric,
role: FlowRole,
visibleStages: FlowNodeKind[] | undefined,
palette: readonly string[] | undefined,
ctx: FlowPathContext,
selectedNodes?: readonly FlowNodeFilter[]
): FlowFilterOptions['nodes'] {
const stages = resolveVisibleStages(role, visibleStages)
const stageOrder = new Map(stages.map((stage, index) => [stage, index]))
const colorIds = new Set<string>()
for (const row of rows) {
for (const node of flowPathForStages(row, stages, ctx)) {
colorIds.add(node.id)
}
}
const colors = stableColorMap(
[...colorIds].sort((a, b) => a.localeCompare(b)),
palette
)
const options: FlowFilterOptions['nodes'] = []
for (const stage of stages) {
const totals = new Map<
string,
{
node: FlowPathNode
value: number
}
>()
const candidateRows = filterRowsByNodes(
rows,
selectedNodeFiltersExceptKind(selectedNodes, stage),
stages,
ctx
)
for (const row of candidateRows) {
const metrics = rowMetrics(row)
const value = metricValue(metrics, metric)
const node = NODE_BUILDERS[stage](row, ctx)
const key = nodeFilterKey({ kind: node.kind, id: node.id })
const current = totals.get(key) ?? { node, value: 0 }
current.value += value
totals.set(key, current)
}
for (const rank of totals.values()) {
options.push({
kind: rank.node.kind,
value: rank.node.id,
label: rank.node.label,
valueLabel: formatNumber(rank.value),
valueRaw: rank.value,
color: colors.get(rank.node.id) ?? colorAt(0, palette),
})
}
}
return options
.sort(
(a, b) =>
(stageOrder.get(a.kind) ?? 0) - (stageOrder.get(b.kind) ?? 0) ||
b.valueRaw - a.valueRaw ||
a.label.localeCompare(b.label) ||
a.value.localeCompare(b.value)
)
}
export function buildFlowFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
palette?: readonly string[],
role: FlowRole = DEFAULT_FLOW_ROLE,
visibleStages?: FlowNodeKind[],
selectedNodes?: readonly FlowNodeFilter[]
): FlowFilterOptions {
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)
),
users: buildUserFilterOptions(rows, metric, palette),
nodes: buildNodeFilterOptions(
rows,
metric,
role,
visibleStages,
palette,
EMPTY_FLOW_PATH_CONTEXT,
selectedNodes
),
}
}
@@ -639,8 +920,18 @@ export function buildDashboardFlowData(
options: FlowBuildOptions = {}
): ProcessedFlowData {
const role = options.role ?? DEFAULT_FLOW_ROLE
const filteredRows = filterRows(rows, options)
const palette = options.colorPalette
const ctx = {
deletedTokenLabel: options.deletedTokenLabel,
}
const stages = resolveVisibleStages(role, options.visibleStages)
const userFilteredRows = filterRows(rows, options)
const filteredRows = filterRowsByNodes(
userFilteredRows,
options.selectedNodes,
stages,
ctx
)
return {
summary: buildSummary(filteredRows),
@@ -650,16 +941,27 @@ export function buildDashboardFlowData(
role,
palette,
options.visibleStages,
{
deletedTokenLabel: options.deletedTokenLabel,
},
ctx,
{
topNodeLimit: options.topNodeLimit,
overflowMode: options.overflowMode,
otherNodeLabel: options.otherNodeLabel,
activeNode: options.activeNode,
activeLink: options.activeLink,
}
),
filterOptions: buildFlowFilterOptions(rows, metric, palette),
filterOptions: {
users: buildUserFilterOptions(rows, metric, palette),
nodes: buildNodeFilterOptions(
userFilteredRows,
metric,
role,
options.visibleStages,
palette,
ctx,
options.selectedNodes
),
},
}
}
@@ -688,6 +990,21 @@ function sankeyDatumValue(
return sankeyDatumSource(datum)[key]
}
function sankeyDatumFlag(
datum: Record<string, unknown>,
key: string
): boolean {
return sankeyDatumValue(datum, key) === true
}
export function flowSankeyDatumValue(
datum: unknown,
key: string
): unknown {
const record = recordValue(datum)
return record ? sankeyDatumValue(record, key) : undefined
}
function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
return (
sankeyDatumValue(datum, 'source') !== undefined &&
@@ -695,6 +1012,23 @@ function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
)
}
export function flowNodeFilterFromSankeyDatum(
datum: unknown
): FlowNodeFilter | undefined {
const record = recordValue(datum)
if (!record || isSankeyLinkDatum(record)) return undefined
const id = flowSankeyDatumValue(record, 'key')
const kind = flowSankeyDatumValue(record, 'kind')
if (
(typeof id === 'string' || typeof id === 'number') &&
isFlowNodeKind(kind)
) {
return { kind, id: String(id) }
}
return undefined
}
function tooltipMetricLines(
valueFormatter: (value: number) => string,
labels: FlowSankeyLabels
@@ -755,28 +1089,41 @@ export function buildFlowSankeySpec(
tokens: node.tokens,
color: node.color,
colorKey: node.colorKey,
highlighted: node.highlighted,
dimmed: node.dimmed,
})),
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,
})),
.map((link, index) => {
let zIndex = 100_000 + index
if (link.highlighted) {
zIndex = 1_000_000 + index
} else if (link.dimmed) {
zIndex = index
}
return {
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,
highlighted: link.highlighted,
dimmed: link.dimmed,
zIndex,
}
}),
},
],
},
@@ -823,9 +1170,17 @@ export function buildFlowSankeySpec(
style: {
fill: (datum: Record<string, unknown>) =>
String(sankeyDatumValue(datum, 'color') ?? colorAt(0)),
fillOpacity: 0.92,
stroke: 'rgba(148, 163, 184, 0.45)',
lineWidth: 1,
fillOpacity: (datum: Record<string, unknown>) => {
if (sankeyDatumFlag(datum, 'dimmed')) return 0.18
if (sankeyDatumFlag(datum, 'highlighted')) return 1
return 0.92
},
stroke: (datum: Record<string, unknown>) =>
sankeyDatumFlag(datum, 'highlighted')
? 'rgba(15, 23, 42, 0.74)'
: 'rgba(148, 163, 184, 0.45)',
lineWidth: (datum: Record<string, unknown>) =>
sankeyDatumFlag(datum, 'highlighted') ? 1.5 : 1,
cursor: 'pointer',
pickMode: 'accurate',
},
@@ -854,8 +1209,11 @@ export function buildFlowSankeySpec(
sankeyDatumValue(datum, 'color') ??
colorAt(0)
),
fillOpacity: (datum: Record<string, unknown>) =>
numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1,
fillOpacity: (datum: Record<string, unknown>) => {
if (sankeyDatumFlag(datum, 'dimmed')) return 0.08
if (sankeyDatumFlag(datum, 'highlighted')) return 0.86
return numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1
},
cursor: 'pointer',
pickMode: 'accurate',
boundsMode: 'accurate',
@@ -889,7 +1247,12 @@ export function buildFlowSankeySpec(
},
},
},
emphasis: { enable: false, trigger: 'hover', effect: 'self' },
// Highlighting is driven entirely by our own `highlighted`/`dimmed` data
// flags (see fillOpacity above). VChart's built-in click emphasis is
// disabled because its Sankey "related" handler crashes on click
// (_handleLinkRelatedClick) and would otherwise fight our full-path
// highlight.
emphasis: { enable: false },
tooltip: {
trigger: 'hover',
activeType: 'mark',
+2
View File
@@ -36,6 +36,8 @@ export { processChartData, processUserChartData } from './charts'
export {
buildDashboardFlowData,
buildFlowSankeySpec,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getFlowStages,
} from './flow'
export { safeDivide, calculateDashboardStats } from './stats'
+35
View File
@@ -62,9 +62,22 @@ export type FlowNodeKind =
| 'model'
| 'channel'
export interface FlowNodeFilter {
kind: FlowNodeKind
id: string
}
export interface FlowLinkSelection {
source: string
target: string
}
export interface FlowBuildOptions {
role?: FlowRole
selectedUsers?: string[]
selectedNodes?: FlowNodeFilter[]
activeNode?: FlowNodeFilter
activeLink?: FlowLinkSelection
colorPalette?: readonly string[]
visibleStages?: FlowNodeKind[]
topNodeLimit?: number
@@ -85,6 +98,8 @@ export interface DashboardFlowNode {
tokens: number
color: string
colorKey: string
highlighted?: boolean
dimmed?: boolean
}
export interface DashboardFlowLink {
@@ -102,6 +117,8 @@ export interface DashboardFlowLink {
hoverColor: string
colorKey: string
share: number
highlighted?: boolean
dimmed?: boolean
}
export interface DashboardFlowGraph {
@@ -117,8 +134,18 @@ export interface FlowUserFilterOption {
color: string
}
export interface FlowNodeFilterOption {
kind: FlowNodeKind
value: string
label: string
valueLabel: string
valueRaw: number
color: string
}
export interface FlowFilterOptions {
users: FlowUserFilterOption[]
nodes: FlowNodeFilterOption[]
}
export interface FlowSummary {
@@ -171,6 +198,14 @@ export interface DashboardChartPreferences {
defaultTimeGranularity: TimeGranularity
}
// User analytics selections are held by the dashboard parent so they survive
// switching between dashboard sub-sections, matching the model/flow filters.
export interface UserChartsFilters {
timeGranularity: TimeGranularity
selectedRange: number
topUserLimit: number
}
// ============================================================================
// API Info Types
// ============================================================================