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