Commit 8ad83bf6 by CaIon

feat(dashboard): interactive sankey highlighting and persistent filters

- Highlight full paths through a clicked node or link in the flow Sankey,
  dimming unrelated nodes/links instead of removing them
- Disable VChart built-in emphasis to avoid crash, use custom highlight sets
- Initialize models filter dialog from currently applied filters so manual
  time ranges are not overridden by preferences; auto-pick granularity by range
- Lift user charts time range/granularity/limit to dashboard as controlled state
parent 06194801
...@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,9 +16,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useQuery } from '@tanstack/react-query'
import { VChart } from '@visactor/react-vchart' import { VChart } from '@visactor/react-vchart'
import type { EventParamsDefinition, IVChart } from '@visactor/vchart'
import { import {
Activity, Activity,
ChevronRight, ChevronRight,
...@@ -56,6 +64,8 @@ import { ...@@ -56,6 +64,8 @@ import {
buildDashboardFlowData, buildDashboardFlowData,
buildFlowSankeySpec, buildFlowSankeySpec,
buildQueryParams, buildQueryParams,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getDefaultDays, getDefaultDays,
getFlowStages, getFlowStages,
} from '@/features/dashboard/lib' } from '@/features/dashboard/lib'
...@@ -66,7 +76,9 @@ import { ...@@ -66,7 +76,9 @@ import {
} from '@/features/dashboard/lib/flow-selection' } from '@/features/dashboard/lib/flow-selection'
import type { import type {
DashboardFilters, DashboardFilters,
FlowLinkSelection,
FlowMetric, FlowMetric,
FlowNodeFilter,
FlowNodeKind, FlowNodeKind,
FlowOverflowMode, FlowOverflowMode,
FlowRole, FlowRole,
...@@ -78,6 +90,7 @@ import { useChartTheme } from '@/lib/use-chart-theme' ...@@ -78,6 +90,7 @@ import { useChartTheme } from '@/lib/use-chart-theme'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { VCHART_OPTION } from '@/lib/vchart' import { VCHART_OPTION } from '@/lib/vchart'
import { useAuthStore } from '@/stores/auth-store' import { useAuthStore } from '@/stores/auth-store'
import { FlowNodeFilterControl } from './flow-node-filter'
interface FlowChartsProps { interface FlowChartsProps {
filters?: DashboardFilters filters?: DashboardFilters
...@@ -89,6 +102,12 @@ const FLOW_METRIC_OPTIONS = [ ...@@ -89,6 +102,12 @@ const FLOW_METRIC_OPTIONS = [
{ value: 'requests', labelKey: 'By requests', icon: Activity }, { value: 'requests', labelKey: 'By requests', icon: Activity },
] as const ] as const
const FLOW_METRIC_LABEL_KEYS: Record<FlowMetric, string> = {
quota: 'Quota',
tokens: 'Tokens',
requests: 'Requests',
}
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50 const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
...@@ -131,6 +150,15 @@ const FLOW_STAGE_META: Record< ...@@ -131,6 +150,15 @@ const FLOW_STAGE_META: Record<
}, },
} }
const FLOW_STAGE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: FLOW_STAGE_META.user.labelKey,
node: FLOW_STAGE_META.node.labelKey,
token: FLOW_STAGE_META.token.labelKey,
group: FLOW_STAGE_META.group.labelKey,
model: FLOW_STAGE_META.model.labelKey,
channel: FLOW_STAGE_META.channel.labelKey,
}
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = { const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
user: 'Other users', user: 'Other users',
node: 'Other nodes', node: 'Other nodes',
...@@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = { ...@@ -140,18 +168,113 @@ const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
channel: 'Other channels', channel: 'Other channels',
} }
type FlowChartPointerEvent = EventParamsDefinition['pointerdown']
function chartRecordValue(
value: unknown
): Record<string, unknown> | undefined {
return value && typeof value === 'object'
? (value as Record<string, unknown>)
: undefined
}
function looksLikeFlowDatum(value: unknown): boolean {
const record = chartRecordValue(value)
if (!record) return false
return (
(record.key !== undefined && record.kind !== undefined) ||
(record.source !== undefined && record.target !== undefined)
)
}
function chartGraphicDatum(value: unknown): unknown {
const record = chartRecordValue(value)
const context = chartRecordValue(record?.context)
const data = context?.data
if (Array.isArray(data)) return data[0]
return data
}
function flowChartEventDatum(event: FlowChartPointerEvent): unknown {
const record = chartRecordValue(event)
if (!record) return undefined
if (record.datum !== undefined && record.datum !== null) return record.datum
const itemRecord = chartRecordValue(record.item)
if (itemRecord?.datum !== undefined && itemRecord.datum !== null) {
return itemRecord.datum
}
const graphicDatum = chartGraphicDatum(record.item)
if (graphicDatum !== undefined && graphicDatum !== null) return graphicDatum
const itemData = itemRecord?.data
if (Array.isArray(itemData)) return itemData[0]
if (itemData !== undefined && itemData !== null) return itemData
return looksLikeFlowDatum(record) ? record : undefined
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
function isSameFlowNodeFilter(
a: FlowNodeFilter | undefined,
b: FlowNodeFilter
): boolean {
return Boolean(a && a.kind === b.kind && a.id === b.id)
}
function toggleSelectedValue(values: string[], value: string): string[] {
return values.includes(value)
? values.filter((item) => item !== value)
: [...values, value]
}
function toggleSelectedNodeFilter(
filters: FlowNodeFilter[],
filter: FlowNodeFilter
): FlowNodeFilter[] {
const key = flowNodeFilterKey(filter)
const hasFilter = filters.some((item) => flowNodeFilterKey(item) === key)
return hasFilter
? filters.filter((item) => flowNodeFilterKey(item) !== key)
: [...filters, filter]
}
function formatFlowMetricNumber(value: number): string {
return Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(
value
)
}
export function FlowCharts(props: FlowChartsProps) { export function FlowCharts(props: FlowChartsProps) {
const { t } = useTranslation() const { t } = useTranslation()
const { resolvedTheme, themeReady } = useChartTheme() const { resolvedTheme, themeReady } = useChartTheme()
const chartInstanceRef = useRef<IVChart | null>(null)
const user = useAuthStore((state) => state.auth.user) const user = useAuthStore((state) => state.auth.user)
const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN) const isRoot = Boolean(user?.role && user.role >= ROLE.SUPER_ADMIN)
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN) const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user' let flowRole: FlowRole = 'user'
if (isRoot) {
flowRole = 'root'
} else if (isAdmin) {
flowRole = 'admin'
}
const [metric, setMetric] = useState<FlowMetric>('quota') const [metric, setMetric] = useState<FlowMetric>('quota')
const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT) const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT)
const [overflowMode, setOverflowMode] = const [overflowMode, setOverflowMode] =
useState<FlowOverflowMode>('aggregate') useState<FlowOverflowMode>('aggregate')
const [selectedUsers, setSelectedUsers] = useState<string[]>([]) const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [selectedNodes, setSelectedNodes] = useState<FlowNodeFilter[]>([])
const [activeFlowNode, setActiveFlowNode] = useState<
FlowNodeFilter | undefined
>()
const [activeFlowLink, setActiveFlowLink] = useState<
FlowLinkSelection | undefined
>()
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([]) const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
const stages = useMemo(() => getFlowStages(flowRole), [flowRole]) const stages = useMemo(() => getFlowStages(flowRole), [flowRole])
...@@ -159,6 +282,19 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -159,6 +282,19 @@ export function FlowCharts(props: FlowChartsProps) {
() => stages.filter((stage) => !hiddenStages.includes(stage)), () => stages.filter((stage) => !hiddenStages.includes(stage)),
[stages, hiddenStages] [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) => { const toggleStage = (stage: FlowNodeKind) => {
setHiddenStages((prev) => { setHiddenStages((prev) => {
const hidden = new Set(prev) const hidden = new Set(prev)
...@@ -209,6 +345,9 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -209,6 +345,9 @@ export function FlowCharts(props: FlowChartsProps) {
buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, { buildDashboardFlowData(isLoading ? [] : (flowRows ?? []), metric, {
role: flowRole, role: flowRole,
selectedUsers, selectedUsers,
selectedNodes,
activeNode: activeFlowNode,
activeLink: activeFlowLink,
visibleStages, visibleStages,
topNodeLimit, topNodeLimit,
overflowMode, overflowMode,
...@@ -221,6 +360,9 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -221,6 +360,9 @@ export function FlowCharts(props: FlowChartsProps) {
isLoading, isLoading,
metric, metric,
overflowMode, overflowMode,
activeFlowNode,
activeFlowLink,
selectedNodes,
selectedUsers, selectedUsers,
topNodeLimit, topNodeLimit,
visibleStages, visibleStages,
...@@ -235,6 +377,75 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -235,6 +377,75 @@ export function FlowCharts(props: FlowChartsProps) {
})), })),
[flowData.filterOptions.users] [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 chartTitle = t('Flow')
const flowSpec = useMemo( const flowSpec = useMemo(
() => () =>
...@@ -252,6 +463,9 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -252,6 +463,9 @@ export function FlowCharts(props: FlowChartsProps) {
topNodeLimit, topNodeLimit,
overflowMode, overflowMode,
flowRole, flowRole,
activeFlowNode ? flowNodeFilterKey(activeFlowNode) : '',
activeFlowLink ? `${activeFlowLink.source}\u0000${activeFlowLink.target}` : '',
selectedNodes.map(flowNodeFilterKey).join(','),
selectedUsers.join(','), selectedUsers.join(','),
visibleStages.join(','), visibleStages.join(','),
flowRows?.length ?? 0, flowRows?.length ?? 0,
...@@ -267,6 +481,46 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -267,6 +481,46 @@ export function FlowCharts(props: FlowChartsProps) {
flowError instanceof Error flowError instanceof Error
? flowError.message ? flowError.message
: t('Please try again later.') : t('Please try again later.')
let chartContent = (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
onReady={(instance: IVChart) => {
chartInstanceRef.current = instance
}}
onPointerDown={handleChartPointerDown}
/>
)
if (displayState === 'loading') {
chartContent = <Skeleton className='h-full w-full' />
} else if (displayState === 'error') {
chartContent = (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
)
} else if (displayState === 'empty') {
chartContent = (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
)
}
return ( return (
<div className='flex flex-col gap-3'> <div className='flex flex-col gap-3'>
...@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -366,6 +620,18 @@ export function FlowCharts(props: FlowChartsProps) {
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
<FlowNodeFilterControl
stages={nodeFilterStages}
stageLabels={FLOW_STAGE_LABEL_KEYS}
metricLabel={metricLabel}
formatMetricValue={formatNodeMetricValue}
options={nodeFilterOptions}
selectedNodes={selectedNodes}
onToggleNode={toggleFlowNodeFilter}
onRemoveNode={removeFlowNodeFilter}
onClearNodes={clearFlowNodeFilters}
/>
</div> </div>
<div className='flex min-w-0 items-center gap-2 xl:justify-end'> <div className='flex min-w-0 items-center gap-2 xl:justify-end'>
...@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) { ...@@ -447,37 +713,7 @@ export function FlowCharts(props: FlowChartsProps) {
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'> <div className='h-[560px] p-1.5 sm:h-[680px] sm:p-2 2xl:h-[760px]'>
{displayState === 'loading' ? ( {chartContent}
<Skeleton className='h-full w-full' />
) : displayState === 'error' ? (
<div className='flex h-full items-center justify-center p-4'>
<Alert variant='destructive' className='max-w-md'>
<CircleAlert />
<AlertTitle>{t('Failed to load')}</AlertTitle>
<AlertDescription>{flowErrorMessage}</AlertDescription>
</Alert>
</div>
) : displayState === 'empty' ? (
<Empty className='h-full border-0 py-12'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Route />
</EmptyMedia>
<EmptyTitle>{t('No flow data available')}</EmptyTitle>
<EmptyDescription>{t('No data available')}</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<VChart
key={`flow-${chartKey}`}
spec={{
...flowSpec,
theme: chartTheme,
background: 'transparent',
}}
option={VCHART_OPTION}
/>
)}
</div> </div>
</div> </div>
</div> </div>
......
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo } from 'react'
import { Filter, X } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import type {
FlowNodeFilter,
FlowNodeFilterOption,
FlowNodeKind,
} from '@/features/dashboard/types'
interface FlowNodeFilterControlProps {
stages: FlowNodeKind[]
stageLabels: Record<FlowNodeKind, string>
metricLabel: string
formatMetricValue: (value: number) => string
options: FlowNodeFilterOption[]
selectedNodes: FlowNodeFilter[]
onToggleNode: (filter: FlowNodeFilter) => void
onRemoveNode: (filter: FlowNodeFilter) => void
onClearNodes: () => void
}
function flowNodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
const { t } = useTranslation()
const selectedKeys = useMemo(
() => new Set(props.selectedNodes.map(flowNodeFilterKey)),
[props.selectedNodes]
)
const optionLabels = useMemo(() => {
const labels = new Map<string, FlowNodeFilterOption>()
for (const option of props.options) {
labels.set(
flowNodeFilterKey({ kind: option.kind, id: option.value }),
option
)
}
return labels
}, [props.options])
const optionsByStage = useMemo(
() =>
props.stages
.map((stage) => ({
stage,
options: props.options.filter((option) => option.kind === stage),
}))
.filter((group) => group.options.length > 0),
[props.options, props.stages]
)
const selectedOptions = props.selectedNodes.map((filter) => {
const option = optionLabels.get(flowNodeFilterKey(filter))
return {
...filter,
label: option?.label ?? filter.id,
}
})
const selectedCount = selectedOptions.length
return (
<div className='flex min-w-0 flex-col gap-1.5'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Node filters')}
</span>
<div className='flex min-w-0 flex-wrap items-center gap-1.5'>
<Popover>
<PopoverTrigger
render={
<Button
type='button'
variant='outline'
size='sm'
aria-label={t('Filter by node')}
/>
}
>
<Filter data-icon='inline-start' aria-hidden='true' />
{selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
{selectedCount > 0 && (
<Badge variant='secondary' className='rounded-sm px-1'>
{selectedCount}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
className='w-[min(28rem,calc(100vw-2rem))] p-0'
align='start'
>
<PopoverHeader className='px-3 pt-3'>
<PopoverTitle>{t('Node filters')}</PopoverTitle>
<PopoverDescription>
{t('Value metric')}: {props.metricLabel}
</PopoverDescription>
</PopoverHeader>
<Command>
<CommandInput placeholder={t('Filter by node')} />
<CommandList>
<CommandEmpty>{t('No nodes')}</CommandEmpty>
{optionsByStage.map((group) => {
const stageLabel = t(props.stageLabels[group.stage])
return (
<CommandGroup key={group.stage} heading={stageLabel}>
{group.options.map((option) => {
const key = flowNodeFilterKey({
kind: option.kind,
id: option.value,
})
const metricValueLabel = props.formatMetricValue(
option.valueRaw
)
return (
<CommandItem
key={key}
value={`${stageLabel} ${option.label} ${props.metricLabel} ${metricValueLabel}`}
data-checked={selectedKeys.has(key)}
onSelect={() =>
props.onToggleNode({
kind: option.kind,
id: option.value,
})
}
>
<span
className='size-2.5 shrink-0 rounded-full'
style={{ backgroundColor: option.color }}
aria-hidden='true'
/>
<span className='min-w-0 flex-1 truncate'>
{option.label}
</span>
<span className='text-muted-foreground flex shrink-0 items-center gap-1 text-xs'>
<span>{props.metricLabel}</span>
<span className='font-mono'>
{metricValueLabel}
</span>
</span>
</CommandItem>
)
})}
</CommandGroup>
)
})}
{selectedCount > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={props.onClearNodes}
className='justify-center text-center'
>
{t('Clear node filters')}
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedOptions.map((option) => (
<Badge
key={flowNodeFilterKey(option)}
variant='secondary'
className='max-w-[14rem] rounded-sm pr-1'
>
<span className='truncate'>
{t(props.stageLabels[option.kind])}: {option.label}
</span>
<button
type='button'
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
aria-label={t('Remove node filter')}
onClick={() =>
props.onRemoveNode({ kind: option.kind, id: option.id })
}
>
<X aria-hidden='true' />
</button>
</Badge>
))}
{selectedCount > 1 && (
<Button
type='button'
variant='ghost'
size='xs'
onClick={props.onClearNodes}
>
{t('Clear')}
</Button>
)}
</div>
</div>
)
}
...@@ -51,12 +51,36 @@ import type { ...@@ -51,12 +51,36 @@ import type {
interface ModelsFilterProps { interface ModelsFilterProps {
preferences: DashboardChartPreferences 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 onFilterChange: (filters: DashboardFilters) => void
onReset: () => void onReset: () => void
titleKey?: string titleKey?: string
descriptionKey?: 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 * Section divider component for better visual organization
*/ */
...@@ -78,20 +102,22 @@ export function ModelsFilter(props: ModelsFilterProps) { ...@@ -78,20 +102,22 @@ export function ModelsFilter(props: ModelsFilterProps) {
const isAdmin = user?.role && user.role >= 10 const isAdmin = user?.role && user.role >= 10
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [filters, setFilters] = useState<DashboardFilters>(() => const [filters, setFilters] = useState<DashboardFilters>(
buildDefaultDashboardFilters(props.preferences) () => props.currentFilters ?? buildDefaultDashboardFilters(props.preferences)
) )
const [selectedRange, setSelectedRange] = useState<number | null>( const [selectedRange, setSelectedRange] = useState<number | null>(() =>
() => props.preferences.defaultTimeRangeDays detectQuickRangeDays(props.currentFilters)
) )
const resetFiltersFromPreferences = () => {
setFilters(buildDefaultDashboardFilters(props.preferences))
setSelectedRange(props.preferences.defaultTimeRangeDays)
}
const handleOpenChange = (nextOpen: boolean) => { 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) setOpen(nextOpen)
} }
...@@ -133,6 +159,7 @@ export function ModelsFilter(props: ModelsFilterProps) { ...@@ -133,6 +159,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
...prev, ...prev,
start_timestamp: start, start_timestamp: start,
end_timestamp: end, end_timestamp: end,
time_granularity: granularityForRangeDays(days),
})) }))
setSelectedRange(days) setSelectedRange(days)
} }
......
...@@ -33,11 +33,13 @@ import { ...@@ -33,11 +33,13 @@ import {
} from '@/features/dashboard/constants' } from '@/features/dashboard/constants'
import { import {
getDefaultDays, getDefaultDays,
getSavedGranularity,
saveGranularity, saveGranularity,
processUserChartData, processUserChartData,
} from '@/features/dashboard/lib' } from '@/features/dashboard/lib'
import type { ProcessedUserChartData } from '@/features/dashboard/types' import type {
ProcessedUserChartData,
UserChartsFilters,
} from '@/features/dashboard/types'
let themeManagerPromise: Promise< let themeManagerPromise: Promise<
(typeof import('@visactor/vchart'))['ThemeManager'] (typeof import('@visactor/vchart'))['ThemeManager']
...@@ -62,7 +64,12 @@ const USER_CHARTS: { ...@@ -62,7 +64,12 @@ const USER_CHARTS: {
const TOP_USER_LIMIT_OPTIONS = [5, 10, 20, 50] 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 { t } = useTranslation()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [themeReady, setThemeReady] = useState(false) const [themeReady, setThemeReady] = useState(false)
...@@ -70,41 +77,45 @@ export function UserCharts() { ...@@ -70,41 +77,45 @@ export function UserCharts() {
(typeof import('@visactor/vchart'))['ThemeManager'] | null (typeof import('@visactor/vchart'))['ThemeManager'] | null
>(null) >(null)
const [timeGranularity, setTimeGranularity] = useState<TimeGranularity>(() => // The selection is owned by the dashboard parent so it persists across
getSavedGranularity() // sub-section switches; the rolling window is derived from the chosen range.
) const timeGranularity = props.filters.timeGranularity
const [selectedRange, setSelectedRange] = useState<number>(() => const selectedRange = props.filters.selectedRange
getDefaultDays(timeGranularity) const topUserLimit = props.filters.topUserLimit
) const onFiltersChange = props.onFiltersChange
const [topUserLimit, setTopUserLimit] = useState(10)
const [timeRange, setTimeRange] = useState(() => { const timeRange = useMemo(() => {
const days = getDefaultDays(timeGranularity) const { start, end } = getRollingDateRange(selectedRange)
const { start, end } = getRollingDateRange(days)
return { return {
start_timestamp: Math.floor(start.getTime() / 1000), start_timestamp: Math.floor(start.getTime() / 1000),
end_timestamp: Math.floor(end.getTime() / 1000), end_timestamp: Math.floor(end.getTime() / 1000),
} }
}) }, [selectedRange])
const handleRangeChange = useCallback((days: number) => { const handleRangeChange = useCallback(
setSelectedRange(days) (days: number) => {
const { start, end } = getRollingDateRange(days) onFiltersChange({ ...props.filters, selectedRange: days })
setTimeRange({ },
start_timestamp: Math.floor(start.getTime() / 1000), [onFiltersChange, props.filters]
end_timestamp: Math.floor(end.getTime() / 1000), )
})
}, [])
const handleGranularityChange = useCallback( const handleGranularityChange = useCallback(
(g: TimeGranularity) => { (g: TimeGranularity) => {
setTimeGranularity(g)
saveGranularity(g) saveGranularity(g)
const days = getDefaultDays(g) onFiltersChange({
if (days !== selectedRange) { ...props.filters,
handleRangeChange(days) timeGranularity: g,
} selectedRange: getDefaultDays(g),
})
},
[onFiltersChange, props.filters]
)
const handleTopUserLimitChange = useCallback(
(limit: number) => {
onFiltersChange({ ...props.filters, topUserLimit: limit })
}, },
[selectedRange, handleRangeChange] [onFiltersChange, props.filters]
) )
useEffect(() => { useEffect(() => {
...@@ -184,7 +195,7 @@ export function UserCharts() { ...@@ -184,7 +195,7 @@ export function UserCharts() {
<Tabs <Tabs
value={String(topUserLimit)} value={String(topUserLimit)}
onValueChange={(value) => setTopUserLimit(Number(value))} onValueChange={(value) => handleTopUserLimitChange(Number(value))}
className='shrink-0' className='shrink-0'
> >
<TabsList> <TabsList>
......
...@@ -31,7 +31,9 @@ import { OverviewDashboard } from './components/overview/overview-dashboard' ...@@ -31,7 +31,9 @@ import { OverviewDashboard } from './components/overview/overview-dashboard'
import { DEFAULT_TIME_GRANULARITY } from './constants' import { DEFAULT_TIME_GRANULARITY } from './constants'
import { import {
buildDefaultDashboardFilters, buildDefaultDashboardFilters,
getDefaultDays,
getSavedChartPreferences, getSavedChartPreferences,
getSavedGranularity,
saveChartPreferences, saveChartPreferences,
} from './lib' } from './lib'
import { import {
...@@ -43,6 +45,7 @@ import { ...@@ -43,6 +45,7 @@ import {
type DashboardChartPreferences, type DashboardChartPreferences,
type DashboardFilters, type DashboardFilters,
type QuotaDataItem, type QuotaDataItem,
type UserChartsFilters,
} from './types' } from './types'
const route = getRouteApi('/_authenticated/dashboard/$section') const route = getRouteApi('/_authenticated/dashboard/$section')
...@@ -166,6 +169,16 @@ export function Dashboard() { ...@@ -166,6 +169,16 @@ export function Dashboard() {
const [modelFilters, setModelFilters] = useState<DashboardFilters>(() => const [modelFilters, setModelFilters] = useState<DashboardFilters>(() =>
buildDefaultDashboardFilters(getSavedChartPreferences()) buildDefaultDashboardFilters(getSavedChartPreferences())
) )
const [userChartsFilters, setUserChartsFilters] = useState<UserChartsFilters>(
() => {
const granularity = getSavedGranularity()
return {
timeGranularity: granularity,
selectedRange: getDefaultDays(granularity),
topUserLimit: 10,
}
}
)
const handleFilterChange = useCallback((filters: DashboardFilters) => { const handleFilterChange = useCallback((filters: DashboardFilters) => {
setModelFilters(filters) setModelFilters(filters)
...@@ -221,6 +234,7 @@ export function Dashboard() { ...@@ -221,6 +234,7 @@ export function Dashboard() {
/> />
<ModelsFilter <ModelsFilter
preferences={chartPreferences} preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange} onFilterChange={handleFilterChange}
onReset={handleResetFilters} onReset={handleResetFilters}
/> />
...@@ -230,6 +244,7 @@ export function Dashboard() { ...@@ -230,6 +244,7 @@ export function Dashboard() {
activeSection === 'flow' ? ( activeSection === 'flow' ? (
<ModelsFilter <ModelsFilter
preferences={chartPreferences} preferences={chartPreferences}
currentFilters={modelFilters}
onFilterChange={handleFilterChange} onFilterChange={handleFilterChange}
onReset={handleResetFilters} onReset={handleResetFilters}
titleKey='Flow Filters' titleKey='Flow Filters'
...@@ -314,7 +329,10 @@ export function Dashboard() { ...@@ -314,7 +329,10 @@ export function Dashboard() {
{activeSection === 'users' && ( {activeSection === 'users' && (
<FadeIn> <FadeIn>
<Suspense fallback={<ModelChartsFallback />}> <Suspense fallback={<ModelChartsFallback />}>
<LazyUserCharts /> <LazyUserCharts
filters={userChartsFilters}
onFiltersChange={setUserChartsFilters}
/>
</Suspense> </Suspense>
</FadeIn> </FadeIn>
)} )}
......
...@@ -170,6 +170,74 @@ describe('dashboard flow data', () => { ...@@ -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', () => { test('reconnects links when a middle stage is hidden', () => {
const result = buildDashboardFlowData(rows, 'quota', { const result = buildDashboardFlowData(rows, 'quota', {
role: 'admin', role: 'admin',
...@@ -220,6 +288,116 @@ describe('dashboard flow data', () => { ...@@ -220,6 +288,116 @@ describe('dashboard flow data', () => {
assert.notEqual(options.users[0].color, options.users[1].color) 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', () => { test('aggregates overflow nodes into per-column Other buckets', () => {
const result = buildDashboardFlowData(topLimitRows, 'quota', { const result = buildDashboardFlowData(topLimitRows, 'quota', {
role: 'admin', role: 'admin',
...@@ -227,7 +405,7 @@ describe('dashboard flow data', () => { ...@@ -227,7 +405,7 @@ describe('dashboard flow data', () => {
overflowMode: 'aggregate', overflowMode: 'aggregate',
otherNodeLabel: (kind) => `Other ${kind}`, 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( const otherUser = result.flow.nodes.find(
(node) => node.id === 'user:__other__' (node) => node.id === 'user:__other__'
) )
...@@ -243,14 +421,14 @@ describe('dashboard flow data', () => { ...@@ -243,14 +421,14 @@ describe('dashboard flow data', () => {
assert.equal(firstStepTotal, 190) assert.equal(firstStepTotal, 190)
assert.equal(otherUser?.label, 'Other user') assert.equal(otherUser?.label, 'Other user')
assert.equal(otherFirstStepLink?.value, 10) assert.equal(otherFirstStepLink?.value, 10)
assert.equal(nodeIds.includes('user:3'), false) assert.equal(nodeIds.has('user:3'), false)
assert.equal(nodeIds.includes('group:free'), false) assert.equal(nodeIds.has('group:free'), false)
assert.equal(nodeIds.includes('model:model-c'), false) assert.equal(nodeIds.has('model:model-c'), false)
assert.equal(nodeIds.includes('channel:203'), false) assert.equal(nodeIds.has('channel:203'), false)
assert.equal(nodeIds.includes('user:__other__'), true) assert.equal(nodeIds.has('user:__other__'), true)
assert.equal(nodeIds.includes('group:__other__'), true) assert.equal(nodeIds.has('group:__other__'), true)
assert.equal(nodeIds.includes('model:__other__'), true) assert.equal(nodeIds.has('model:__other__'), true)
assert.equal(nodeIds.includes('channel:__other__'), true) assert.equal(nodeIds.has('channel:__other__'), true)
}) })
test('hides overflow paths when overflow mode is hide', () => { test('hides overflow paths when overflow mode is hide', () => {
...@@ -260,16 +438,16 @@ describe('dashboard flow data', () => { ...@@ -260,16 +438,16 @@ describe('dashboard flow data', () => {
overflowMode: 'hide', overflowMode: 'hide',
otherNodeLabel: (kind) => `Other ${kind}`, 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 const firstStepTotal = result.flow.links
.filter((link) => link.source.startsWith('user:')) .filter((link) => link.source.startsWith('user:'))
.reduce((sum, link) => sum + link.value, 0) .reduce((sum, link) => sum + link.value, 0)
assert.equal(result.summary.quota, 190) assert.equal(result.summary.quota, 190)
assert.equal(firstStepTotal, 180) assert.equal(firstStepTotal, 180)
assert.equal(nodeIds.includes('user:3'), false) assert.equal(nodeIds.has('user:3'), false)
assert.equal(nodeIds.includes('user:__other__'), false) assert.equal(nodeIds.has('user:__other__'), false)
assert.equal(nodeIds.includes('model:__other__'), false) assert.equal(nodeIds.has('model:__other__'), false)
}) })
test('ranks top nodes using the selected flow metric', () => { test('ranks top nodes using the selected flow metric', () => {
...@@ -310,14 +488,14 @@ describe('dashboard flow data', () => { ...@@ -310,14 +488,14 @@ describe('dashboard flow data', () => {
topNodeLimit: 1, topNodeLimit: 1,
overflowMode: 'aggregate', 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.has('user:1'), true)
assert.equal(nodeIds.includes('user:__other__'), true) assert.equal(nodeIds.has('user:__other__'), true)
assert.equal(nodeIds.includes('model:model-a'), true) assert.equal(nodeIds.has('model:model-a'), true)
assert.equal(nodeIds.includes('model:__other__'), true) assert.equal(nodeIds.has('model:__other__'), true)
assert.equal(nodeIds.includes('group:__other__'), false) assert.equal(nodeIds.has('group:__other__'), false)
assert.equal(nodeIds.includes('channel:__other__'), false) assert.equal(nodeIds.has('channel:__other__'), false)
assert.deepEqual( assert.deepEqual(
result.flow.links.map((link) => [link.source, link.target, link.value]), result.flow.links.map((link) => [link.source, link.target, link.value]),
[ [
...@@ -327,6 +505,213 @@ describe('dashboard flow data', () => { ...@@ -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', () => { test('builds Sankey spec with quota token request tooltips', () => {
const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', { const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', {
role: 'root', role: 'root',
...@@ -343,6 +728,7 @@ describe('dashboard flow data', () => { ...@@ -343,6 +728,7 @@ describe('dashboard flow data', () => {
assert.equal(flowSpec.type, 'sankey') assert.equal(flowSpec.type, 'sankey')
assert.equal(flowSpec.title.text, 'Flow') 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: aliceNode }), true)
assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true) assert.equal(flowSpec.tooltip.mark.visible({ datum: userNodeLink }), true)
assert.equal(flowSpec.animation, false) assert.equal(flowSpec.animation, false)
...@@ -373,4 +759,41 @@ describe('dashboard flow data', () => { ...@@ -373,4 +759,41 @@ describe('dashboard flow data', () => {
] ]
) )
}) })
test('maps active flow highlight states into the Sankey spec', () => {
const result = buildDashboardFlowData(rows, 'quota', {
role: 'root',
activeNode: { kind: 'user', id: 'user:1' },
})
const flowSpec = buildFlowSankeySpec(result.flow, 'Flow')
const values = flowSpec.data[0].values[0]
const aliceNode = values.nodes.find(
(node: Record<string, unknown>) => node.key === 'user:1'
)
const bobNode = values.nodes.find(
(node: Record<string, unknown>) => node.key === 'user:2'
)
const highlightedLink = values.links.find(
(link: Record<string, unknown>) =>
link.source === 'model:gpt-4.1' && link.target === 'channel:101'
)
const dimmedLink = values.links.find(
(link: Record<string, unknown>) =>
link.source === 'model:claude-4-sonnet' &&
link.target === 'channel:101'
)
const nodeOpacity = flowSpec.node.style.fillOpacity
const linkOpacity = flowSpec.link.style.fillOpacity
assert.deepEqual(flowSpec.emphasis, { enable: false })
assert.equal(aliceNode.highlighted, true)
assert.equal(bobNode.dimmed, true)
assert.equal(highlightedLink.highlighted, true)
assert.equal(dimmedLink.dimmed, true)
assert.equal(nodeOpacity(aliceNode), 1)
assert.equal(nodeOpacity(bobNode), 0.18)
assert.equal(linkOpacity(highlightedLink), 0.86)
assert.equal(linkOpacity(dimmedLink), 0.08)
assert.equal(highlightedLink.zIndex > dimmedLink.zIndex, true)
})
}) })
...@@ -22,7 +22,9 @@ import type { ...@@ -22,7 +22,9 @@ import type {
DashboardFlowNode, DashboardFlowNode,
FlowBuildOptions, FlowBuildOptions,
FlowFilterOptions, FlowFilterOptions,
FlowLinkSelection,
FlowMetric, FlowMetric,
FlowNodeFilter,
FlowNodeKind, FlowNodeKind,
FlowOverflowMode, FlowOverflowMode,
FlowQuotaDataItem, FlowQuotaDataItem,
...@@ -73,6 +75,13 @@ type FlowGraphOptions = { ...@@ -73,6 +75,13 @@ type FlowGraphOptions = {
topNodeLimit?: number topNodeLimit?: number
overflowMode?: FlowOverflowMode overflowMode?: FlowOverflowMode
otherNodeLabel?: (kind: FlowNodeKind) => string otherNodeLabel?: (kind: FlowNodeKind) => string
activeNode?: FlowNodeFilter
activeLink?: FlowLinkSelection
}
type FlowHighlightSets = {
nodes: Set<string>
links: Set<string>
} }
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {} const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
...@@ -90,6 +99,16 @@ const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = { ...@@ -90,6 +99,16 @@ const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
const DEFAULT_FLOW_CHART_COLOR = '#1664FF' const DEFAULT_FLOW_CHART_COLOR = '#1664FF'
const FLOW_NODE_KINDS: readonly FlowNodeKind[] = [
'user',
'node',
'token',
'group',
'model',
'channel',
]
const FLOW_NODE_KIND_SET = new Set<FlowNodeKind>(FLOW_NODE_KINDS)
const OTHER_FLOW_NODE_IDS: Record<FlowNodeKind, string> = { const OTHER_FLOW_NODE_IDS: Record<FlowNodeKind, string> = {
user: 'user:__other__', user: 'user:__other__',
node: 'node:__other__', node: 'node:__other__',
...@@ -113,6 +132,10 @@ function numberValue(value: unknown): number { ...@@ -113,6 +132,10 @@ function numberValue(value: unknown): number {
return Number.isFinite(n) ? n : 0 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 { function rowMetrics(row: FlowQuotaDataItem): FlowMetrics {
return { return {
quota: numberValue(row.quota), quota: numberValue(row.quota),
...@@ -277,7 +300,7 @@ function stableColorMap( ...@@ -277,7 +300,7 @@ function stableColorMap(
palette?: readonly string[] palette?: readonly string[]
): Map<string, string> { ): Map<string, string> {
const map = new Map<string, string>() const map = new Map<string, string>()
const uniqueKeys = Array.from(new Set(keys)) const uniqueKeys = [...new Set(keys)]
const colors = colorPalette(uniqueKeys.length, palette) const colors = colorPalette(uniqueKeys.length, palette)
uniqueKeys.forEach((key, index) => { uniqueKeys.forEach((key, index) => {
map.set(key, colorAt(index, colors)) map.set(key, colorAt(index, colors))
...@@ -294,6 +317,79 @@ function filterRows( ...@@ -294,6 +317,79 @@ function filterRows(
return rows.filter((row) => selectedUsers.has(userNode(row).id)) return rows.filter((row) => selectedUsers.has(userNode(row).id))
} }
function nodeFilterKey(filter: FlowNodeFilter): string {
return `${filter.kind}\u0000${filter.id}`
}
function normalizeSelectedNodeFilters(
selectedNodes: readonly FlowNodeFilter[] | undefined,
stages: readonly FlowNodeKind[]
): Map<FlowNodeKind, Set<string>> {
const visibleKinds = new Set(stages)
const filters = new Map<FlowNodeKind, Set<string>>()
for (const filter of selectedNodes ?? []) {
if (!visibleKinds.has(filter.kind)) continue
const selected = filters.get(filter.kind) ?? new Set<string>()
selected.add(filter.id)
filters.set(filter.kind, selected)
}
return filters
}
function pathMatchesNodeFilters(
path: FlowPathNode[],
filters: Map<FlowNodeKind, Set<string>>
): boolean {
if (filters.size === 0) return true
const pathNodesByKind = new Map<FlowNodeKind, Set<string>>()
for (const node of path) {
const ids = pathNodesByKind.get(node.kind) ?? new Set<string>()
ids.add(node.id)
pathNodesByKind.set(node.kind, ids)
}
for (const [kind, selectedIds] of filters) {
const pathIds = pathNodesByKind.get(kind)
if (!pathIds) return false
let hasSelectedNode = false
for (const id of selectedIds) {
if (pathIds.has(id)) {
hasSelectedNode = true
break
}
}
if (!hasSelectedNode) return false
}
return true
}
function filterRowsByNodes(
rows: FlowQuotaDataItem[],
selectedNodes: readonly FlowNodeFilter[] | undefined,
stages: FlowNodeKind[],
ctx: FlowPathContext
): FlowQuotaDataItem[] {
const filters = normalizeSelectedNodeFilters(selectedNodes, stages)
if (filters.size === 0) return rows
return rows.filter((row) =>
pathMatchesNodeFilters(flowPathForStages(row, stages, ctx), filters)
)
}
function selectedNodeFiltersExceptKind(
selectedNodes: readonly FlowNodeFilter[] | undefined,
kind: FlowNodeKind
): FlowNodeFilter[] | undefined {
const filtered = (selectedNodes ?? []).filter((filter) => filter.kind !== kind)
return filtered.length > 0 ? filtered : undefined
}
function addNode( function addNode(
map: Map<string, DashboardFlowNode>, map: Map<string, DashboardFlowNode>,
pathNode: FlowPathNode, pathNode: FlowPathNode,
...@@ -389,11 +485,20 @@ function linkStableKey(link: Pick<DashboardFlowLink, 'source' | 'target'>) { ...@@ -389,11 +485,20 @@ function linkStableKey(link: Pick<DashboardFlowLink, 'source' | 'target'>) {
return `${link.source}\u0000${link.target}` return `${link.source}\u0000${link.target}`
} }
function pathLinkKey(source: FlowPathNode, target: FlowPathNode): string {
return `${source.id}\u0000${target.id}`
}
function byLinkDrawPriority( function byLinkDrawPriority(
a: DashboardFlowLink, a: DashboardFlowLink,
b: DashboardFlowLink b: DashboardFlowLink
): number { ): 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 { function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary {
...@@ -459,7 +564,7 @@ function buildTopNodeSets( ...@@ -459,7 +564,7 @@ function buildTopNodeSets(
const topSets = new Map<FlowNodeKind, Set<string>>() const topSets = new Map<FlowNodeKind, Set<string>>()
for (const [kind, stageTotals] of totals) { for (const [kind, stageTotals] of totals) {
const topIds = Array.from(stageTotals.values()) const topIds = [...stageTotals.values()]
.sort( .sort(
(a, b) => (a, b) =>
b.value - a.value || b.value - a.value ||
...@@ -499,6 +604,83 @@ function applyTopNodeLimit( ...@@ -499,6 +604,83 @@ function applyTopNodeLimit(
) )
} }
function pathContainsFlowNode(
path: FlowPathNode[],
filter: FlowNodeFilter
): boolean {
return path.some((node) => node.kind === filter.kind && node.id === filter.id)
}
function pathContainsFlowLink(
path: FlowPathNode[],
link: FlowLinkSelection
): boolean {
for (let i = 0; i < path.length - 1; i++) {
if (path[i]?.id === link.source && path[i + 1]?.id === link.target) {
return true
}
}
return false
}
function buildFlowHighlightSets(
preparedPaths: PreparedFlowPath[],
activeNode: FlowNodeFilter | undefined,
activeLink: FlowLinkSelection | undefined,
stages: FlowNodeKind[]
): FlowHighlightSets | undefined {
const nodeActive = Boolean(activeNode && stages.includes(activeNode.kind))
if (!nodeActive && !activeLink) return undefined
// A link selection highlights paths that traverse that exact edge; otherwise
// fall back to highlighting paths that pass through the active node.
const matchesPath = (path: FlowPathNode[]): boolean => {
if (activeLink) return pathContainsFlowLink(path, activeLink)
return activeNode ? pathContainsFlowNode(path, activeNode) : false
}
const highlightedNodes = new Set<string>()
const highlightedLinks = new Set<string>()
for (const prepared of preparedPaths) {
const { path } = prepared
if (!matchesPath(path)) continue
for (const node of path) {
highlightedNodes.add(node.id)
}
for (let i = 0; i < path.length - 1; i++) {
const source = path[i]
const target = path[i + 1]
if (!source || !target) continue
highlightedLinks.add(pathLinkKey(source, target))
}
}
if (highlightedNodes.size === 0) return undefined
return {
nodes: highlightedNodes,
links: highlightedLinks,
}
}
function applyFlowHighlights(
nodes: Iterable<DashboardFlowNode>,
links: Iterable<DashboardFlowLink>,
highlightSets: FlowHighlightSets | undefined
): void {
if (!highlightSets) return
for (const node of nodes) {
node.highlighted = highlightSets.nodes.has(node.id)
node.dimmed = !node.highlighted
}
for (const link of links) {
const highlighted = highlightSets.links.has(linkStableKey(link))
link.highlighted = highlighted
link.dimmed = !highlighted
}
}
function buildFlowGraph( function buildFlowGraph(
rows: FlowQuotaDataItem[], rows: FlowQuotaDataItem[],
metric: FlowMetric, metric: FlowMetric,
...@@ -556,8 +738,18 @@ function buildFlowGraph( ...@@ -556,8 +738,18 @@ function buildFlowGraph(
addLink(links, source, target, metrics, metric, color, root.id) 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, b) =>
a.source.localeCompare(b.source) || a.target.localeCompare(b.target) a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
) )
...@@ -575,7 +767,7 @@ function buildFlowGraph( ...@@ -575,7 +767,7 @@ function buildFlowGraph(
assignLinkDisplayColors(flowLinks) assignLinkDisplayColors(flowLinks)
return { return {
nodes: Array.from(nodes.values()).sort(byValueThenLabel), nodes: [...nodes.values()].sort(byValueThenLabel),
links: flowLinks, links: flowLinks,
} }
} }
...@@ -586,11 +778,11 @@ function formatNumber(value: number): string { ...@@ -586,11 +778,11 @@ function formatNumber(value: number): string {
) )
} }
export function buildFlowFilterOptions( function buildUserFilterOptions(
rows: FlowQuotaDataItem[], rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota', metric: FlowMetric = 'quota',
palette?: readonly string[] palette?: readonly string[]
): FlowFilterOptions { ): FlowFilterOptions['users'] {
const users = new Map< const users = new Map<
string, string,
{ {
...@@ -618,18 +810,107 @@ export function buildFlowFilterOptions( ...@@ -618,18 +810,107 @@ export function buildFlowFilterOptions(
users.set(user.id, current) users.set(user.id, current)
} }
return [...users.entries()]
.map(([value, user]) => ({
value,
label: user.label,
valueLabel: formatNumber(user.value),
valueRaw: user.value,
color: user.color,
}))
.sort((a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label))
}
function buildNodeFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric,
role: FlowRole,
visibleStages: FlowNodeKind[] | undefined,
palette: readonly string[] | undefined,
ctx: FlowPathContext,
selectedNodes?: readonly FlowNodeFilter[]
): FlowFilterOptions['nodes'] {
const stages = resolveVisibleStages(role, visibleStages)
const stageOrder = new Map(stages.map((stage, index) => [stage, index]))
const colorIds = new Set<string>()
for (const row of rows) {
for (const node of flowPathForStages(row, stages, ctx)) {
colorIds.add(node.id)
}
}
const colors = stableColorMap(
[...colorIds].sort((a, b) => a.localeCompare(b)),
palette
)
const options: FlowFilterOptions['nodes'] = []
for (const stage of stages) {
const totals = new Map<
string,
{
node: FlowPathNode
value: number
}
>()
const candidateRows = filterRowsByNodes(
rows,
selectedNodeFiltersExceptKind(selectedNodes, stage),
stages,
ctx
)
for (const row of candidateRows) {
const metrics = rowMetrics(row)
const value = metricValue(metrics, metric)
const node = NODE_BUILDERS[stage](row, ctx)
const key = nodeFilterKey({ kind: node.kind, id: node.id })
const current = totals.get(key) ?? { node, value: 0 }
current.value += value
totals.set(key, current)
}
for (const rank of totals.values()) {
options.push({
kind: rank.node.kind,
value: rank.node.id,
label: rank.node.label,
valueLabel: formatNumber(rank.value),
valueRaw: rank.value,
color: colors.get(rank.node.id) ?? colorAt(0, palette),
})
}
}
return options
.sort(
(a, b) =>
(stageOrder.get(a.kind) ?? 0) - (stageOrder.get(b.kind) ?? 0) ||
b.valueRaw - a.valueRaw ||
a.label.localeCompare(b.label) ||
a.value.localeCompare(b.value)
)
}
export function buildFlowFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
palette?: readonly string[],
role: FlowRole = DEFAULT_FLOW_ROLE,
visibleStages?: FlowNodeKind[],
selectedNodes?: readonly FlowNodeFilter[]
): FlowFilterOptions {
return { return {
users: Array.from(users.entries()) users: buildUserFilterOptions(rows, metric, palette),
.map(([value, user]) => ({ nodes: buildNodeFilterOptions(
value, rows,
label: user.label, metric,
valueLabel: formatNumber(user.value), role,
valueRaw: user.value, visibleStages,
color: user.color, palette,
})) EMPTY_FLOW_PATH_CONTEXT,
.sort( selectedNodes
(a, b) => b.valueRaw - a.valueRaw || a.label.localeCompare(b.label) ),
),
} }
} }
...@@ -639,8 +920,18 @@ export function buildDashboardFlowData( ...@@ -639,8 +920,18 @@ export function buildDashboardFlowData(
options: FlowBuildOptions = {} options: FlowBuildOptions = {}
): ProcessedFlowData { ): ProcessedFlowData {
const role = options.role ?? DEFAULT_FLOW_ROLE const role = options.role ?? DEFAULT_FLOW_ROLE
const filteredRows = filterRows(rows, options)
const palette = options.colorPalette 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 { return {
summary: buildSummary(filteredRows), summary: buildSummary(filteredRows),
...@@ -650,16 +941,27 @@ export function buildDashboardFlowData( ...@@ -650,16 +941,27 @@ export function buildDashboardFlowData(
role, role,
palette, palette,
options.visibleStages, options.visibleStages,
{ ctx,
deletedTokenLabel: options.deletedTokenLabel,
},
{ {
topNodeLimit: options.topNodeLimit, topNodeLimit: options.topNodeLimit,
overflowMode: options.overflowMode, overflowMode: options.overflowMode,
otherNodeLabel: options.otherNodeLabel, 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( ...@@ -688,6 +990,21 @@ function sankeyDatumValue(
return sankeyDatumSource(datum)[key] return sankeyDatumSource(datum)[key]
} }
function sankeyDatumFlag(
datum: Record<string, unknown>,
key: string
): boolean {
return sankeyDatumValue(datum, key) === true
}
export function flowSankeyDatumValue(
datum: unknown,
key: string
): unknown {
const record = recordValue(datum)
return record ? sankeyDatumValue(record, key) : undefined
}
function isSankeyLinkDatum(datum: Record<string, unknown>): boolean { function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
return ( return (
sankeyDatumValue(datum, 'source') !== undefined && sankeyDatumValue(datum, 'source') !== undefined &&
...@@ -695,6 +1012,23 @@ function isSankeyLinkDatum(datum: Record<string, unknown>): boolean { ...@@ -695,6 +1012,23 @@ function isSankeyLinkDatum(datum: Record<string, unknown>): boolean {
) )
} }
export function flowNodeFilterFromSankeyDatum(
datum: unknown
): FlowNodeFilter | undefined {
const record = recordValue(datum)
if (!record || isSankeyLinkDatum(record)) return undefined
const id = flowSankeyDatumValue(record, 'key')
const kind = flowSankeyDatumValue(record, 'kind')
if (
(typeof id === 'string' || typeof id === 'number') &&
isFlowNodeKind(kind)
) {
return { kind, id: String(id) }
}
return undefined
}
function tooltipMetricLines( function tooltipMetricLines(
valueFormatter: (value: number) => string, valueFormatter: (value: number) => string,
labels: FlowSankeyLabels labels: FlowSankeyLabels
...@@ -755,28 +1089,41 @@ export function buildFlowSankeySpec( ...@@ -755,28 +1089,41 @@ export function buildFlowSankeySpec(
tokens: node.tokens, tokens: node.tokens,
color: node.color, color: node.color,
colorKey: node.colorKey, colorKey: node.colorKey,
highlighted: node.highlighted,
dimmed: node.dimmed,
})), })),
links: flow.links links: flow.links
.filter((link) => link.value > 0) .filter((link) => link.value > 0)
.sort(byLinkDrawPriority) .sort(byLinkDrawPriority)
.map((link, index) => ({ .map((link, index) => {
source: link.source, let zIndex = 100_000 + index
target: link.target, if (link.highlighted) {
linkKey: linkStableKey(link), zIndex = 1_000_000 + index
sourceLabel: link.sourceLabel, } else if (link.dimmed) {
targetLabel: link.targetLabel, zIndex = index
value: link.value, }
requests: link.requests,
quota: link.quota, return {
tokens: link.tokens, source: link.source,
color: link.color, target: link.target,
linkColor: link.linkColor, linkKey: linkStableKey(link),
linkAlpha: link.linkAlpha, sourceLabel: link.sourceLabel,
hoverColor: link.hoverColor, targetLabel: link.targetLabel,
colorKey: link.colorKey, value: link.value,
share: link.share, requests: link.requests,
zIndex: index, 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( ...@@ -823,9 +1170,17 @@ export function buildFlowSankeySpec(
style: { style: {
fill: (datum: Record<string, unknown>) => fill: (datum: Record<string, unknown>) =>
String(sankeyDatumValue(datum, 'color') ?? colorAt(0)), String(sankeyDatumValue(datum, 'color') ?? colorAt(0)),
fillOpacity: 0.92, fillOpacity: (datum: Record<string, unknown>) => {
stroke: 'rgba(148, 163, 184, 0.45)', if (sankeyDatumFlag(datum, 'dimmed')) return 0.18
lineWidth: 1, if (sankeyDatumFlag(datum, 'highlighted')) return 1
return 0.92
},
stroke: (datum: Record<string, unknown>) =>
sankeyDatumFlag(datum, 'highlighted')
? 'rgba(15, 23, 42, 0.74)'
: 'rgba(148, 163, 184, 0.45)',
lineWidth: (datum: Record<string, unknown>) =>
sankeyDatumFlag(datum, 'highlighted') ? 1.5 : 1,
cursor: 'pointer', cursor: 'pointer',
pickMode: 'accurate', pickMode: 'accurate',
}, },
...@@ -854,8 +1209,11 @@ export function buildFlowSankeySpec( ...@@ -854,8 +1209,11 @@ export function buildFlowSankeySpec(
sankeyDatumValue(datum, 'color') ?? sankeyDatumValue(datum, 'color') ??
colorAt(0) colorAt(0)
), ),
fillOpacity: (datum: Record<string, unknown>) => fillOpacity: (datum: Record<string, unknown>) => {
numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1, if (sankeyDatumFlag(datum, 'dimmed')) return 0.08
if (sankeyDatumFlag(datum, 'highlighted')) return 0.86
return numberValue(sankeyDatumValue(datum, 'linkAlpha')) || 1
},
cursor: 'pointer', cursor: 'pointer',
pickMode: 'accurate', pickMode: 'accurate',
boundsMode: 'accurate', boundsMode: 'accurate',
...@@ -889,7 +1247,12 @@ export function buildFlowSankeySpec( ...@@ -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: { tooltip: {
trigger: 'hover', trigger: 'hover',
activeType: 'mark', activeType: 'mark',
......
...@@ -36,6 +36,8 @@ export { processChartData, processUserChartData } from './charts' ...@@ -36,6 +36,8 @@ export { processChartData, processUserChartData } from './charts'
export { export {
buildDashboardFlowData, buildDashboardFlowData,
buildFlowSankeySpec, buildFlowSankeySpec,
flowNodeFilterFromSankeyDatum,
flowSankeyDatumValue,
getFlowStages, getFlowStages,
} from './flow' } from './flow'
export { safeDivide, calculateDashboardStats } from './stats' export { safeDivide, calculateDashboardStats } from './stats'
......
...@@ -62,9 +62,22 @@ export type FlowNodeKind = ...@@ -62,9 +62,22 @@ export type FlowNodeKind =
| 'model' | 'model'
| 'channel' | 'channel'
export interface FlowNodeFilter {
kind: FlowNodeKind
id: string
}
export interface FlowLinkSelection {
source: string
target: string
}
export interface FlowBuildOptions { export interface FlowBuildOptions {
role?: FlowRole role?: FlowRole
selectedUsers?: string[] selectedUsers?: string[]
selectedNodes?: FlowNodeFilter[]
activeNode?: FlowNodeFilter
activeLink?: FlowLinkSelection
colorPalette?: readonly string[] colorPalette?: readonly string[]
visibleStages?: FlowNodeKind[] visibleStages?: FlowNodeKind[]
topNodeLimit?: number topNodeLimit?: number
...@@ -85,6 +98,8 @@ export interface DashboardFlowNode { ...@@ -85,6 +98,8 @@ export interface DashboardFlowNode {
tokens: number tokens: number
color: string color: string
colorKey: string colorKey: string
highlighted?: boolean
dimmed?: boolean
} }
export interface DashboardFlowLink { export interface DashboardFlowLink {
...@@ -102,6 +117,8 @@ export interface DashboardFlowLink { ...@@ -102,6 +117,8 @@ export interface DashboardFlowLink {
hoverColor: string hoverColor: string
colorKey: string colorKey: string
share: number share: number
highlighted?: boolean
dimmed?: boolean
} }
export interface DashboardFlowGraph { export interface DashboardFlowGraph {
...@@ -117,8 +134,18 @@ export interface FlowUserFilterOption { ...@@ -117,8 +134,18 @@ export interface FlowUserFilterOption {
color: string color: string
} }
export interface FlowNodeFilterOption {
kind: FlowNodeKind
value: string
label: string
valueLabel: string
valueRaw: number
color: string
}
export interface FlowFilterOptions { export interface FlowFilterOptions {
users: FlowUserFilterOption[] users: FlowUserFilterOption[]
nodes: FlowNodeFilterOption[]
} }
export interface FlowSummary { export interface FlowSummary {
...@@ -171,6 +198,14 @@ export interface DashboardChartPreferences { ...@@ -171,6 +198,14 @@ export interface DashboardChartPreferences {
defaultTimeGranularity: TimeGranularity 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 // API Info Types
// ============================================================================ // ============================================================================
......
...@@ -271,6 +271,7 @@ ...@@ -271,6 +271,7 @@
"All Models": "All Models", "All Models": "All Models",
"All models in use are properly configured.": "All models in use are properly configured.", "All models in use are properly configured.": "All models in use are properly configured.",
"All Must Match (AND)": "All Must Match (AND)", "All Must Match (AND)": "All Must Match (AND)",
"All nodes": "All nodes",
"All requests must include": "All requests must include", "All requests must include": "All requests must include",
"All Status": "All Status", "All Status": "All Status",
"All Sync Status": "All Sync Status", "All Sync Status": "All Sync Status",
...@@ -780,6 +781,7 @@ ...@@ -780,6 +781,7 @@
"Clear filters": "Clear filters", "Clear filters": "Clear filters",
"Clear Mapping": "Clear Mapping", "Clear Mapping": "Clear Mapping",
"Clear mode flags in prompts": "Clear mode flags in prompts", "Clear mode flags in prompts": "Clear mode flags in prompts",
"Clear node filters": "Clear node filters",
"Clear search": "Clear search", "Clear search": "Clear search",
"Clear selection": "Clear selection", "Clear selection": "Clear selection",
"Clear selection (Escape)": "Clear selection (Escape)", "Clear selection (Escape)": "Clear selection (Escape)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Filter by name or ID...", "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, ID, or key...": "Filter by name, ID, or key...",
"Filter by name...": "Filter by name...", "Filter by name...": "Filter by name...",
"Filter by node": "Filter by node",
"Filter by price field": "Filter by price field", "Filter by price field": "Filter by price field",
"Filter by ratio type": "Filter by ratio type", "Filter by ratio type": "Filter by ratio type",
"Filter by request ID": "Filter by request ID", "Filter by request ID": "Filter by request ID",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Memory Threshold (%)", "Memory Threshold (%)": "Memory Threshold (%)",
"Merchant ID": "Merchant ID", "Merchant ID": "Merchant ID",
"Merchant ID is required": "Merchant ID is required", "Merchant ID is required": "Merchant ID is required",
"Merge into Other": "Merge into Other",
"Message Priority": "Message Priority", "Message Priority": "Message Priority",
"Metadata": "Metadata", "Metadata": "Metadata",
"min downtime": "min downtime", "min downtime": "min downtime",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "More than 999 days left", "More than 999 days left": "More than 999 days left",
"More...": "More...", "More...": "More...",
"Most-used models in the selected period and category": "Most-used models in the selected period and category", "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": "Move",
"Move a request header": "Move a request header", "Move a request header": "Move a request header",
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance", "Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
...@@ -2733,6 +2736,7 @@ ...@@ -2733,6 +2736,7 @@
"No models to remove": "No models to remove", "No models to remove": "No models to remove",
"No new models to add": "No new models to add", "No new models to add": "No new models to add",
"No new models yet": "No new models yet", "No new models yet": "No new models yet",
"No nodes": "No nodes",
"No notable climbers right now": "No notable climbers right now", "No notable climbers right now": "No notable climbers right now",
"No notable drops right now": "No notable drops right now", "No notable drops right now": "No notable drops right now",
"No parameter overrides configured.": "No parameter overrides configured.", "No parameter overrides configured.": "No parameter overrides configured.",
...@@ -2791,6 +2795,7 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "No vendor data available", "No vendor data available": "No vendor data available",
"No X Found": "No X Found", "No X Found": "No X Found",
"Node": "Node", "Node": "Node",
"Node filters": "Node filters",
"Node Name": "Node Name", "Node Name": "Node Name",
"Non-stream": "Non-stream", "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.", "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 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "Output tokens", "Output tokens": "Output tokens",
"Output Tokens": "Output Tokens", "Output Tokens": "Output Tokens",
"Overage limited": "Overage limited", "Overage limited": "Overage limited",
"Overflow items": "Overflow items",
"overall": "overall", "overall": "overall",
"Overflow items": "Overflow items",
"Overnight range": "Overnight range", "Overnight range": "Overnight range",
"override": "override", "override": "override",
"Override": "Override", "Override": "Override",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Remove functionResponse.id field", "Remove functionResponse.id field": "Remove functionResponse.id field",
"Remove mapped targets": "Remove mapped targets", "Remove mapped targets": "Remove mapped targets",
"Remove Models": "Remove Models", "Remove Models": "Remove Models",
"Remove node filter": "Remove node filter",
"Remove Passkey": "Remove Passkey", "Remove Passkey": "Remove Passkey",
"Remove Passkey?": "Remove Passkey?", "Remove Passkey?": "Remove Passkey?",
"Remove rule group": "Remove rule group", "Remove rule group": "Remove rule group",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Selected {{count}}", "Selected {{count}}": "Selected {{count}}",
"selected channel(s). Leave empty to remove tag.": "selected channel(s). Leave empty to remove tag.", "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 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.", "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", "Self-Use Mode": "Self-Use Mode",
"Send": "Send", "Send": "Send",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "Value", "Value": "Value",
"Value (supports JSON or plain text)": "Value (supports JSON or plain text)", "Value (supports JSON or plain text)": "Value (supports JSON or plain text)",
"Value is required": "Value is required", "Value is required": "Value is required",
"Value metric": "Value metric",
"Value must be at least 0": "Value must be at least 0", "Value must be at least 0": "Value must be at least 0",
"Value Regex": "Value Regex", "Value Regex": "Value Regex",
"variable": "variable", "variable": "variable",
......
...@@ -271,6 +271,7 @@ ...@@ -271,6 +271,7 @@
"All Models": "Tous les modèles", "All Models": "Tous les modèles",
"All models in use are properly configured.": "Tous les modèles utilisés sont correctement configurés.", "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 Must Match (AND)": "Toutes doivent correspondre (AND)",
"All nodes": "Tous les nœuds",
"All requests must include": "Toutes les requêtes doivent inclure", "All requests must include": "Toutes les requêtes doivent inclure",
"All Status": "Tous les statuts", "All Status": "Tous les statuts",
"All Sync Status": "Tous les statuts de synchronisation", "All Sync Status": "Tous les statuts de synchronisation",
...@@ -780,6 +781,7 @@ ...@@ -780,6 +781,7 @@
"Clear filters": "Effacer les filtres", "Clear filters": "Effacer les filtres",
"Clear Mapping": "Effacer le mappage", "Clear Mapping": "Effacer le mappage",
"Clear mode flags in prompts": "Effacer les indicateurs de mode dans les prompts", "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 search": "Effacer la recherche",
"Clear selection": "Effacer la sélection", "Clear selection": "Effacer la sélection",
"Clear selection (Escape)": "Effacer la sélection (Échap)", "Clear selection (Escape)": "Effacer la sélection (Échap)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Filtrer par nom ou ID...", "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, ID, or key...": "Filtrer par nom, ID ou clé...",
"Filter by name...": "Filtrer par nom...", "Filter by name...": "Filtrer par nom...",
"Filter by node": "Filtrer par nœud",
"Filter by price field": "Filtrer par champ de prix", "Filter by price field": "Filtrer par champ de prix",
"Filter by ratio type": "Filtrer par type de ratio", "Filter by ratio type": "Filtrer par type de ratio",
"Filter by request ID": "Filtrer par ID de requête", "Filter by request ID": "Filtrer par ID de requête",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Seuil mémoire (%)", "Memory Threshold (%)": "Seuil mémoire (%)",
"Merchant ID": "ID du commerçant", "Merchant ID": "ID du commerçant",
"Merchant ID is required": "L'ID marchand est requis", "Merchant ID is required": "L'ID marchand est requis",
"Merge into Other": "Fusionner dans Autres",
"Message Priority": "Priorité du message", "Message Priority": "Priorité du message",
"Metadata": "Métadonnées", "Metadata": "Métadonnées",
"min downtime": "min d'interruption", "min downtime": "min d'interruption",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "Plus de 999 jours restants", "More than 999 days left": "Plus de 999 jours restants",
"More...": "Plus...", "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", "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": "Déplacer",
"Move a request header": "Déplacer un en-tête de requête", "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", "Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal",
...@@ -2733,6 +2736,7 @@ ...@@ -2733,6 +2736,7 @@
"No models to remove": "Aucun modèle à supprimer", "No models to remove": "Aucun modèle à supprimer",
"No new models to add": "Aucun nouveau modèle à ajouter", "No new models to add": "Aucun nouveau modèle à ajouter",
"No new models yet": "Pas encore de nouveaux modèles", "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 climbers right now": "Aucune progression notable pour le moment",
"No notable drops right now": "Aucune chute 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.", "No parameter overrides configured.": "Aucune surcharge de paramètres configurée.",
...@@ -2791,6 +2795,7 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "Aucune donnée de fournisseur disponible", "No vendor data available": "Aucune donnée de fournisseur disponible",
"No X Found": "Aucun X trouvé", "No X Found": "Aucun X trouvé",
"Node": "Nœud", "Node": "Nœud",
"Node filters": "Filtres de nœuds",
"Node Name": "Nom du nœud", "Node Name": "Nom du nœud",
"Non-stream": "Non-streaming", "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.", "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 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "Jetons de sortie", "Output tokens": "Jetons de sortie",
"Output Tokens": "Tokens de sortie", "Output Tokens": "Tokens de sortie",
"Overage limited": "Dépassement limité", "Overage limited": "Dépassement limité",
"Overflow items": "Éléments excédentaires",
"overall": "global", "overall": "global",
"Overflow items": "Éléments excédentaires",
"Overnight range": "Plage nocturne", "Overnight range": "Plage nocturne",
"override": "remplacer", "override": "remplacer",
"Override": "Remplacer", "Override": "Remplacer",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Supprimer le champ functionResponse.id", "Remove functionResponse.id field": "Supprimer le champ functionResponse.id",
"Remove mapped targets": "Retirer les cibles mappées", "Remove mapped targets": "Retirer les cibles mappées",
"Remove Models": "Supprimer des modèles", "Remove Models": "Supprimer des modèles",
"Remove node filter": "Supprimer le filtre de nœud",
"Remove Passkey": "Supprimer le Passkey", "Remove Passkey": "Supprimer le Passkey",
"Remove Passkey?": "Supprimer la clé d'accès ?", "Remove Passkey?": "Supprimer la clé d'accès ?",
"Remove rule group": "Supprimer le groupe de règles", "Remove rule group": "Supprimer le groupe de règles",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "{{count}} sélectionné(s)", "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 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 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.", "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", "Self-Use Mode": "Mode d'utilisation personnelle",
"Send": "Envoyer", "Send": "Envoyer",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "Valeur", "Value": "Valeur",
"Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)", "Value (supports JSON or plain text)": "Valeur (JSON ou texte brut)",
"Value is required": "La valeur est obligatoire", "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 must be at least 0": "La valeur doit être au moins 0",
"Value Regex": "Regex de valeur", "Value Regex": "Regex de valeur",
"variable": "variable", "variable": "variable",
......
...@@ -271,6 +271,7 @@ ...@@ -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)": "すべて一致(AND)", "All Must Match (AND)": "すべて一致(AND)",
"All nodes": "すべてのノード",
"All requests must include": "すべてのリクエストには", "All requests must include": "すべてのリクエストには",
"All Status": "すべてのステータス", "All Status": "すべてのステータス",
"All Sync Status": "すべての同期状態", "All Sync Status": "すべての同期状態",
...@@ -780,6 +781,7 @@ ...@@ -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 search": "検索をクリア", "Clear search": "検索をクリア",
"Clear selection": "選択をクリア", "Clear selection": "選択をクリア",
"Clear selection (Escape)": "選択をクリア (Escape)", "Clear selection (Escape)": "選択をクリア (Escape)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "名前またはIDでフィルター...", "Filter by name or ID...": "名前またはIDでフィルター...",
"Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...", "Filter by name, ID, or key...": "名前、ID、またはキーでフィルター...",
"Filter by name...": "名前でフィルター...", "Filter by name...": "名前でフィルター...",
"Filter by node": "ノードでフィルター",
"Filter by price field": "価格フィールドでフィルター", "Filter by price field": "価格フィールドでフィルター",
"Filter by ratio type": "倍率タイプで絞り込み", "Filter by ratio type": "倍率タイプで絞り込み",
"Filter by request ID": "リクエストIDで絞り込み", "Filter by request ID": "リクエストIDで絞り込み",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "メモリ閾値 (%)", "Memory Threshold (%)": "メモリ閾値 (%)",
"Merchant ID": "マーチャントID", "Merchant ID": "マーチャントID",
"Merchant ID is required": "マーチャント ID は必須です", "Merchant ID is required": "マーチャント ID は必須です",
"Merge into Other": "その他にまとめる",
"Message Priority": "メッセージの優先度", "Message Priority": "メッセージの優先度",
"Metadata": "メタデータ", "Metadata": "メタデータ",
"min downtime": "分のダウンタイム", "min downtime": "分のダウンタイム",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "999日以上", "More than 999 days left": "999日以上",
"More...": "その他...", "More...": "その他...",
"Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル", "Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル",
"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 @@ ...@@ -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 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 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "ベンダーデータがありません", "No vendor data available": "ベンダーデータがありません",
"No X Found": "X が見つかりません", "No X Found": "X が見つかりません",
"Node": "ノード", "Node": "ノード",
"Node filters": "ノードフィルター",
"Node Name": "ノード名", "Node Name": "ノード名",
"Non-stream": "非ストリーミング", "Non-stream": "非ストリーミング",
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。", "Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "0 以外の招待報酬には、支払いゲートウェイ設定でのコンプライアンス確認が必要です。",
...@@ -2958,8 +2963,8 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "出力トークン", "Output tokens": "出力トークン",
"Output Tokens": "出力トークン", "Output Tokens": "出力トークン",
"Overage limited": "超過利用制限中", "Overage limited": "超過利用制限中",
"Overflow items": "超過項目",
"overall": "全体", "overall": "全体",
"Overflow items": "超過項目",
"Overnight range": "日跨ぎ範囲", "Overnight range": "日跨ぎ範囲",
"override": "上書き", "override": "上書き",
"Override": "上書き", "Override": "上書き",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "functionResponse.id フィールドを削除", "Remove functionResponse.id field": "functionResponse.id フィールドを削除",
"Remove mapped targets": "マッピング先を削除", "Remove mapped targets": "マッピング先を削除",
"Remove Models": "モデルを削除", "Remove Models": "モデルを削除",
"Remove node filter": "ノードフィルターを削除",
"Remove Passkey": "Passkey連携解除", "Remove Passkey": "Passkey連携解除",
"Remove Passkey?": "Passkeyを削除しますか?", "Remove Passkey?": "Passkeyを削除しますか?",
"Remove rule group": "ルールグループを削除", "Remove rule group": "ルールグループを削除",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "{{count}} 件選択済み", "Selected {{count}}": "{{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 when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。", "Selected when creating a token and used as the default billing group for API calls.": "トークン作成時に選択され、API 呼び出しのデフォルト課金グループとして使われます。",
"Self-Use Mode": "セルフユースモード", "Self-Use Mode": "セルフユースモード",
"Send": "送信", "Send": "送信",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "値", "Value": "値",
"Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)", "Value (supports JSON or plain text)": "値(JSONまたはプレーンテキスト対応)",
"Value is required": "値は必須です", "Value is required": "値は必須です",
"Value metric": "値の指標",
"Value must be at least 0": "値は 0 以上である必要があります", "Value must be at least 0": "値は 0 以上である必要があります",
"Value Regex": "Value 正規表現", "Value Regex": "Value 正規表現",
"variable": "変数", "variable": "変数",
......
...@@ -271,6 +271,7 @@ ...@@ -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)": "Все должны совпасть (AND)", "All Must Match (AND)": "Все должны совпасть (AND)",
"All nodes": "Все узлы",
"All requests must include": "Все запросы должны содержать", "All requests must include": "Все запросы должны содержать",
"All Status": "Все статусы", "All Status": "Все статусы",
"All Sync Status": "Все статусы синхронизации", "All Sync Status": "Все статусы синхронизации",
...@@ -780,6 +781,7 @@ ...@@ -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 search": "Очистить поиск", "Clear search": "Очистить поиск",
"Clear selection": "Снять выделение", "Clear selection": "Снять выделение",
"Clear selection (Escape)": "Снять выделение (Escape)", "Clear selection (Escape)": "Снять выделение (Escape)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Фильтр по имени или ID...", "Filter by name or ID...": "Фильтр по имени или ID...",
"Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...", "Filter by name, ID, or key...": "Фильтровать по имени, ID или ключу...",
"Filter by name...": "Фильтр по имени...", "Filter by name...": "Фильтр по имени...",
"Filter by node": "Фильтр по узлу",
"Filter by price field": "Фильтр по полю цены", "Filter by price field": "Фильтр по полю цены",
"Filter by ratio type": "Фильтровать по типу коэффициента", "Filter by ratio type": "Фильтровать по типу коэффициента",
"Filter by request ID": "Фильтр по ID запроса", "Filter by request ID": "Фильтр по ID запроса",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Порог памяти (%)", "Memory Threshold (%)": "Порог памяти (%)",
"Merchant ID": "ID мерчанта", "Merchant ID": "ID мерчанта",
"Merchant ID is required": "Требуется ID мерчанта", "Merchant ID is required": "Требуется ID мерчанта",
"Merge into Other": "Объединить в «Другое»",
"Message Priority": "Приоритет сообщения", "Message Priority": "Приоритет сообщения",
"Metadata": "Метаданные", "Metadata": "Метаданные",
"min downtime": "мин простоя", "min downtime": "мин простоя",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "Более 999 дней", "More than 999 days left": "Более 999 дней",
"More...": "Подробнее...", "More...": "Подробнее...",
"Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории", "Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории",
"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 @@ ...@@ -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 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 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "Данных по поставщикам нет", "No vendor data available": "Данных по поставщикам нет",
"No X Found": "X не найдено", "No X Found": "X не найдено",
"Node": "Узел", "Node": "Узел",
"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 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "Выходные токены", "Output tokens": "Выходные токены",
"Output Tokens": "Выходные токены", "Output Tokens": "Выходные токены",
"Overage limited": "Ограничение перерасхода", "Overage limited": "Ограничение перерасхода",
"Overflow items": "Элементы сверх лимита",
"overall": "всего", "overall": "всего",
"Overflow items": "Элементы сверх лимита",
"Overnight range": "Диапазон через полночь", "Overnight range": "Диапазон через полночь",
"override": "переопределить", "override": "переопределить",
"Override": "Перезаписать", "Override": "Перезаписать",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Удалить поле functionResponse.id", "Remove functionResponse.id field": "Удалить поле functionResponse.id",
"Remove mapped targets": "Удалить сопоставленные цели", "Remove mapped targets": "Удалить сопоставленные цели",
"Remove Models": "Удалить модели", "Remove Models": "Удалить модели",
"Remove node filter": "Удалить фильтр узла",
"Remove Passkey": "Отвязать Passkey", "Remove Passkey": "Отвязать Passkey",
"Remove Passkey?": "Удалить ключ доступа?", "Remove Passkey?": "Удалить ключ доступа?",
"Remove rule group": "Удалить группу правил", "Remove rule group": "Удалить группу правил",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Выбрано: {{count}}", "Selected {{count}}": "Выбрано: {{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 when creating a token and used as the default billing group for API calls.": "Выбирается при создании токена и используется как группа тарификации по умолчанию для вызовов API.", "Selected when creating a token and used as the default billing group for API calls.": "Выбирается при создании токена и используется как группа тарификации по умолчанию для вызовов API.",
"Self-Use Mode": "Режим самоиспользования", "Self-Use Mode": "Режим самоиспользования",
"Send": "Отправить", "Send": "Отправить",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "Значение", "Value": "Значение",
"Value (supports JSON or plain text)": "Значение (JSON или текст)", "Value (supports JSON or plain text)": "Значение (JSON или текст)",
"Value is required": "Значение обязательно", "Value is required": "Значение обязательно",
"Value metric": "Метрика значения",
"Value must be at least 0": "Значение должно быть не менее 0", "Value must be at least 0": "Значение должно быть не менее 0",
"Value Regex": "Регулярное выражение значения", "Value Regex": "Регулярное выражение значения",
"variable": "переменная", "variable": "переменная",
......
...@@ -271,6 +271,7 @@ ...@@ -271,6 +271,7 @@
"All Models": "Tất cả các mẫu", "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 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 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 requests must include": "Mọi yêu cầu phải có header",
"All Status": "Tất cả trạng thái", "All Status": "Tất cả trạng thái",
"All Sync Status": "Tất cả Trạng thái Đồng bộ", "All Sync Status": "Tất cả Trạng thái Đồng bộ",
...@@ -780,6 +781,7 @@ ...@@ -780,6 +781,7 @@
"Clear filters": "Clear filter", "Clear filters": "Clear filter",
"Clear Mapping": "Xóa Ánh xạ", "Clear Mapping": "Xóa Ánh xạ",
"Clear mode flags in prompts": "Xóa các cờ chế độ trong lời nhắc", "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 search": "Xóa tìm kiếm",
"Clear selection": "Bỏ chọn", "Clear selection": "Bỏ chọn",
"Clear selection (Escape)": "Bỏ chọn (Escape)", "Clear selection (Escape)": "Bỏ chọn (Escape)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "Lọc theo tên hoặc ID...", "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, ID, or key...": "Lọc theo tên, ID hoặc khóa...",
"Filter by name...": "Lọc theo tên...", "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 price field": "Lọc theo trường giá",
"Filter by ratio type": "Lọc theo loại tỷ lệ", "Filter by ratio type": "Lọc theo loại tỷ lệ",
"Filter by request ID": "Lọc theo ID yêu cầu", "Filter by request ID": "Lọc theo ID yêu cầu",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "Ngưỡng bộ nhớ (%)", "Memory Threshold (%)": "Ngưỡng bộ nhớ (%)",
"Merchant ID": "Mã thương gia", "Merchant ID": "Mã thương gia",
"Merchant ID is required": "Bắt buộc nhập Merchant ID", "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", "Message Priority": "Ưu tiên tin nhắn",
"Metadata": "Siêu dữ liệu", "Metadata": "Siêu dữ liệu",
"min downtime": "phút gián đoạn", "min downtime": "phút gián đoạn",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "Hơn 999 ngày", "More than 999 days left": "Hơn 999 ngày",
"More...": "Thêm...", "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", "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": "Di chuyển",
"Move a request header": "Di chuyển header yêu cầu", "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", "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 @@ ...@@ -2733,6 +2736,7 @@
"No models to remove": "Không có mô hình để xóa", "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 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 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 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 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.", "No parameter overrides configured.": "Không có ghi đè tham số nào được cấu hình.",
...@@ -2791,6 +2795,7 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "Không có dữ liệu nhà cung cấp", "No vendor data available": "Không có dữ liệu nhà cung cấp",
"No X Found": "Không tìm thấy X", "No X Found": "Không tìm thấy X",
"Node": "Nút", "Node": "Nút",
"Node filters": "Bộ lọc nút",
"Node Name": "Tên nút", "Node Name": "Tên nút",
"Non-stream": "Không phát trực tuyến", "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.", "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 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "Token đầu ra", "Output tokens": "Token đầu ra",
"Output Tokens": "Token đầu ra", "Output Tokens": "Token đầu ra",
"Overage limited": "Đã giới hạn vượt mức", "Overage limited": "Đã giới hạn vượt mức",
"Overflow items": "Mục vượt giới hạn",
"overall": "tổng", "overall": "tổng",
"Overflow items": "Mục vượt giới hạn",
"Overnight range": "Khoảng qua nửa đêm", "Overnight range": "Khoảng qua nửa đêm",
"override": "ghi đè", "override": "ghi đè",
"Override": "Ghi đè", "Override": "Ghi đè",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "Loại bỏ trường functionResponse.id", "Remove functionResponse.id field": "Loại bỏ trường functionResponse.id",
"Remove mapped targets": "Xóa đích đã ánh xạ", "Remove mapped targets": "Xóa đích đã ánh xạ",
"Remove Models": "Xóa mô hình", "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 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", "Remove rule group": "Gỡ nhóm quy tắc",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "Đã chọn {{count}}", "Selected {{count}}": "Đã chọn {{count}}",
"selected channel(s). Leave empty to remove tag.": "Kênh đã chọn. Để trống để xóa thẻ.", "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 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.", "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", "Self-Use Mode": "Chế độ tự sử dụng",
"Send": "Gửi", "Send": "Gửi",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "Giá trị", "Value": "Giá trị",
"Value (supports JSON or plain text)": "Giá trị (hỗ trợ JSON hoặc văn bản thuần)", "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 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 must be at least 0": "Giá trị phải ít nhất là 0",
"Value Regex": "Regex giá trị", "Value Regex": "Regex giá trị",
"variable": "biến", "variable": "biến",
......
...@@ -271,6 +271,7 @@ ...@@ -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)": "全部满足(AND)", "All Must Match (AND)": "全部满足(AND)",
"All nodes": "全部节点",
"All requests must include": "所有请求必须携带", "All requests must include": "所有请求必须携带",
"All Status": "所有状态", "All Status": "所有状态",
"All Sync Status": "所有同步状态", "All Sync Status": "所有同步状态",
...@@ -780,6 +781,7 @@ ...@@ -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 search": "清除搜索", "Clear search": "清除搜索",
"Clear selection": "清除选择", "Clear selection": "清除选择",
"Clear selection (Escape)": "清除选择 (Escape)", "Clear selection (Escape)": "清除选择 (Escape)",
...@@ -1842,6 +1844,7 @@ ...@@ -1842,6 +1844,7 @@
"Filter by name or ID...": "按名称或 ID 筛选...", "Filter by name or ID...": "按名称或 ID 筛选...",
"Filter by name, ID, or key...": "按名称、ID 或密钥筛选...", "Filter by name, ID, or key...": "按名称、ID 或密钥筛选...",
"Filter by name...": "按名称筛选...", "Filter by name...": "按名称筛选...",
"Filter by node": "按节点筛选",
"Filter by price field": "按价格字段筛选", "Filter by price field": "按价格字段筛选",
"Filter by ratio type": "按倍率类型筛选", "Filter by ratio type": "按倍率类型筛选",
"Filter by request ID": "按请求 ID 筛选", "Filter by request ID": "按请求 ID 筛选",
...@@ -2431,6 +2434,7 @@ ...@@ -2431,6 +2434,7 @@
"Memory Threshold (%)": "内存阈值 (%)", "Memory Threshold (%)": "内存阈值 (%)",
"Merchant ID": "商户 ID", "Merchant ID": "商户 ID",
"Merchant ID is required": "商户 ID 为必填项", "Merchant ID is required": "商户 ID 为必填项",
"Merge into Other": "合并为其他",
"Message Priority": "消息优先级", "Message Priority": "消息优先级",
"Metadata": "元信息", "Metadata": "元信息",
"min downtime": "分钟停机", "min downtime": "分钟停机",
...@@ -2557,7 +2561,6 @@ ...@@ -2557,7 +2561,6 @@
"More than 999 days left": "剩余超过 999 天", "More than 999 days left": "剩余超过 999 天",
"More...": "更多...", "More...": "更多...",
"Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型", "Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型",
"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 @@ ...@@ -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 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 @@ ...@@ -2791,6 +2795,7 @@
"No vendor data available": "暂无厂商数据", "No vendor data available": "暂无厂商数据",
"No X Found": "未找到 X", "No X Found": "未找到 X",
"Node": "节点", "Node": "节点",
"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 @@ ...@@ -2958,8 +2963,8 @@
"Output tokens": "输出 token", "Output tokens": "输出 token",
"Output Tokens": "输出 Token", "Output Tokens": "输出 Token",
"Overage limited": "超额受限", "Overage limited": "超额受限",
"Overflow items": "超出项",
"overall": "总体", "overall": "总体",
"Overflow items": "超出项",
"Overnight range": "跨日范围", "Overnight range": "跨日范围",
"override": "覆盖", "override": "覆盖",
"Override": "覆盖", "Override": "覆盖",
...@@ -3460,6 +3465,7 @@ ...@@ -3460,6 +3465,7 @@
"Remove functionResponse.id field": "移除 functionResponse.id 字段", "Remove functionResponse.id field": "移除 functionResponse.id 字段",
"Remove mapped targets": "移除映射目标", "Remove mapped targets": "移除映射目标",
"Remove Models": "删除模型", "Remove Models": "删除模型",
"Remove node filter": "移除节点筛选",
"Remove Passkey": "解绑 Passkey", "Remove Passkey": "解绑 Passkey",
"Remove Passkey?": "移除通行密钥?", "Remove Passkey?": "移除通行密钥?",
"Remove rule group": "移除规则组", "Remove rule group": "移除规则组",
...@@ -3804,6 +3810,7 @@ ...@@ -3804,6 +3810,7 @@
"Selected {{count}}": "已选 {{count}} 个", "Selected {{count}}": "已选 {{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 when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。", "Selected when creating a token and used as the default billing group for API calls.": "创建令牌时选择,用作 API 调用的默认计费分组。",
"Self-Use Mode": "自用模式", "Self-Use Mode": "自用模式",
"Send": "发送", "Send": "发送",
...@@ -4582,6 +4589,7 @@ ...@@ -4582,6 +4589,7 @@
"Value": "值", "Value": "值",
"Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)", "Value (supports JSON or plain text)": "值(支持 JSON 或普通文本)",
"Value is required": "值为必填项", "Value is required": "值为必填项",
"Value metric": "数值口径",
"Value must be at least 0": "值必须至少为 0", "Value must be at least 0": "值必须至少为 0",
"Value Regex": "Value 正则", "Value Regex": "Value 正则",
"variable": "变量", "variable": "变量",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment