diff --git a/web/default/src/features/dashboard/components/flow/flow-charts.tsx b/web/default/src/features/dashboard/components/flow/flow-charts.tsx index ea320a7e..ad263aee 100644 --- a/web/default/src/features/dashboard/components/flow/flow-charts.tsx +++ b/web/default/src/features/dashboard/components/flow/flow-charts.tsx @@ -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 ( -
-
- {items.map((item) => { - const Icon = item.icon - return ( -
-
- -
- {t(item.title)} -
-
- {props.loading ? ( -
- - -
- ) : ( -
- {item.value} -
- )} -
- ) - })} -
-
- ) +const FLOW_OTHER_NODE_LABEL_KEYS: Record = { + 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('quota') + const [topNodeLimit, setTopNodeLimit] = useState(DEFAULT_FLOW_TOP_NODE_LIMIT) + const [overflowMode, setOverflowMode] = + useState('aggregate') const [selectedUsers, setSelectedUsers] = useState([]) const [hiddenStages, setHiddenStages] = useState([]) @@ -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 (
- - -
-
- setMetric(value as FlowMetric)} - className='shrink-0' - > - - {FLOW_METRIC_OPTIONS.map((option) => ( - - {t(option.labelKey)} - - ))} - - -
- {isAdmin && ( -
- - compactFlowSelectionLabel(values.length) - } - /> +
+
+
+
+ + {t('Flow width metric')} + + + + + } + > + + + + {t('Choose how flow widths are calculated.')} + + + +
+ setMetric(value as FlowMetric)} + className='shrink-0' + > + + {FLOW_METRIC_OPTIONS.map((option) => { + const Icon = option.icon + return ( + + + ) + })} + +
- )} - {isLoading && ( - - )} + +
+ + {t('Display limit')} + + setTopNodeLimit(Number(value))} + className='shrink-0' + > + + {FLOW_TOP_LIMIT_OPTIONS.map((limit) => ( + + {t('Top {{count}}', { count: limit })} + + ))} + + +
+ +
+ + {t('Overflow items')} + + + setOverflowMode(value as FlowOverflowMode) + } + className='shrink-0' + > + + {FLOW_OVERFLOW_MODE_OPTIONS.map((option) => ( + + {t(option.labelKey)} + + ))} + + +
+
+ +
+ {isAdmin && ( +
+ + compactFlowSelectionLabel(values.length) + } + /> +
+ )} + {isLoading && ( + + )} +
diff --git a/web/default/src/features/dashboard/lib/flow.test.ts b/web/default/src/features/dashboard/lib/flow.test.ts index 0a916cdb..b123b933 100644 --- a/web/default/src/features/dashboard/lib/flow.test.ts +++ b/web/default/src/features/dashboard/lib/flow.test.ts @@ -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', diff --git a/web/default/src/features/dashboard/lib/flow.ts b/web/default/src/features/dashboard/lib/flow.ts index 61441d02..4910d6a2 100644 --- a/web/default/src/features/dashboard/lib/flow.ts +++ b/web/default/src/features/dashboard/lib/flow.ts @@ -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 = { + 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 = { + 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> | undefined { + if (!limit) return undefined + + const totals = new Map>() + 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>() + 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> +): boolean { + const topNodes = topNodeSets?.get(node.kind) + return !topNodes || topNodes.has(node.id) +} + +function applyTopNodeLimit( + path: FlowPathNode[], + topNodeSets: Map> | 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() const links = new Map() 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), } } diff --git a/web/default/src/features/dashboard/types.ts b/web/default/src/features/dashboard/types.ts index 80378e25..4c9b37f6 100644 --- a/web/default/src/features/dashboard/types.ts +++ b/web/default/src/features/dashboard/types.ts @@ -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 { diff --git a/web/default/src/i18n/locales/en.json b/web/default/src/i18n/locales/en.json index a6b27545..ce319146 100644 --- a/web/default/src/i18n/locales/en.json +++ b/web/default/src/i18n/locales/en.json @@ -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", diff --git a/web/default/src/i18n/locales/fr.json b/web/default/src/i18n/locales/fr.json index 937f1bb8..f6e52a9e 100644 --- a/web/default/src/i18n/locales/fr.json +++ b/web/default/src/i18n/locales/fr.json @@ -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", diff --git a/web/default/src/i18n/locales/ja.json b/web/default/src/i18n/locales/ja.json index 49bc1ac8..ae52e33f 100644 --- a/web/default/src/i18n/locales/ja.json +++ b/web/default/src/i18n/locales/ja.json @@ -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": "上書き", diff --git a/web/default/src/i18n/locales/ru.json b/web/default/src/i18n/locales/ru.json index bed7c2ed..2421dd3d 100644 --- a/web/default/src/i18n/locales/ru.json +++ b/web/default/src/i18n/locales/ru.json @@ -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": "переопределить", diff --git a/web/default/src/i18n/locales/vi.json b/web/default/src/i18n/locales/vi.json index 7cba0abf..6891e5ad 100644 --- a/web/default/src/i18n/locales/vi.json +++ b/web/default/src/i18n/locales/vi.json @@ -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 đè", diff --git a/web/default/src/i18n/locales/zh.json b/web/default/src/i18n/locales/zh.json index 31c36ca5..0bfc9adf 100644 --- a/web/default/src/i18n/locales/zh.json +++ b/web/default/src/i18n/locales/zh.json @@ -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": "覆盖",