feat: limit dashboard flow nodes
This commit is contained in:
+165
-109
@@ -32,13 +32,8 @@ import {
|
||||
WalletCards,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { formatNumber, formatQuota } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import {
|
||||
Empty,
|
||||
@@ -56,7 +51,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { getFlowQuotaDates } from '@/features/dashboard/api'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
@@ -74,23 +68,34 @@ import type {
|
||||
DashboardFilters,
|
||||
FlowMetric,
|
||||
FlowNodeKind,
|
||||
FlowOverflowMode,
|
||||
FlowRole,
|
||||
FlowSummary,
|
||||
} from '@/features/dashboard/types'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { computeTimeRange } from '@/lib/time'
|
||||
import { useChartTheme } from '@/lib/use-chart-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { VCHART_OPTION } from '@/lib/vchart'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
interface FlowChartsProps {
|
||||
filters?: DashboardFilters
|
||||
}
|
||||
|
||||
interface FlowStatsProps {
|
||||
summary: FlowSummary
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
const FLOW_METRIC_OPTIONS = [
|
||||
{ value: 'quota', labelKey: 'Quota', icon: WalletCards },
|
||||
{ value: 'tokens', labelKey: 'Tokens', icon: Hash },
|
||||
{ value: 'requests', labelKey: 'Requests', icon: Activity },
|
||||
{ value: 'quota', labelKey: 'By quota', icon: WalletCards },
|
||||
{ value: 'tokens', labelKey: 'By tokens', icon: Hash },
|
||||
{ value: 'requests', labelKey: 'By requests', icon: Activity },
|
||||
] as const
|
||||
|
||||
const FLOW_TOP_LIMIT_OPTIONS = [10, 20, 50, 100] as const
|
||||
|
||||
const DEFAULT_FLOW_TOP_NODE_LIMIT = 50
|
||||
|
||||
const FLOW_OVERFLOW_MODE_OPTIONS = [
|
||||
{ value: 'aggregate', labelKey: 'Merge into Other' },
|
||||
{ value: 'hide', labelKey: 'Hide' },
|
||||
] as const
|
||||
|
||||
// A Sankey needs at least two columns to render any link.
|
||||
@@ -126,58 +131,13 @@ const FLOW_STAGE_META: Record<
|
||||
},
|
||||
}
|
||||
|
||||
function FlowStats(props: FlowStatsProps) {
|
||||
const { t } = useTranslation()
|
||||
const items = [
|
||||
{
|
||||
key: 'quota',
|
||||
title: 'Quota',
|
||||
value: formatQuota(props.summary.quota),
|
||||
icon: WalletCards,
|
||||
},
|
||||
{
|
||||
key: 'tokens',
|
||||
title: 'Tokens',
|
||||
value: formatNumber(props.summary.tokens),
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
key: 'requests',
|
||||
title: 'Requests',
|
||||
value: formatNumber(props.summary.requests),
|
||||
icon: Activity,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<div className='divide-border/60 grid grid-cols-3 divide-x'>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div key={item.key} className='px-3 py-2.5 sm:px-5 sm:py-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Icon className='text-muted-foreground/60 size-3.5 shrink-0' />
|
||||
<div className='text-muted-foreground truncate text-xs font-medium tracking-wider uppercase'>
|
||||
{t(item.title)}
|
||||
</div>
|
||||
</div>
|
||||
{props.loading ? (
|
||||
<div className='mt-2 flex flex-col gap-1.5'>
|
||||
<Skeleton className='h-7 w-20' />
|
||||
<Skeleton className='h-3.5 w-28' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
|
||||
{item.value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const FLOW_OTHER_NODE_LABEL_KEYS: Record<FlowNodeKind, string> = {
|
||||
user: 'Other users',
|
||||
node: 'Other nodes',
|
||||
token: 'Other tokens',
|
||||
group: 'Other groups',
|
||||
model: 'Other models',
|
||||
channel: 'Other channels',
|
||||
}
|
||||
|
||||
export function FlowCharts(props: FlowChartsProps) {
|
||||
@@ -188,6 +148,9 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
|
||||
const flowRole: FlowRole = isRoot ? 'root' : isAdmin ? 'admin' : 'user'
|
||||
const [metric, setMetric] = useState<FlowMetric>('quota')
|
||||
const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT)
|
||||
const [overflowMode, setOverflowMode] =
|
||||
useState<FlowOverflowMode>('aggregate')
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
|
||||
const [hiddenStages, setHiddenStages] = useState<FlowNodeKind[]>([])
|
||||
|
||||
@@ -247,9 +210,22 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
role: flowRole,
|
||||
selectedUsers,
|
||||
visibleStages,
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
deletedTokenLabel: (tokenId) => t('Deleted ({{id}})', { id: tokenId }),
|
||||
otherNodeLabel: (kind) => t(FLOW_OTHER_NODE_LABEL_KEYS[kind]),
|
||||
}),
|
||||
[flowRole, flowRows, isLoading, metric, selectedUsers, visibleStages, t]
|
||||
[
|
||||
flowRole,
|
||||
flowRows,
|
||||
isLoading,
|
||||
metric,
|
||||
overflowMode,
|
||||
selectedUsers,
|
||||
topNodeLimit,
|
||||
visibleStages,
|
||||
t,
|
||||
]
|
||||
)
|
||||
const userFilterOptions = useMemo(
|
||||
() =>
|
||||
@@ -273,6 +249,8 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
const chartTheme = resolvedTheme === 'dark' ? 'dark' : 'light'
|
||||
const chartKey = [
|
||||
metric,
|
||||
topNodeLimit,
|
||||
overflowMode,
|
||||
flowRole,
|
||||
selectedUsers.join(','),
|
||||
visibleStages.join(','),
|
||||
@@ -292,46 +270,124 @@ export function FlowCharts(props: FlowChartsProps) {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
<FlowStats summary={flowData.summary} loading={isLoading} />
|
||||
|
||||
<div className='flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Tabs
|
||||
value={metric}
|
||||
onValueChange={(value) => setMetric(value as FlowMetric)}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList>
|
||||
{FLOW_METRIC_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className='flex min-w-0 flex-col gap-2 sm:flex-row lg:w-[min(24rem,34vw)]'>
|
||||
<MultiSelect
|
||||
options={userFilterOptions}
|
||||
selected={selectedUsers}
|
||||
onChange={setSelectedUsers}
|
||||
placeholder={t('All users')}
|
||||
emptyText={t('No users')}
|
||||
maxVisibleChips={2}
|
||||
renderSelectedSummary={(values) =>
|
||||
compactFlowSelectionLabel(values.length)
|
||||
}
|
||||
/>
|
||||
<div className='flex flex-col gap-2 xl:flex-row xl:items-end xl:justify-between'>
|
||||
<div className='flex min-w-0 flex-wrap items-end gap-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Flow width metric')}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground/60 hover:text-foreground flex size-5 shrink-0 items-center justify-center rounded-md'
|
||||
aria-label={t('Flow width metric')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Info className='size-3.5' />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className='max-w-[14rem]'>
|
||||
{t('Choose how flow widths are calculated.')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Tabs
|
||||
value={metric}
|
||||
onValueChange={(value) => setMetric(value as FlowMetric)}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Flow width metric')}>
|
||||
{FLOW_METRIC_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='gap-1.5 px-2.5 text-xs'
|
||||
>
|
||||
<Icon data-icon='inline-start' aria-hidden='true' />
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
)
|
||||
})}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Display limit')}
|
||||
</span>
|
||||
<Tabs
|
||||
value={String(topNodeLimit)}
|
||||
onValueChange={(value) => setTopNodeLimit(Number(value))}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Display limit')}>
|
||||
{FLOW_TOP_LIMIT_OPTIONS.map((limit) => (
|
||||
<TabsTrigger
|
||||
key={limit}
|
||||
value={String(limit)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t('Top {{count}}', { count: limit })}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
{t('Overflow items')}
|
||||
</span>
|
||||
<Tabs
|
||||
value={overflowMode}
|
||||
onValueChange={(value) =>
|
||||
setOverflowMode(value as FlowOverflowMode)
|
||||
}
|
||||
className='shrink-0'
|
||||
>
|
||||
<TabsList aria-label={t('Overflow items')}>
|
||||
{FLOW_OVERFLOW_MODE_OPTIONS.map((option) => (
|
||||
<TabsTrigger
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{t(option.labelKey)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex min-w-0 items-center gap-2 xl:justify-end'>
|
||||
{isAdmin && (
|
||||
<div className='flex min-w-0 flex-col gap-2 sm:flex-row xl:w-[min(24rem,34vw)]'>
|
||||
<MultiSelect
|
||||
options={userFilterOptions}
|
||||
selected={selectedUsers}
|
||||
onChange={setSelectedUsers}
|
||||
placeholder={t('All users')}
|
||||
emptyText={t('No users')}
|
||||
maxVisibleChips={2}
|
||||
renderSelectedSummary={(values) =>
|
||||
compactFlowSelectionLabel(values.length)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
|
||||
+144
@@ -1,5 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import type { FlowQuotaDataItem } from '../types'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
@@ -52,6 +53,42 @@ const rows: FlowQuotaDataItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const topLimitRows: FlowQuotaDataItem[] = [
|
||||
{
|
||||
user_id: 1,
|
||||
username: 'alpha',
|
||||
use_group: 'vip',
|
||||
channel_id: 201,
|
||||
channel_name: 'channel-a',
|
||||
model_name: 'model-a',
|
||||
quota: 100,
|
||||
token_used: 1_000,
|
||||
count: 1,
|
||||
},
|
||||
{
|
||||
user_id: 2,
|
||||
username: 'beta',
|
||||
use_group: 'default',
|
||||
channel_id: 202,
|
||||
channel_name: 'channel-b',
|
||||
model_name: 'model-b',
|
||||
quota: 80,
|
||||
token_used: 10,
|
||||
count: 20,
|
||||
},
|
||||
{
|
||||
user_id: 3,
|
||||
username: 'gamma',
|
||||
use_group: 'free',
|
||||
channel_id: 203,
|
||||
channel_name: 'channel-c',
|
||||
model_name: 'model-c',
|
||||
quota: 10,
|
||||
token_used: 2_000,
|
||||
count: 5,
|
||||
},
|
||||
]
|
||||
|
||||
describe('dashboard flow data', () => {
|
||||
test('builds normal user token-group-model flow', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 2), 'quota', {
|
||||
@@ -183,6 +220,113 @@ describe('dashboard flow data', () => {
|
||||
assert.notEqual(options.users[0].color, options.users[1].color)
|
||||
})
|
||||
|
||||
test('aggregates overflow nodes into per-column Other buckets', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 2,
|
||||
overflowMode: 'aggregate',
|
||||
otherNodeLabel: (kind) => `Other ${kind}`,
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
const otherUser = result.flow.nodes.find(
|
||||
(node) => node.id === 'user:__other__'
|
||||
)
|
||||
const otherFirstStepLink = result.flow.links.find(
|
||||
(link) =>
|
||||
link.source === 'user:__other__' && link.target === 'group:__other__'
|
||||
)
|
||||
const firstStepTotal = result.flow.links
|
||||
.filter((link) => link.source.startsWith('user:'))
|
||||
.reduce((sum, link) => sum + link.value, 0)
|
||||
|
||||
assert.equal(result.summary.quota, 190)
|
||||
assert.equal(firstStepTotal, 190)
|
||||
assert.equal(otherUser?.label, 'Other user')
|
||||
assert.equal(otherFirstStepLink?.value, 10)
|
||||
assert.equal(nodeIds.includes('user:3'), false)
|
||||
assert.equal(nodeIds.includes('group:free'), false)
|
||||
assert.equal(nodeIds.includes('model:model-c'), false)
|
||||
assert.equal(nodeIds.includes('channel:203'), false)
|
||||
assert.equal(nodeIds.includes('user:__other__'), true)
|
||||
assert.equal(nodeIds.includes('group:__other__'), true)
|
||||
assert.equal(nodeIds.includes('model:__other__'), true)
|
||||
assert.equal(nodeIds.includes('channel:__other__'), true)
|
||||
})
|
||||
|
||||
test('hides overflow paths when overflow mode is hide', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 2,
|
||||
overflowMode: 'hide',
|
||||
otherNodeLabel: (kind) => `Other ${kind}`,
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
const firstStepTotal = result.flow.links
|
||||
.filter((link) => link.source.startsWith('user:'))
|
||||
.reduce((sum, link) => sum + link.value, 0)
|
||||
|
||||
assert.equal(result.summary.quota, 190)
|
||||
assert.equal(firstStepTotal, 180)
|
||||
assert.equal(nodeIds.includes('user:3'), false)
|
||||
assert.equal(nodeIds.includes('user:__other__'), false)
|
||||
assert.equal(nodeIds.includes('model:__other__'), false)
|
||||
})
|
||||
|
||||
test('ranks top nodes using the selected flow metric', () => {
|
||||
const byQuota = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const byRequests = buildDashboardFlowData(topLimitRows, 'requests', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const byTokens = buildDashboardFlowData(topLimitRows, 'tokens', {
|
||||
role: 'admin',
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
|
||||
assert.equal(
|
||||
byQuota.flow.nodes.some((node) => node.id === 'user:1'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
byRequests.flow.nodes.some((node) => node.id === 'user:2'),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
byTokens.flow.nodes.some((node) => node.id === 'user:3'),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('applies top limits only to visible stages', () => {
|
||||
const result = buildDashboardFlowData(topLimitRows, 'quota', {
|
||||
role: 'admin',
|
||||
visibleStages: ['user', 'model'],
|
||||
topNodeLimit: 1,
|
||||
overflowMode: 'aggregate',
|
||||
})
|
||||
const nodeIds = result.flow.nodes.map((node) => node.id)
|
||||
|
||||
assert.equal(nodeIds.includes('user:1'), true)
|
||||
assert.equal(nodeIds.includes('user:__other__'), true)
|
||||
assert.equal(nodeIds.includes('model:model-a'), true)
|
||||
assert.equal(nodeIds.includes('model:__other__'), true)
|
||||
assert.equal(nodeIds.includes('group:__other__'), false)
|
||||
assert.equal(nodeIds.includes('channel:__other__'), false)
|
||||
assert.deepEqual(
|
||||
result.flow.links.map((link) => [link.source, link.target, link.value]),
|
||||
[
|
||||
['user:__other__', 'model:__other__', 90],
|
||||
['user:1', 'model:model-a', 100],
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('builds Sankey spec with quota token request tooltips', () => {
|
||||
const result = buildDashboardFlowData(rows.slice(0, 1), 'quota', {
|
||||
role: 'root',
|
||||
|
||||
+177
-33
@@ -24,11 +24,13 @@ import type {
|
||||
FlowFilterOptions,
|
||||
FlowMetric,
|
||||
FlowNodeKind,
|
||||
FlowOverflowMode,
|
||||
FlowQuotaDataItem,
|
||||
FlowRole,
|
||||
FlowSummary,
|
||||
ProcessedFlowData,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
import { getDashboardChartColors } from './charts'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -53,14 +55,32 @@ type FlowPathNode = {
|
||||
kind: FlowNodeKind
|
||||
}
|
||||
|
||||
type PreparedFlowPath = {
|
||||
path: FlowPathNode[]
|
||||
metrics: FlowMetrics
|
||||
}
|
||||
|
||||
type FlowNodeRank = {
|
||||
node: FlowPathNode
|
||||
value: number
|
||||
}
|
||||
|
||||
type FlowPathContext = {
|
||||
deletedTokenLabel?: (tokenId: number) => string
|
||||
}
|
||||
|
||||
type FlowGraphOptions = {
|
||||
topNodeLimit?: number
|
||||
overflowMode?: FlowOverflowMode
|
||||
otherNodeLabel?: (kind: FlowNodeKind) => string
|
||||
}
|
||||
|
||||
const EMPTY_FLOW_PATH_CONTEXT: FlowPathContext = {}
|
||||
|
||||
const DEFAULT_FLOW_ROLE: FlowRole = 'user'
|
||||
|
||||
const DEFAULT_FLOW_OVERFLOW_MODE: FlowOverflowMode = 'aggregate'
|
||||
|
||||
const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
|
||||
quota: 'Quota',
|
||||
tokens: 'Tokens',
|
||||
@@ -70,6 +90,24 @@ const DEFAULT_FLOW_SANKEY_LABELS: FlowSankeyLabels = {
|
||||
|
||||
const DEFAULT_FLOW_CHART_COLOR = '#1664FF'
|
||||
|
||||
const OTHER_FLOW_NODE_IDS: Record<FlowNodeKind, string> = {
|
||||
user: 'user:__other__',
|
||||
node: 'node:__other__',
|
||||
token: 'token:__other__',
|
||||
group: 'group:__other__',
|
||||
model: 'model:__other__',
|
||||
channel: 'channel:__other__',
|
||||
}
|
||||
|
||||
const DEFAULT_OTHER_FLOW_NODE_LABELS: Record<FlowNodeKind, string> = {
|
||||
user: 'Other users',
|
||||
node: 'Other nodes',
|
||||
token: 'Other tokens',
|
||||
group: 'Other groups',
|
||||
model: 'Other models',
|
||||
channel: 'Other channels',
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
@@ -107,13 +145,11 @@ function nodeNameNode(row: FlowQuotaDataItem): FlowPathNode {
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNode(
|
||||
row: FlowQuotaDataItem,
|
||||
ctx: FlowPathContext
|
||||
): FlowPathNode {
|
||||
function tokenNode(row: FlowQuotaDataItem, ctx: FlowPathContext): FlowPathNode {
|
||||
const tokenID = numberValue(row.token_id)
|
||||
return {
|
||||
id: tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
|
||||
id:
|
||||
tokenID > 0 ? `token:${tokenID}` : `token:${row.token_name || 'unknown'}`,
|
||||
label: row.token_name || deletedTokenLabel(tokenID, ctx),
|
||||
kind: 'token',
|
||||
}
|
||||
@@ -192,15 +228,12 @@ function resolveVisibleStages(
|
||||
return filtered.length >= MIN_FLOW_STAGES ? filtered : stages
|
||||
}
|
||||
|
||||
function flowPath(
|
||||
function flowPathForStages(
|
||||
row: FlowQuotaDataItem,
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[],
|
||||
stages: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
): FlowPathNode[] {
|
||||
return resolveVisibleStages(role, visibleStages).map((stage) =>
|
||||
NODE_BUILDERS[stage](row, ctx)
|
||||
)
|
||||
return stages.map((stage) => NODE_BUILDERS[stage](row, ctx))
|
||||
}
|
||||
|
||||
function colorAt(index: number, palette?: readonly string[]): string {
|
||||
@@ -252,18 +285,6 @@ function stableColorMap(
|
||||
return map
|
||||
}
|
||||
|
||||
function rootColorKeys(
|
||||
rows: FlowQuotaDataItem[],
|
||||
role: FlowRole,
|
||||
visibleStages?: FlowNodeKind[]
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
rows.map((row) => flowPath(row, role, visibleStages)[0]?.id ?? 'unknown')
|
||||
)
|
||||
).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function filterRows(
|
||||
rows: FlowQuotaDataItem[],
|
||||
options: FlowBuildOptions = {}
|
||||
@@ -392,26 +413,137 @@ function buildSummary(rows: FlowQuotaDataItem[]): FlowSummary {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTopNodeLimit(limit?: number): number | undefined {
|
||||
if (limit === undefined) return undefined
|
||||
const parsed = Math.floor(limit)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function otherFlowNode(
|
||||
kind: FlowNodeKind,
|
||||
labeler?: (kind: FlowNodeKind) => string
|
||||
): FlowPathNode {
|
||||
return {
|
||||
id: OTHER_FLOW_NODE_IDS[kind],
|
||||
label: labeler?.(kind) ?? DEFAULT_OTHER_FLOW_NODE_LABELS[kind],
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
function buildTopNodeSets(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric,
|
||||
stages: FlowNodeKind[],
|
||||
limit: number | undefined,
|
||||
ctx: FlowPathContext
|
||||
): Map<FlowNodeKind, Set<string>> | undefined {
|
||||
if (!limit) return undefined
|
||||
|
||||
const totals = new Map<FlowNodeKind, Map<string, FlowNodeRank>>()
|
||||
for (const stage of stages) {
|
||||
totals.set(stage, new Map())
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
const metrics = rowMetrics(row)
|
||||
const value = metricValue(metrics, metric)
|
||||
const path = flowPathForStages(row, stages, ctx)
|
||||
for (const node of path) {
|
||||
const stageTotals = totals.get(node.kind)
|
||||
if (!stageTotals) continue
|
||||
const current = stageTotals.get(node.id) ?? { node, value: 0 }
|
||||
current.value += value
|
||||
stageTotals.set(node.id, current)
|
||||
}
|
||||
}
|
||||
|
||||
const topSets = new Map<FlowNodeKind, Set<string>>()
|
||||
for (const [kind, stageTotals] of totals) {
|
||||
const topIds = Array.from(stageTotals.values())
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.value - a.value ||
|
||||
a.node.label.localeCompare(b.node.label) ||
|
||||
a.node.id.localeCompare(b.node.id)
|
||||
)
|
||||
.slice(0, limit)
|
||||
.map((rank) => rank.node.id)
|
||||
topSets.set(kind, new Set(topIds))
|
||||
}
|
||||
|
||||
return topSets
|
||||
}
|
||||
|
||||
function isTopFlowNode(
|
||||
node: FlowPathNode,
|
||||
topNodeSets?: Map<FlowNodeKind, Set<string>>
|
||||
): boolean {
|
||||
const topNodes = topNodeSets?.get(node.kind)
|
||||
return !topNodes || topNodes.has(node.id)
|
||||
}
|
||||
|
||||
function applyTopNodeLimit(
|
||||
path: FlowPathNode[],
|
||||
topNodeSets: Map<FlowNodeKind, Set<string>> | undefined,
|
||||
mode: FlowOverflowMode,
|
||||
labeler?: (kind: FlowNodeKind) => string
|
||||
): FlowPathNode[] | undefined {
|
||||
if (!topNodeSets) return path
|
||||
const containsOverflowNode = path.some(
|
||||
(node) => !isTopFlowNode(node, topNodeSets)
|
||||
)
|
||||
if (!containsOverflowNode) return path
|
||||
if (mode === 'hide') return undefined
|
||||
return path.map((node) =>
|
||||
isTopFlowNode(node, topNodeSets) ? node : otherFlowNode(node.kind, labeler)
|
||||
)
|
||||
}
|
||||
|
||||
function buildFlowGraph(
|
||||
rows: FlowQuotaDataItem[],
|
||||
metric: FlowMetric,
|
||||
role: FlowRole,
|
||||
palette?: readonly string[],
|
||||
visibleStages?: FlowNodeKind[],
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT
|
||||
ctx: FlowPathContext = EMPTY_FLOW_PATH_CONTEXT,
|
||||
options: FlowGraphOptions = {}
|
||||
): DashboardFlowGraph {
|
||||
const stages = resolveVisibleStages(role, visibleStages)
|
||||
const topNodeSets = buildTopNodeSets(
|
||||
rows,
|
||||
metric,
|
||||
stages,
|
||||
normalizeTopNodeLimit(options.topNodeLimit),
|
||||
ctx
|
||||
)
|
||||
const overflowMode = options.overflowMode ?? DEFAULT_FLOW_OVERFLOW_MODE
|
||||
const preparedPaths: PreparedFlowPath[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const path = applyTopNodeLimit(
|
||||
flowPathForStages(row, stages, ctx),
|
||||
topNodeSets,
|
||||
overflowMode,
|
||||
options.otherNodeLabel
|
||||
)
|
||||
if (!path) continue
|
||||
preparedPaths.push({ path, metrics: rowMetrics(row) })
|
||||
}
|
||||
|
||||
const nodes = new Map<string, DashboardFlowNode>()
|
||||
const links = new Map<string, DashboardFlowLink>()
|
||||
const colors = stableColorMap(
|
||||
rootColorKeys(rows, role, visibleStages),
|
||||
preparedPaths
|
||||
.map((prepared) => prepared.path[0]?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
palette
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
const path = flowPath(row, role, visibleStages, ctx)
|
||||
for (const prepared of preparedPaths) {
|
||||
const { path, metrics } = prepared
|
||||
const root = path[0]
|
||||
if (!root) continue
|
||||
const metrics = rowMetrics(row)
|
||||
const color = colors.get(root.id) ?? colorAt(0, palette)
|
||||
|
||||
for (const node of path) {
|
||||
@@ -430,8 +562,8 @@ function buildFlowGraph(
|
||||
a.source.localeCompare(b.source) || a.target.localeCompare(b.target)
|
||||
)
|
||||
const firstStepSources = new Set(
|
||||
rows
|
||||
.map((row) => flowPath(row, role, visibleStages)[0]?.id)
|
||||
preparedPaths
|
||||
.map((prepared) => prepared.path[0]?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
)
|
||||
const total = flowLinks
|
||||
@@ -512,9 +644,21 @@ export function buildDashboardFlowData(
|
||||
|
||||
return {
|
||||
summary: buildSummary(filteredRows),
|
||||
flow: buildFlowGraph(filteredRows, metric, role, palette, options.visibleStages, {
|
||||
deletedTokenLabel: options.deletedTokenLabel,
|
||||
}),
|
||||
flow: buildFlowGraph(
|
||||
filteredRows,
|
||||
metric,
|
||||
role,
|
||||
palette,
|
||||
options.visibleStages,
|
||||
{
|
||||
deletedTokenLabel: options.deletedTokenLabel,
|
||||
},
|
||||
{
|
||||
topNodeLimit: options.topNodeLimit,
|
||||
overflowMode: options.overflowMode,
|
||||
otherNodeLabel: options.otherNodeLabel,
|
||||
}
|
||||
),
|
||||
filterOptions: buildFlowFilterOptions(rows, metric, palette),
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -50,6 +50,8 @@ export interface FlowQuotaDataItem {
|
||||
|
||||
export type FlowMetric = 'quota' | 'tokens' | 'requests'
|
||||
|
||||
export type FlowOverflowMode = 'aggregate' | 'hide'
|
||||
|
||||
export type FlowRole = 'user' | 'admin' | 'root'
|
||||
|
||||
export type FlowNodeKind =
|
||||
@@ -65,9 +67,12 @@ export interface FlowBuildOptions {
|
||||
selectedUsers?: string[]
|
||||
colorPalette?: readonly string[]
|
||||
visibleStages?: FlowNodeKind[]
|
||||
topNodeLimit?: number
|
||||
overflowMode?: FlowOverflowMode
|
||||
// Resolves the label for a token whose record no longer exists (deleted).
|
||||
// Lets the caller inject a localized string such as "Deleted (123)".
|
||||
deletedTokenLabel?: (tokenId: number) => string
|
||||
otherNodeLabel?: (kind: FlowNodeKind) => string
|
||||
}
|
||||
|
||||
export interface DashboardFlowNode {
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "by",
|
||||
"By category": "By category",
|
||||
"By model author": "By model author",
|
||||
"By quota": "By quota",
|
||||
"By requests": "By requests",
|
||||
"By tokens": "By tokens",
|
||||
"Cache": "Cache",
|
||||
"Cache create (1h) price": "Cache create (1h) price",
|
||||
"Cache create price": "Cache create price",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "Choose between system preference, light mode, or dark mode",
|
||||
"Choose channels to sync upstream ratio configurations from": "Choose channels to sync upstream ratio configurations from",
|
||||
"Choose Group": "Choose Group",
|
||||
"Choose how flow widths are calculated.": "Choose how flow widths are calculated.",
|
||||
"Choose how quota values are shown to users": "Choose how quota values are shown to users",
|
||||
"Choose how the platform will operate": "Choose how the platform will operate",
|
||||
"Choose how to filter domains": "Choose how to filter domains",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "Disk Hits",
|
||||
"Disk Threshold (%)": "Disk Threshold (%)",
|
||||
"Display in Currency": "Display in Currency",
|
||||
"Display limit": "Display limit",
|
||||
"Display Mode": "Display Mode",
|
||||
"Display name": "Display name",
|
||||
"Display Name": "Display Name",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "Floating",
|
||||
"Flow": "Flow",
|
||||
"Flow Filters": "Flow Filters",
|
||||
"Flow width metric": "Flow width metric",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead extension not detected. Please ensure it is installed and active.",
|
||||
"Flush interval (minutes)": "Flush interval (minutes)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "Follow the guided steps to prepare your workspace before the first login.",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "More than 999 days left",
|
||||
"More...": "More...",
|
||||
"Most-used models in the selected period and category": "Most-used models in the selected period and category",
|
||||
"Merge into Other": "Merge into Other",
|
||||
"Move": "Move",
|
||||
"Move a request header": "Move a request header",
|
||||
"Move affiliate rewards to your main balance": "Move affiliate rewards to your main balance",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Original Model",
|
||||
"Other": "Other",
|
||||
"Other channels": "Other channels",
|
||||
"Other groups": "Other groups",
|
||||
"Other models": "Other models",
|
||||
"Other nodes": "Other nodes",
|
||||
"Other tokens": "Other tokens",
|
||||
"Other users": "Other users",
|
||||
"Outage": "Outage",
|
||||
"Output": "Output",
|
||||
"Output aspect ratio": "Output aspect ratio",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "Output tokens",
|
||||
"Output Tokens": "Output Tokens",
|
||||
"Overage limited": "Overage limited",
|
||||
"Overflow items": "Overflow items",
|
||||
"overall": "overall",
|
||||
"Overnight range": "Overnight range",
|
||||
"override": "override",
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "par",
|
||||
"By category": "Par catégorie",
|
||||
"By model author": "Par auteur du modèle",
|
||||
"By quota": "Par quota",
|
||||
"By requests": "Par requêtes",
|
||||
"By tokens": "Par jetons",
|
||||
"Cache": "Cache",
|
||||
"Cache create (1h) price": "Prix de création du cache (1 h)",
|
||||
"Cache create price": "Prix de création du cache",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "Choisissez entre la préférence système, le mode clair ou le mode sombre",
|
||||
"Choose channels to sync upstream ratio configurations from": "Choisissez les canaux à partir desquels synchroniser les configurations de ratio amont",
|
||||
"Choose Group": "Choisir un groupe",
|
||||
"Choose how flow widths are calculated.": "Choisissez comment l'épaisseur des flux est calculée.",
|
||||
"Choose how quota values are shown to users": "Choisissez comment les valeurs de quota sont affichées aux utilisateurs",
|
||||
"Choose how the platform will operate": "Choisissez le mode de fonctionnement de la plateforme",
|
||||
"Choose how to filter domains": "Choisissez comment filtrer les domaines",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "Hits disque",
|
||||
"Disk Threshold (%)": "Seuil disque (%)",
|
||||
"Display in Currency": "Afficher en devise",
|
||||
"Display limit": "Limite d'affichage",
|
||||
"Display Mode": "Mode d'affichage",
|
||||
"Display name": "Nom d'affichage",
|
||||
"Display Name": "Nom d'affichage",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "Flottant",
|
||||
"Flow": "Flux",
|
||||
"Flow Filters": "Filtres de flux",
|
||||
"Flow width metric": "Indicateur de largeur du flux",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "Extension FluentRead non détectée. Veuillez vous assurer qu'elle est installée et activée.",
|
||||
"Flush interval (minutes)": "Intervalle d’écriture (minutes)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "Suivez les étapes guidées pour préparer votre espace de travail avant la première connexion.",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "Plus de 999 jours restants",
|
||||
"More...": "Plus...",
|
||||
"Most-used models in the selected period and category": "Modèles les plus utilisés dans la période et catégorie choisies",
|
||||
"Merge into Other": "Fusionner dans Autres",
|
||||
"Move": "Déplacer",
|
||||
"Move a request header": "Déplacer un en-tête de requête",
|
||||
"Move affiliate rewards to your main balance": "Transférer les récompenses d'affiliation vers votre solde principal",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Modèle Original",
|
||||
"Other": "Autre",
|
||||
"Other channels": "Autres canaux",
|
||||
"Other groups": "Autres groupes",
|
||||
"Other models": "Autres modèles",
|
||||
"Other nodes": "Autres nœuds",
|
||||
"Other tokens": "Autres jetons",
|
||||
"Other users": "Autres utilisateurs",
|
||||
"Outage": "Interruption",
|
||||
"Output": "Sortie",
|
||||
"Output aspect ratio": "Format d'image de sortie",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "Jetons de sortie",
|
||||
"Output Tokens": "Tokens de sortie",
|
||||
"Overage limited": "Dépassement limité",
|
||||
"Overflow items": "Éléments excédentaires",
|
||||
"overall": "global",
|
||||
"Overnight range": "Plage nocturne",
|
||||
"override": "remplacer",
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "によって",
|
||||
"By category": "カテゴリ別",
|
||||
"By model author": "モデル提供者別",
|
||||
"By quota": "クォータ別",
|
||||
"By requests": "リクエスト数別",
|
||||
"By tokens": "トークン数別",
|
||||
"Cache": "キャッシュ",
|
||||
"Cache create (1h) price": "キャッシュ作成価格 (1時間)",
|
||||
"Cache create price": "キャッシュ作成価格",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "システム設定、ライトモード、またはダークモードから選択します",
|
||||
"Choose channels to sync upstream ratio configurations from": "アップストリームの比率設定を同期するチャネルを選択してください",
|
||||
"Choose Group": "グループを選択",
|
||||
"Choose how flow widths are calculated.": "フローの線幅をどの指標で計算するかを選択します。",
|
||||
"Choose how quota values are shown to users": "クォータ値がユーザーにどのように表示されるかを選択してください",
|
||||
"Choose how the platform will operate": "プラットフォームの運用方法を選択",
|
||||
"Choose how to filter domains": "ドメインをフィルタリングする方法を選択してください",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "ディスクヒット",
|
||||
"Disk Threshold (%)": "ディスク閾値 (%)",
|
||||
"Display in Currency": "通貨で表示",
|
||||
"Display limit": "表示数",
|
||||
"Display Mode": "表示モード",
|
||||
"Display name": "表示名",
|
||||
"Display Name": "表示名",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "フローティング",
|
||||
"Flow": "フロー",
|
||||
"Flow Filters": "フローフィルター",
|
||||
"Flow width metric": "線幅の指標",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "FluentRead 拡張機能が検出されませんでした。インストールされていて有効になっていることを確認してください。",
|
||||
"Flush interval (minutes)": "書き込み間隔(分)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "初回ログイン前に、ガイド付きの手順に従ってワークスペースを準備してください。",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "999日以上",
|
||||
"More...": "その他...",
|
||||
"Most-used models in the selected period and category": "選択した期間とカテゴリで最も使われているモデル",
|
||||
"Merge into Other": "その他にまとめる",
|
||||
"Move": "移動",
|
||||
"Move a request header": "リクエストヘッダーを移動",
|
||||
"Move affiliate rewards to your main balance": "アフィリエイト報酬をメイン残高に移動する",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "org-...",
|
||||
"Original Model": "オリジナルモデル",
|
||||
"Other": "その他",
|
||||
"Other channels": "その他のチャネル",
|
||||
"Other groups": "その他のグループ",
|
||||
"Other models": "その他のモデル",
|
||||
"Other nodes": "その他のノード",
|
||||
"Other tokens": "その他のトークン",
|
||||
"Other users": "その他のユーザー",
|
||||
"Outage": "ダウンタイム",
|
||||
"Output": "出力",
|
||||
"Output aspect ratio": "出力アスペクト比",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "出力トークン",
|
||||
"Output Tokens": "出力トークン",
|
||||
"Overage limited": "超過利用制限中",
|
||||
"Overflow items": "超過項目",
|
||||
"overall": "全体",
|
||||
"Overnight range": "日跨ぎ範囲",
|
||||
"override": "上書き",
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "от",
|
||||
"By category": "По категориям",
|
||||
"By model author": "По автору модели",
|
||||
"By quota": "По квоте",
|
||||
"By requests": "По запросам",
|
||||
"By tokens": "По токенам",
|
||||
"Cache": "Кэш",
|
||||
"Cache create (1h) price": "Цена создания кэша (1 ч)",
|
||||
"Cache create price": "Цена создания кэша",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "Выберите между системными настройками, светлым режимом или темным режимом",
|
||||
"Choose channels to sync upstream ratio configurations from": "Выберите каналы для синхронизации конфигураций соотношений из вышестоящих источников",
|
||||
"Choose Group": "Выбрать группу",
|
||||
"Choose how flow widths are calculated.": "Выберите, как рассчитывается ширина потоков.",
|
||||
"Choose how quota values are shown to users": "Выберите, как значения квоты отображаются пользователям",
|
||||
"Choose how the platform will operate": "Выберите режим работы платформы",
|
||||
"Choose how to filter domains": "Выберите, как фильтровать домены",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "Попаданий диска",
|
||||
"Disk Threshold (%)": "Порог диска (%)",
|
||||
"Display in Currency": "Отображать в валюте",
|
||||
"Display limit": "Лимит отображения",
|
||||
"Display Mode": "Режим отображения",
|
||||
"Display name": "Отображаемое имя",
|
||||
"Display Name": "Отображаемое имя",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "Плавающая",
|
||||
"Flow": "Поток",
|
||||
"Flow Filters": "Фильтры потока",
|
||||
"Flow width metric": "Метрика ширины потока",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "Расширение FluentRead не обнаружено. Убедитесь, что оно установлено и активно.",
|
||||
"Flush interval (minutes)": "Интервал записи (минуты)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "Следуйте пошаговым инструкциям, чтобы подготовить рабочее пространство перед первым входом.",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "Более 999 дней",
|
||||
"More...": "Подробнее...",
|
||||
"Most-used models in the selected period and category": "Самые используемые модели в выбранном периоде и категории",
|
||||
"Merge into Other": "Объединить в «Другое»",
|
||||
"Move": "Переместить",
|
||||
"Move a request header": "Переместить заголовок запроса",
|
||||
"Move affiliate rewards to your main balance": "Перевести партнерские вознаграждения на основной баланс",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "орг-...",
|
||||
"Original Model": "Оригинальная модель",
|
||||
"Other": "Другое",
|
||||
"Other channels": "Другие каналы",
|
||||
"Other groups": "Другие группы",
|
||||
"Other models": "Другие модели",
|
||||
"Other nodes": "Другие узлы",
|
||||
"Other tokens": "Другие токены",
|
||||
"Other users": "Другие пользователи",
|
||||
"Outage": "Простой",
|
||||
"Output": "Вывод",
|
||||
"Output aspect ratio": "Соотношение сторон",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "Выходные токены",
|
||||
"Output Tokens": "Выходные токены",
|
||||
"Overage limited": "Ограничение перерасхода",
|
||||
"Overflow items": "Элементы сверх лимита",
|
||||
"overall": "всего",
|
||||
"Overnight range": "Диапазон через полночь",
|
||||
"override": "переопределить",
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "by",
|
||||
"By category": "Theo danh mục",
|
||||
"By model author": "Theo nhà phát triển mô hình",
|
||||
"By quota": "Theo hạn ngạch",
|
||||
"By requests": "Theo số yêu cầu",
|
||||
"By tokens": "Theo số token",
|
||||
"Cache": "Bộ nhớ đệm",
|
||||
"Cache create (1h) price": "Giá tạo cache (1 giờ)",
|
||||
"Cache create price": "Giá tạo cache",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "Lựa chọn giữa tùy chọn hệ thống, chế độ sáng hoặc chế độ tối",
|
||||
"Choose channels to sync upstream ratio configurations from": "Chọn các kênh để đồng bộ cấu hình tỷ lệ đường lên từ",
|
||||
"Choose Group": "Chọn Nhóm",
|
||||
"Choose how flow widths are calculated.": "Chọn cách tính độ rộng của các luồng.",
|
||||
"Choose how quota values are shown to users": "Chọn cách hiển thị giá trị hạn ngạch cho người dùng",
|
||||
"Choose how the platform will operate": "Chọn cách nền tảng sẽ hoạt động",
|
||||
"Choose how to filter domains": "Chọn cách lọc tên miền",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "Lượt truy cập đĩa",
|
||||
"Disk Threshold (%)": "Ngưỡng đĩa (%)",
|
||||
"Display in Currency": "Hiển thị tiền tệ",
|
||||
"Display limit": "Giới hạn hiển thị",
|
||||
"Display Mode": "Chế độ hiển thị",
|
||||
"Display name": "Tên hiển thị",
|
||||
"Display Name": "Tên hiển thị",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "Nổi",
|
||||
"Flow": "Luồng",
|
||||
"Flow Filters": "Bộ lọc luồng",
|
||||
"Flow width metric": "Chỉ số độ rộng luồng",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "Không phát hiện tiện ích mở rộng FluentRead. Vui lòng đảm bảo nó đã được cài đặt và kích hoạt.",
|
||||
"Flush interval (minutes)": "Khoảng ghi xuống DB (phút)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "Thực hiện theo các bước hướng dẫn để chuẩn bị không gian làm việc của bạn trước lần đăng nhập đầu tiên.",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "Hơn 999 ngày",
|
||||
"More...": "Thêm...",
|
||||
"Most-used models in the selected period and category": "Mô hình được dùng nhiều nhất trong khoảng thời gian và danh mục đã chọn",
|
||||
"Merge into Other": "Gộp vào Khác",
|
||||
"Move": "Di chuyển",
|
||||
"Move a request header": "Di chuyển header yêu cầu",
|
||||
"Move affiliate rewards to your main balance": "Chuyển phần thưởng liên kết vào số dư chính của bạn",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "org-...",
|
||||
"Original Model": "Nguyên mẫu",
|
||||
"Other": "Khác",
|
||||
"Other channels": "Kênh khác",
|
||||
"Other groups": "Nhóm khác",
|
||||
"Other models": "Mô hình khác",
|
||||
"Other nodes": "Nút khác",
|
||||
"Other tokens": "Token khác",
|
||||
"Other users": "Người dùng khác",
|
||||
"Outage": "Gián đoạn",
|
||||
"Output": "Đầu ra",
|
||||
"Output aspect ratio": "Tỉ lệ khung hình",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "Token đầu ra",
|
||||
"Output Tokens": "Token đầu ra",
|
||||
"Overage limited": "Đã giới hạn vượt mức",
|
||||
"Overflow items": "Mục vượt giới hạn",
|
||||
"overall": "tổng",
|
||||
"Overnight range": "Khoảng qua nửa đêm",
|
||||
"override": "ghi đè",
|
||||
|
||||
Vendored
+14
@@ -625,6 +625,9 @@
|
||||
"by": "由",
|
||||
"By category": "按行业",
|
||||
"By model author": "按模型厂商",
|
||||
"By quota": "按额度",
|
||||
"By requests": "按请求数",
|
||||
"By tokens": "按 Token",
|
||||
"Cache": "缓存",
|
||||
"Cache create (1h) price": "1 小时缓存写入价格",
|
||||
"Cache create price": "缓存写入价格",
|
||||
@@ -746,6 +749,7 @@
|
||||
"Choose between system preference, light mode, or dark mode": "选择系统偏好、浅色模式或深色模式",
|
||||
"Choose channels to sync upstream ratio configurations from": "选择要同步上游比例配置的渠道",
|
||||
"Choose Group": "选择分组",
|
||||
"Choose how flow widths are calculated.": "选择分流图连线宽度的计算方式。",
|
||||
"Choose how quota values are shown to users": "选择如何向用户展示配额值",
|
||||
"Choose how the platform will operate": "选择平台的运行模式",
|
||||
"Choose how to filter domains": "选择如何过滤域名",
|
||||
@@ -1315,6 +1319,7 @@
|
||||
"Disk Hits": "磁盘命中",
|
||||
"Disk Threshold (%)": "磁盘阈值 (%)",
|
||||
"Display in Currency": "以货币显示",
|
||||
"Display limit": "显示数量",
|
||||
"Display Mode": "显示模式",
|
||||
"Display name": "显示名称",
|
||||
"Display Name": "显示名称",
|
||||
@@ -1868,6 +1873,7 @@
|
||||
"Floating": "浮动",
|
||||
"Flow": "分流",
|
||||
"Flow Filters": "分流筛选",
|
||||
"Flow width metric": "连线粗细",
|
||||
"FluentRead extension not detected. Please ensure it is installed and active.": "未检测到 FluentRead 扩展。请确保已安装并激活。",
|
||||
"Flush interval (minutes)": "刷库间隔(分钟)",
|
||||
"Follow the guided steps to prepare your workspace before the first login.": "请按照引导步骤在首次登录前准备您的工作区。",
|
||||
@@ -2551,6 +2557,7 @@
|
||||
"More than 999 days left": "剩余超过 999 天",
|
||||
"More...": "更多...",
|
||||
"Most-used models in the selected period and category": "所选时间范围与分类下使用率最高的模型",
|
||||
"Merge into Other": "合并为其他",
|
||||
"Move": "移动",
|
||||
"Move a request header": "移动请求头",
|
||||
"Move affiliate rewards to your main balance": "将推广奖励转移到您的主余额",
|
||||
@@ -2936,6 +2943,12 @@
|
||||
"org-...": "org-...",
|
||||
"Original Model": "原始模型",
|
||||
"Other": "其他",
|
||||
"Other channels": "其他渠道",
|
||||
"Other groups": "其他分组",
|
||||
"Other models": "其他模型",
|
||||
"Other nodes": "其他节点",
|
||||
"Other tokens": "其他令牌",
|
||||
"Other users": "其他用户",
|
||||
"Outage": "中断",
|
||||
"Output": "输出",
|
||||
"Output aspect ratio": "输出宽高比",
|
||||
@@ -2945,6 +2958,7 @@
|
||||
"Output tokens": "输出 token",
|
||||
"Output Tokens": "输出 Token",
|
||||
"Overage limited": "超额受限",
|
||||
"Overflow items": "超出项",
|
||||
"overall": "总体",
|
||||
"Overnight range": "跨日范围",
|
||||
"override": "覆盖",
|
||||
|
||||
Reference in New Issue
Block a user