{
+ chartInstanceRef.current = instance
+ }}
+ onPointerDown={handleChartPointerDown}
+ />
+ )
+ if (displayState === 'loading') {
+ chartContent =
+ } else if (displayState === 'error') {
+ chartContent = (
+
+
+
+ {t('Failed to load')}
+ {flowErrorMessage}
+
+
+ )
+ } else if (displayState === 'empty') {
+ chartContent = (
+
+
+
+
+
+ {t('No flow data available')}
+ {t('No data available')}
+
+
+ )
+ }
return (
@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) {
+
+
@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) {
- {displayState === 'loading' ? (
-
- ) : displayState === 'error' ? (
-
-
-
- {t('Failed to load')}
- {flowErrorMessage}
-
-
- ) : displayState === 'empty' ? (
-
-
-
-
-
- {t('No flow data available')}
- {t('No data available')}
-
-
- ) : (
-
- )}
+ {chartContent}
diff --git a/web/default/src/features/dashboard/components/flow/flow-node-filter.tsx b/web/default/src/features/dashboard/components/flow/flow-node-filter.tsx
new file mode 100644
index 00000000..1e239398
--- /dev/null
+++ b/web/default/src/features/dashboard/components/flow/flow-node-filter.tsx
@@ -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 .
+
+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
+ 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()
+ 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 (
+
+
+ {t('Node filters')}
+
+
+
+
+ }
+ >
+
+ {selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
+ {selectedCount > 0 && (
+
+ {selectedCount}
+
+ )}
+
+
+
+ {t('Node filters')}
+
+ {t('Value metric')}: {props.metricLabel}
+
+
+
+
+
+ {t('No nodes')}
+ {optionsByStage.map((group) => {
+ const stageLabel = t(props.stageLabels[group.stage])
+ return (
+
+ {group.options.map((option) => {
+ const key = flowNodeFilterKey({
+ kind: option.kind,
+ id: option.value,
+ })
+ const metricValueLabel = props.formatMetricValue(
+ option.valueRaw
+ )
+ return (
+
+ props.onToggleNode({
+ kind: option.kind,
+ id: option.value,
+ })
+ }
+ >
+
+
+ {option.label}
+
+
+ {props.metricLabel}
+
+ {metricValueLabel}
+
+
+
+ )
+ })}
+
+ )
+ })}
+ {selectedCount > 0 && (
+ <>
+
+
+
+ {t('Clear node filters')}
+
+
+ >
+ )}
+
+
+
+
+
+ {selectedOptions.map((option) => (
+
+
+ {t(props.stageLabels[option.kind])}: {option.label}
+
+
+
+ ))}
+
+ {selectedCount > 1 && (
+
+ )}
+
+
+ )
+}
diff --git a/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx b/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx
index 1cbf1332..ea718c5a 100644
--- a/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx
+++ b/web/default/src/features/dashboard/components/models/models-filter-dialog.tsx
@@ -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(() =>
- buildDefaultDashboardFilters(props.preferences)
+ const [filters, setFilters] = useState(
+ () => props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
)
- const [selectedRange, setSelectedRange] = useState(
- () => props.preferences.defaultTimeRangeDays
+ const [selectedRange, setSelectedRange] = useState(() =>
+ 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)
}
diff --git a/web/default/src/features/dashboard/components/users/user-charts.tsx b/web/default/src/features/dashboard/components/users/user-charts.tsx
index 0836cc4b..8c1d6376 100644
--- a/web/default/src/features/dashboard/components/users/user-charts.tsx
+++ b/web/default/src/features/dashboard/components/users/user-charts.tsx
@@ -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(() =>
- getSavedGranularity()
- )
- const [selectedRange, setSelectedRange] = useState(() =>
- 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() {
setTopUserLimit(Number(value))}
+ onValueChange={(value) => handleTopUserLimitChange(Number(value))}
className='shrink-0'
>
diff --git a/web/default/src/features/dashboard/index.tsx b/web/default/src/features/dashboard/index.tsx
index bf4b7180..147aedb8 100644
--- a/web/default/src/features/dashboard/index.tsx
+++ b/web/default/src/features/dashboard/index.tsx
@@ -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(() =>
buildDefaultDashboardFilters(getSavedChartPreferences())
)
+ const [userChartsFilters, setUserChartsFilters] = useState(
+ () => {
+ 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() {
/>
@@ -230,6 +244,7 @@ export function Dashboard() {
activeSection === 'flow' ? (
}>
-
+
)}
diff --git a/web/default/src/features/dashboard/lib/flow.test.ts b/web/default/src/features/dashboard/lib/flow.test.ts
index b123b933..02a4016c 100644
--- a/web/default/src/features/dashboard/lib/flow.test.ts
+++ b/web/default/src/features/dashboard/lib/flow.test.ts
@@ -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) => node.key === 'user:1'
+ )
+ const bobNode = values.nodes.find(
+ (node: Record) => node.key === 'user:2'
+ )
+ const highlightedLink = values.links.find(
+ (link: Record) =>
+ link.source === 'model:gpt-4.1' && link.target === 'channel:101'
+ )
+ const dimmedLink = values.links.find(
+ (link: Record) =>
+ 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)
+ })
})
diff --git a/web/default/src/features/dashboard/lib/flow.ts b/web/default/src/features/dashboard/lib/flow.ts
index 4910d6a2..7d857aa8 100644
--- a/web/default/src/features/dashboard/lib/flow.ts
+++ b/web/default/src/features/dashboard/lib/flow.ts
@@ -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
+ links: Set
}
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(FLOW_NODE_KINDS)
+
const OTHER_FLOW_NODE_IDS: Record = {
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 {
const map = new Map()
- 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> {
+ const visibleKinds = new Set(stages)
+ const filters = new Map>()
+
+ for (const filter of selectedNodes ?? []) {
+ if (!visibleKinds.has(filter.kind)) continue
+ const selected = filters.get(filter.kind) ?? new Set()
+ selected.add(filter.id)
+ filters.set(filter.kind, selected)
+ }
+
+ return filters
+}
+
+function pathMatchesNodeFilters(
+ path: FlowPathNode[],
+ filters: Map>
+): boolean {
+ if (filters.size === 0) return true
+
+ const pathNodesByKind = new Map>()
+ for (const node of path) {
+ const ids = pathNodesByKind.get(node.kind) ?? new Set()
+ 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,
pathNode: FlowPathNode,
@@ -389,11 +485,20 @@ function linkStableKey(link: Pick) {
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>()
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()
+ const highlightedLinks = new Set()
+ 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,
+ links: Iterable,
+ 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()
+ 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,
+ 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): boolean {
return (
sankeyDatumValue(datum, 'source') !== undefined &&
@@ -695,6 +1012,23 @@ function isSankeyLinkDatum(datum: Record): 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(sankeyDatumValue(datum, 'color') ?? colorAt(0)),
- fillOpacity: 0.92,
- stroke: 'rgba(148, 163, 184, 0.45)',
- lineWidth: 1,
+ fillOpacity: (datum: Record) => {
+ if (sankeyDatumFlag(datum, 'dimmed')) return 0.18
+ if (sankeyDatumFlag(datum, 'highlighted')) return 1
+ return 0.92
+ },
+ stroke: (datum: Record) =>
+ sankeyDatumFlag(datum, 'highlighted')
+ ? 'rgba(15, 23, 42, 0.74)'
+ : 'rgba(148, 163, 184, 0.45)',
+ lineWidth: (datum: Record) =>
+ 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) =>
- numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1,
+ fillOpacity: (datum: Record) => {
+ 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',
diff --git a/web/default/src/features/dashboard/lib/index.ts b/web/default/src/features/dashboard/lib/index.ts
index 92c6e023..ad6aef51 100644
--- a/web/default/src/features/dashboard/lib/index.ts
+++ b/web/default/src/features/dashboard/lib/index.ts
@@ -36,6 +36,8 @@ export { processChartData, processUserChartData } from './charts'
export {
buildDashboardFlowData,
buildFlowSankeySpec,
+ flowNodeFilterFromSankeyDatum,
+ flowSankeyDatumValue,
getFlowStages,
} from './flow'
export { safeDivide, calculateDashboardStats } from './stats'
diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts
index 4c9b37f6..df05d637 100644
--- a/web/default/src/features/dashboard/types.ts
+++ b/web/default/src/features/dashboard/types.ts
@@ -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
// ============================================================================
diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json
index ce319146..923b5fc9 100644
--- a/web/default/src/i18n/locales/en.json
+++ b/web/default/src/i18n/locales/en.json
@@ -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",
diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json
index f6e52a9e..1b627834 100644
--- a/web/default/src/i18n/locales/fr.json
+++ b/web/default/src/i18n/locales/fr.json
@@ -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",
diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json
index ae52e33f..4b95458b 100644
--- a/web/default/src/i18n/locales/ja.json
+++ b/web/default/src/i18n/locales/ja.json
@@ -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": "変数",
diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json
index 2421dd3d..2e1b32da 100644
--- a/web/default/src/i18n/locales/ru.json
+++ b/web/default/src/i18n/locales/ru.json
@@ -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": "переменная",
diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json
index 6891e5ad..15b0da7d 100644
--- a/web/default/src/i18n/locales/vi.json
+++ b/web/default/src/i18n/locales/vi.json
@@ -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",
diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json
index 0bfc9adf..32ed0f6e 100644
--- a/web/default/src/i18n/locales/zh.json
+++ b/web/default/src/i18n/locales/zh.json
@@ -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": "变量",