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