From 8ad83bf62f8bd4c8cc0dc994768eecaf61f0de8b Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Jun 2026 21:58:28 +0800 Subject: [PATCH] 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 --- .../dashboard/components/flow/flow-charts.tsx | 302 ++++++++++-- .../components/flow/flow-node-filter.tsx | 235 +++++++++ .../models/models-filter-dialog.tsx | 47 +- .../components/users/user-charts.tsx | 69 +-- web/default/src/features/dashboard/index.tsx | 20 +- .../src/features/dashboard/lib/flow.test.ts | 463 +++++++++++++++++- .../src/features/dashboard/lib/flow.ts | 457 +++++++++++++++-- .../src/features/dashboard/lib/index.ts | 2 + web/default/src/features/dashboard/types.ts | 35 ++ web/default/src/i18n/locales/en.json | 12 +- web/default/src/i18n/locales/fr.json | 12 +- web/default/src/i18n/locales/ja.json | 12 +- web/default/src/i18n/locales/ru.json | 12 +- web/default/src/i18n/locales/vi.json | 12 +- web/default/src/i18n/locales/zh.json | 12 +- 15 files changed, 1550 insertions(+), 152 deletions(-) create mode 100644 web/default/src/features/dashboard/components/flow/flow-node-filter.tsx diff --git a/web/default/src/features/dashboard/components/flow/flow-charts.tsx b/web/default/src/features/dashboard/components/flow/flow-charts.tsx index ad263aee..e57d3e8a 100644 --- a/web/default/src/features/dashboard/components/flow/flow-charts.tsx +++ b/web/default/src/features/dashboard/components/flow/flow-charts.tsx @@ -16,9 +16,17 @@ along with this program. If not, see . 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 = { + 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 = { + 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 = { user: 'Other users', node: 'Other nodes', @@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record = { channel: 'Other channels', } +type FlowChartPointerEvent = EventParamsDefinition['pointerdown'] + +function chartRecordValue( + value: unknown +): Record | undefined { + return value && typeof value === 'object' + ? (value as Record) + : 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(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('quota') const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT) const [overflowMode, setOverflowMode] = useState('aggregate') const [selectedUsers, setSelectedUsers] = useState([]) + const [selectedNodes, setSelectedNodes] = useState([]) + const [activeFlowNode, setActiveFlowNode] = useState< + FlowNodeFilter | undefined + >() + const [activeFlowLink, setActiveFlowLink] = useState< + FlowLinkSelection | undefined + >() const [hiddenStages, setHiddenStages] = useState([]) 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 = ( + { + 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')} + +
+ + + } + > + + + + {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, + }) + } + > + + ) + })} + + ) + })} + {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": "变量",