Commit 8ad83bf6 by CaIon

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
parent 06194801
......@@ -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>
......
/*
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),
})
},
[onFiltersChange, props.filters]
)
const handleTopUserLimitChange = useCallback(
(limit: number) => {
onFiltersChange({ ...props.filters, topUserLimit: limit })
},
[selectedRange, handleRangeChange]
[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>
......
......@@ -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>
)}
......
......@@ -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)
})
})
......@@ -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',
......
......@@ -36,6 +36,8 @@ export { processChartData, processUserChartData } from './charts'
export {
buildDashboardFlowData,
buildFlowSankeySpec,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getFlowStages,
} from './flow'
export { safeDivide, calculateDashboardStats } from './stats'
......
......@@ -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
// ============================================================================
......
......@@ -271,6 +271,7 @@
"All Models": "All Models",
"All models in use are properly configured.": "All models in use are properly configured.",
"All Must Match (AND)": "All Must Match (AND)",
"All nodes": "All nodes",
"All requests must include": "All requests must include",
"All Status": "All Status",
"All Sync Status": "All Sync Status",
......@@ -780,6 +781,7 @@
"Clear filters": "Clear filters",
"Clear Mapping": "Clear Mapping",
"Clear mode flags in prompts": "Clear mode flags in prompts",
"Clear node filters": "Clear node filters",
"Clear search": "Clear search",
"Clear selection": "Clear selection",
"Clear selection (Escape)": "Clear selection (Escape)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Filter by name or ID...",
"Filter by name, ID, or key...": "Filter by name, ID, or key...",
"Filter by name...": "Filter by name...",
"Filter by node": "Filter by node",
"Filter by price field": "Filter by price field",
"Filter by ratio type": "Filter by ratio type",
"Filter by request ID": "Filter by request ID",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Memory Threshold (%)",
"Merchant ID": "Merchant ID",
"Merchant ID is required": "Merchant ID is required",
"Merge into Other": "Merge into Other",
"Message Priority": "Message Priority",
"Metadata": "Metadata",
"min downtime": "min downtime",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "More than 999 days left",
"More...": "More...",
"Most-used models in the selected period and category": "Most-used models in the selected period and category",
"Merge into Other": "Merge into Other",
"Move": "Move",
"Move a request header": "Move a request header",
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
......@@ -2733,6 +2736,7 @@
"No models to remove": "No models to remove",
"No new models to add": "No new models to add",
"No new models yet": "No new models yet",
"No nodes": "No nodes",
"No notable climbers right now": "No notable climbers right now",
"No notable drops right now": "No notable drops right now",
"No parameter overrides configured.": "No parameter overrides configured.",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "No vendor data available",
"No X Found": "No X Found",
"Node": "Node",
"Node filters": "Node filters",
"Node Name": "Node Name",
"Non-stream": "Non-stream",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.",
......@@ -2958,8 +2963,8 @@
"Output tokens": "Output tokens",
"Output Tokens": "Output Tokens",
"Overage limited": "Overage limited",
"Overflow items": "Overflow items",
"overall": "overall",
"Overflow items": "Overflow items",
"Overnight range": "Overnight range",
"override": "override",
"Override": "Override",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Remove functionResponse.id field",
"Remove mapped targets": "Remove mapped targets",
"Remove Models": "Remove Models",
"Remove node filter": "Remove node filter",
"Remove Passkey": "Remove Passkey",
"Remove Passkey?": "Remove Passkey?",
"Remove rule group": "Remove rule group",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Selected {{count}}",
"selected channel(s). Leave empty to remove tag.": "selected channel(s). Leave empty to remove tag.",
"Selected conflicts were overwritten successfully.": "Selected conflicts were overwritten successfully.",
"Selected nodes": "Selected nodes",
"Selected when creating a token and used as the default billing group for API calls.": "Selected when creating a token and used as the default billing group for API calls.",
"Self-Use Mode": "Self-Use Mode",
"Send": "Send",
......@@ -4582,6 +4589,7 @@
"Value": "Value",
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
"Value is required": "Value is required",
"Value metric": "Value metric",
"Value must be at least 0": "Value must be at least 0",
"Value Regex": "Value Regex",
"variable": "variable",
......
......@@ -271,6 +271,7 @@
"All Models": "Tous les modèles",
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.",
"All Must Match (AND)": "Toutes doivent correspondre (AND)",
"All nodes": "Tous les nœuds",
"All requests must include": "Toutes les requêtes doivent inclure",
"All Status": "Tous les statuts",
"All Sync Status": "Tous les statuts de synchronisation",
......@@ -780,6 +781,7 @@
"Clear filters": "Effacer les filtres",
"Clear Mapping": "Effacer le mappage",
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts",
"Clear node filters": "Effacer les filtres de nœuds",
"Clear search": "Effacer la recherche",
"Clear selection": "Effacer la sélection",
"Clear selection (Escape)": "Effacer la sélection (Échap)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Filtrer par nom ou ID...",
"Filter by name, ID, or key...": "Filtrer par nom, ID ou clé...",
"Filter by name...": "Filtrer par nom...",
"Filter by node": "Filtrer par nœud",
"Filter by price field": "Filtrer par champ de prix",
"Filter by ratio type": "Filtrer par type de ratio",
"Filter by request ID": "Filtrer par ID de requête",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Seuil mémoire (%)",
"Merchant ID": "ID du commerçant",
"Merchant ID is required": "L'ID marchand est requis",
"Merge into Other": "Fusionner dans Autres",
"Message Priority": "Priorité du message",
"Metadata": "Métadonnées",
"min downtime": "min d'interruption",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "Plus de 999 jours restants",
"More...": "Plus...",
"Most-used models in the selected period and category": "Modèles les plus utilisés dans la période et catégorie choisies",
"Merge into Other": "Fusionner dans Autres",
"Move": "Déplacer",
"Move a request header": "Déplacer un en-tête de requête",
"Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal",
......@@ -2733,6 +2736,7 @@
"No models to remove": "Aucun modèle à supprimer",
"No new models to add": "Aucun nouveau modèle à ajouter",
"No new models yet": "Pas encore de nouveaux modèles",
"No nodes": "Aucun nœud",
"No notable climbers right now": "Aucune progression notable pour le moment",
"No notable drops right now": "Aucune chute notable pour le moment",
"No parameter overrides configured.": "Aucune surcharge de paramètres configurée.",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "Aucune donnée de fournisseur disponible",
"No X Found": "Aucun X trouvé",
"Node": "Nœud",
"Node filters": "Filtres de nœuds",
"Node Name": "Nom du nœud",
"Non-stream": "Non-streaming",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Les récompenses d’invitation non nulles nécessitent une confirmation de conformité dans les paramètres de la passerelle de paiement.",
......@@ -2958,8 +2963,8 @@
"Output tokens": "Jetons de sortie",
"Output Tokens": "Tokens de sortie",
"Overage limited": "Dépassement limité",
"Overflow items": "Éléments excédentaires",
"overall": "global",
"Overflow items": "Éléments excédentaires",
"Overnight range": "Plage nocturne",
"override": "remplacer",
"Override": "Remplacer",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Supprimer le champ functionResponse.id",
"Remove mapped targets": "Retirer les cibles mappées",
"Remove Models": "Supprimer des modèles",
"Remove node filter": "Supprimer le filtre de nœud",
"Remove Passkey": "Supprimer le Passkey",
"Remove Passkey?": "Supprimer la clé d'accès ?",
"Remove rule group": "Supprimer le groupe de règles",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "{{count}} sélectionné(s)",
"selected channel(s). Leave empty to remove tag.": "canal(aux) sélectionné(s). Laisser vide pour supprimer l'étiquette.",
"Selected conflicts were overwritten successfully.": "Les conflits sélectionnés ont été écrasés avec succès.",
"Selected nodes": "Nœuds sélectionnés",
"Selected when creating a token and used as the default billing group for API calls.": "Sélectionné lors de la création d’un jeton et utilisé comme groupe de facturation par défaut pour les appels API.",
"Self-Use Mode": "Mode d'utilisation personnelle",
"Send": "Envoyer",
......@@ -4582,6 +4589,7 @@
"Value": "Valeur",
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
"Value is required": "La valeur est obligatoire",
"Value metric": "Métrique de valeur",
"Value must be at least 0": "La valeur doit être au moins 0",
"Value Regex": "Regex de valeur",
"variable": "variable",
......
......@@ -271,6 +271,7 @@
"All Models": "すべてのモデル",
"All models in use are properly configured.": "使用中のすべてのモデルが適切に構成されています。",
"All Must Match (AND)": "すべて一致(AND)",
"All nodes": "すべてのノード",
"All requests must include": "すべてのリクエストには",
"All Status": "すべてのステータス",
"All Sync Status": "すべての同期状態",
......@@ -780,6 +781,7 @@
"Clear filters": "フィルターをクリア",
"Clear Mapping": "マッピングをクリア",
"Clear mode flags in prompts": "プロンプト内のモードフラグをクリア",
"Clear node filters": "ノードフィルターをクリア",
"Clear search": "検索をクリア",
"Clear selection": "選択をクリア",
"Clear selection (Escape)": "選択をクリア (Escape)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "名前またはIDでフィルター...",
"Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...",
"Filter by name...": "名前でフィルター...",
"Filter by node": "ノードでフィルター",
"Filter by price field": "価格フィールドでフィルター",
"Filter by ratio type": "倍率タイプで絞り込み",
"Filter by request ID": "リクエストIDで絞り込み",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "メモリ閾値 (%)",
"Merchant ID": "マーチャントID",
"Merchant ID is required": "マーチャント ID は必須です",
"Merge into Other": "その他にまとめる",
"Message Priority": "メッセージの優先度",
"Metadata": "メタデータ",
"min downtime": "分のダウンタイム",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "999日以上",
"More...": "その他...",
"Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル",
"Merge into Other": "その他にまとめる",
"Move": "移動",
"Move a request header": "リクエストヘッダーを移動",
"Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する",
......@@ -2733,6 +2736,7 @@
"No models to remove": "削除するモデルがありません",
"No new models to add": "追加する新しいモデルはありません",
"No new models yet": "新しいモデルはまだありません",
"No nodes": "ノードなし",
"No notable climbers right now": "現在、目立った上昇はありません",
"No notable drops right now": "現在、目立った下降はありません",
"No parameter overrides configured.": "パラメータのオーバーライドが設定されていません。",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "ベンダーデータがありません",
"No X Found": "X が見つかりません",
"Node": "ノード",
"Node filters": "ノードフィルター",
"Node Name": "ノード名",
"Non-stream": "非ストリーミング",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。",
......@@ -2958,8 +2963,8 @@
"Output tokens": "出力トークン",
"Output Tokens": "出力トークン",
"Overage limited": "超過利用制限中",
"Overflow items": "超過項目",
"overall": "全体",
"Overflow items": "超過項目",
"Overnight range": "日跨ぎ範囲",
"override": "上書き",
"Override": "上書き",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "functionResponse.id フィールドを削除",
"Remove mapped targets": "マッピング先を削除",
"Remove Models": "モデルを削除",
"Remove node filter": "ノードフィルターを削除",
"Remove Passkey": "Passkey連携解除",
"Remove Passkey?": "Passkeyを削除しますか?",
"Remove rule group": "ルールグループを削除",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "{{count}} 件選択済み",
"selected channel(s). Leave empty to remove tag.": "選択されたチャネル。タグを削除するには空のままにしてください。",
"Selected conflicts were overwritten successfully.": "選択した競合が正常に上書きされました。",
"Selected nodes": "選択したノード",
"Selected when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。",
"Self-Use Mode": "セルフユースモード",
"Send": "送信",
......@@ -4582,6 +4589,7 @@
"Value": "値",
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
"Value is required": "値は必須です",
"Value metric": "値の指標",
"Value must be at least 0": "値は 0 以上である必要があります",
"Value Regex": "Value 正規表現",
"variable": "変数",
......
......@@ -271,6 +271,7 @@
"All Models": "Все модели",
"All models in use are properly configured.": "Все используемые модели настроены правильно.",
"All Must Match (AND)": "Все должны совпасть (AND)",
"All nodes": "Все узлы",
"All requests must include": "Все запросы должны содержать",
"All Status": "Все статусы",
"All Sync Status": "Все статусы синхронизации",
......@@ -780,6 +781,7 @@
"Clear filters": "Очистить фильтры",
"Clear Mapping": "Очистить сопоставление",
"Clear mode flags in prompts": "Очистить флаги режимов в промптах",
"Clear node filters": "Очистить фильтры узлов",
"Clear search": "Очистить поиск",
"Clear selection": "Снять выделение",
"Clear selection (Escape)": "Снять выделение (Escape)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Фильтр по имени или ID...",
"Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...",
"Filter by name...": "Фильтр по имени...",
"Filter by node": "Фильтр по узлу",
"Filter by price field": "Фильтр по полю цены",
"Filter by ratio type": "Фильтровать по типу коэффициента",
"Filter by request ID": "Фильтр по ID запроса",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Порог памяти (%)",
"Merchant ID": "ID мерчанта",
"Merchant ID is required": "Требуется ID мерчанта",
"Merge into Other": "Объединить в «Другое»",
"Message Priority": "Приоритет сообщения",
"Metadata": "Метаданные",
"min downtime": "мин простоя",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "Более 999 дней",
"More...": "Подробнее...",
"Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории",
"Merge into Other": "Объединить в «Другое»",
"Move": "Переместить",
"Move a request header": "Переместить заголовок запроса",
"Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс",
......@@ -2733,6 +2736,7 @@
"No models to remove": "Нет моделей для удаления",
"No new models to add": "Нет новых моделей для добавления",
"No new models yet": "Новых моделей пока нет",
"No nodes": "Нет узлов",
"No notable climbers right now": "Сейчас нет значимых поднявшихся моделей",
"No notable drops right now": "Сейчас нет значимых упавших моделей",
"No parameter overrides configured.": "Нет настроенных переопределений параметров.",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "Данных по поставщикам нет",
"No X Found": "X не найдено",
"Node": "Узел",
"Node filters": "Фильтры узлов",
"Node Name": "Имя узла",
"Non-stream": "Не потоковый",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Ненулевые награды за приглашения требуют подтверждения соответствия в настройках платежного шлюза.",
......@@ -2958,8 +2963,8 @@
"Output tokens": "Выходные токены",
"Output Tokens": "Выходные токены",
"Overage limited": "Ограничение перерасхода",
"Overflow items": "Элементы сверх лимита",
"overall": "всего",
"Overflow items": "Элементы сверх лимита",
"Overnight range": "Диапазон через полночь",
"override": "переопределить",
"Override": "Перезаписать",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Удалить поле functionResponse.id",
"Remove mapped targets": "Удалить сопоставленные цели",
"Remove Models": "Удалить модели",
"Remove node filter": "Удалить фильтр узла",
"Remove Passkey": "Отвязать Passkey",
"Remove Passkey?": "Удалить ключ доступа?",
"Remove rule group": "Удалить группу правил",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Выбрано: {{count}}",
"selected channel(s). Leave empty to remove tag.": "выбранный канал(ы). Оставьте пустым, чтобы удалить тег.",
"Selected conflicts were overwritten successfully.": "Выбранные конфликты успешно перезаписаны.",
"Selected nodes": "Выбранные узлы",
"Selected when creating a token and used as the default billing group for API calls.": "Выбирается при создании токена и используется как группа тарификации по умолчанию для вызовов API.",
"Self-Use Mode": "Режим самоиспользования",
"Send": "Отправить",
......@@ -4582,6 +4589,7 @@
"Value": "Значение",
"Value (supports JSON or plain text)": "Значение (JSON или текст)",
"Value is required": "Значение обязательно",
"Value metric": "Метрика значения",
"Value must be at least 0": "Значение должно быть не менее 0",
"Value Regex": "Регулярное выражение значения",
"variable": "переменная",
......
......@@ -271,6 +271,7 @@
"All Models": "Tất cả các mẫu",
"All models in use are properly configured.": "Tất cả các mô hình đang được sử dụng đều được cấu hình đúng cách.",
"All Must Match (AND)": "Tất cả phải khớp (AND)",
"All nodes": "Tất cả nút",
"All requests must include": "Mọi yêu cầu phải có header",
"All Status": "Tất cả trạng thái",
"All Sync Status": "Tất cả Trạng thái Đồng bộ",
......@@ -780,6 +781,7 @@
"Clear filters": "Clear filter",
"Clear Mapping": "Xóa Ánh xạ",
"Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc",
"Clear node filters": "Xóa bộ lọc nút",
"Clear search": "Xóa tìm kiếm",
"Clear selection": "Bỏ chọn",
"Clear selection (Escape)": "Bỏ chọn (Escape)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Lọc theo tên hoặc ID...",
"Filter by name, ID, or key...": "Lọc theo tên, ID hoặc khóa...",
"Filter by name...": "Lọc theo tên...",
"Filter by node": "Lọc theo nút",
"Filter by price field": "Lọc theo trường giá",
"Filter by ratio type": "Lọc theo loại tỷ lệ",
"Filter by request ID": "Lọc theo ID yêu cầu",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Ngưỡng bộ nhớ (%)",
"Merchant ID": "Mã thương gia",
"Merchant ID is required": "Bắt buộc nhập Merchant ID",
"Merge into Other": "Gộp vào Khác",
"Message Priority": "Ưu tiên tin nhắn",
"Metadata": "Siêu dữ liệu",
"min downtime": "phút gián đoạn",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "Hơn 999 ngày",
"More...": "Thêm...",
"Most-used models in the selected period and category": "Mô hình được dùng nhiều nhất trong khoảng thời gian và danh mục đã chọn",
"Merge into Other": "Gộp vào Khác",
"Move": "Di chuyển",
"Move a request header": "Di chuyển header yêu cầu",
"Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn",
......@@ -2733,6 +2736,7 @@
"No models to remove": "Không có mô hình để xóa",
"No new models to add": "Không có mô hình mới để thêm",
"No new models yet": "Chưa có mô hình mới",
"No nodes": "Không có nút",
"No notable climbers right now": "Hiện chưa có mô hình nào tăng đáng kể",
"No notable drops right now": "Hiện chưa có mô hình nào giảm đáng kể",
"No parameter overrides configured.": "Không có ghi đè tham số nào được cấu hình.",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "Không có dữ liệu nhà cung cấp",
"No X Found": "Không tìm thấy X",
"Node": "Nút",
"Node filters": "Bộ lọc nút",
"Node Name": "Tên nút",
"Non-stream": "Không phát trực tuyến",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "Phần thưởng mời khác 0 yêu cầu xác nhận tuân thủ trong cài đặt Cổng thanh toán.",
......@@ -2958,8 +2963,8 @@
"Output tokens": "Token đầu ra",
"Output Tokens": "Token đầu ra",
"Overage limited": "Đã giới hạn vượt mức",
"Overflow items": "Mục vượt giới hạn",
"overall": "tổng",
"Overflow items": "Mục vượt giới hạn",
"Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè",
"Override": "Ghi đè",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Loại bỏ trường functionResponse.id",
"Remove mapped targets": "Xóa đích đã ánh xạ",
"Remove Models": "Xóa mô hình",
"Remove node filter": "Xóa bộ lọc nút này",
"Remove Passkey": "Xóa Khóa truy cập",
"Remove Passkey?": "Xóa khóa truy cập?",
"Remove rule group": "Gỡ nhóm quy tắc",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Đã chọn {{count}}",
"selected channel(s). Leave empty to remove tag.": "Kênh đã chọn. Để trống để xóa thẻ.",
"Selected conflicts were overwritten successfully.": "Các xung đột được chọn đã được ghi đè thành công.",
"Selected nodes": "Nút đã chọn",
"Selected when creating a token and used as the default billing group for API calls.": "Được chọn khi tạo token và dùng làm nhóm tính phí mặc định cho các lệnh gọi API.",
"Self-Use Mode": "Chế độ tự sử dụng",
"Send": "Gửi",
......@@ -4582,6 +4589,7 @@
"Value": "Giá trị",
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)",
"Value is required": "Giá trị là bắt buộc",
"Value metric": "Chỉ số giá trị",
"Value must be at least 0": "Giá trị phải ít nhất là 0",
"Value Regex": "Regex giá trị",
"variable": "biến",
......
......@@ -271,6 +271,7 @@
"All Models": "所有模型",
"All models in use are properly configured.": "所有正在使用的模型都已正确配置。",
"All Must Match (AND)": "全部满足(AND)",
"All nodes": "全部节点",
"All requests must include": "所有请求必须携带",
"All Status": "所有状态",
"All Sync Status": "所有同步状态",
......@@ -780,6 +781,7 @@
"Clear filters": "清除筛选器",
"Clear Mapping": "清除映射",
"Clear mode flags in prompts": "在提示中清除模式标志",
"Clear node filters": "清空节点筛选",
"Clear search": "清除搜索",
"Clear selection": "清除选择",
"Clear selection (Escape)": "清除选择 (Escape)",
......@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "按名称或 ID 筛选...",
"Filter by name, ID, or key...": "按名称、ID 或密钥筛选...",
"Filter by name...": "按名称筛选...",
"Filter by node": "按节点筛选",
"Filter by price field": "按价格字段筛选",
"Filter by ratio type": "按倍率类型筛选",
"Filter by request ID": "按请求 ID 筛选",
......@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "内存阈值 (%)",
"Merchant ID": "商户 ID",
"Merchant ID is required": "商户 ID 为必填项",
"Merge into Other": "合并为其他",
"Message Priority": "消息优先级",
"Metadata": "元信息",
"min downtime": "分钟停机",
......@@ -2557,7 +2561,6 @@
"More than 999 days left": "剩余超过 999 天",
"More...": "更多...",
"Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型",
"Merge into Other": "合并为其他",
"Move": "移动",
"Move a request header": "移动请求头",
"Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额",
......@@ -2733,6 +2736,7 @@
"No models to remove": "无待删除模型",
"No new models to add": "没有新模型可添加",
"No new models yet": "暂无新模型",
"No nodes": "无节点",
"No notable climbers right now": "当前没有显著上升的模型",
"No notable drops right now": "当前没有显著下降的模型",
"No parameter overrides configured.": "未配置参数覆盖。",
......@@ -2791,6 +2795,7 @@
"No vendor data available": "暂无厂商数据",
"No X Found": "未找到 X",
"Node": "节点",
"Node filters": "节点筛选",
"Node Name": "节点名称",
"Non-stream": "非流式",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
......@@ -2958,8 +2963,8 @@
"Output tokens": "输出 token",
"Output Tokens": "输出 Token",
"Overage limited": "超额受限",
"Overflow items": "超出项",
"overall": "总体",
"Overflow items": "超出项",
"Overnight range": "跨日范围",
"override": "覆盖",
"Override": "覆盖",
......@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "移除 functionResponse.id 字段",
"Remove mapped targets": "移除映射目标",
"Remove Models": "删除模型",
"Remove node filter": "移除节点筛选",
"Remove Passkey": "解绑 Passkey",
"Remove Passkey?": "移除通行密钥?",
"Remove rule group": "移除规则组",
......@@ -3804,6 +3810,7 @@
"Selected {{count}}": "已选 {{count}} 个",
"selected channel(s). Leave empty to remove tag.": "选定的渠道。留空以移除标签。",
"Selected conflicts were overwritten successfully.": "选中的冲突已成功覆盖。",
"Selected nodes": "已选节点",
"Selected when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。",
"Self-Use Mode": "自用模式",
"Send": "发送",
......@@ -4582,6 +4589,7 @@
"Value": "值",
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
"Value is required": "值为必填项",
"Value metric": "数值口径",
"Value must be at least 0": "值必须至少为 0",
"Value Regex": "Value 正则",
"variable": "变量",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment