mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-03 04:43:01 +00:00
merge: adopt main functional additions onto refactor/ui design system
Merge origin/main while keeping this branch as the source of truth for UI/UX. The design-system revert (337169e0a) and StatusBadge padding revert (1b1b23d1d) from main were absorbed without applying them; only functional additions were ported and re-styled with our primitives: - usage-logs: Stream/Timing column split with new TimingMetricsCell and StreamTpsCell (first-token latency + duration readout, t/s line), admin All/Only Mine view scope (useLogsViewScope), and a mobile filter-panel collapse toggle in DataTableFilterPanel - keys: relative timestamps with absolute-time tooltip and stale-access highlight via ApiKeyTimestampCell (auto-refreshing now ticker), and copy button that resolves the real key on demand - pricing: ModelBillingModeBadge distinguishing dynamic pricing from token/per-request billing in the Type column - i18n: First token translations for all locales Rejected from main: icon-badge tones, overview accent theme colors, bespoke mobile lists (redemptions/keys), and all other styling that conflicts with the refactor/ui design language.
This commit is contained in:
@@ -94,6 +94,7 @@ export function DataTableFilterPanel<TData>(
|
||||
const { t } = useTranslation()
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const [mobilePanelCollapsed, setMobilePanelCollapsed] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const hasAdvancedFilters = props.advancedFilters != null
|
||||
@@ -183,11 +184,36 @@ export function DataTableFilterPanel<TData>(
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='grid min-w-0 gap-2'>{props.mobilePinnedFilters}</div>
|
||||
{!mobilePanelCollapsed && (
|
||||
<div className='grid min-w-0 gap-2'>{props.mobilePinnedFilters}</div>
|
||||
)}
|
||||
|
||||
<div className='mt-2 flex min-w-0 flex-col gap-2'>
|
||||
{props.stats}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-2',
|
||||
!mobilePanelCollapsed && 'mt-2'
|
||||
)}
|
||||
>
|
||||
{!mobilePanelCollapsed && props.stats}
|
||||
<div className='flex flex-wrap items-center justify-end gap-1.5'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() =>
|
||||
setMobilePanelCollapsed((collapsed) => !collapsed)
|
||||
}
|
||||
aria-expanded={!mobilePanelCollapsed}
|
||||
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
|
||||
className='text-muted-foreground hover:text-foreground mr-auto'
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
!mobilePanelCollapsed && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
{props.actionStart}
|
||||
{hasMobileFilters && (
|
||||
<DrawerTrigger
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ApiKeyTimestampCellProps {
|
||||
timestamp: number
|
||||
now: number
|
||||
locale?: string
|
||||
justNowLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ApiKeyTimestampCell(props: ApiKeyTimestampCellProps) {
|
||||
if (!props.timestamp || props.timestamp === -1) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
const timestampMs = props.timestamp * 1000
|
||||
const isJustNow = timestampMs <= props.now && props.now - timestampMs < 60_000
|
||||
const relativeTime = isJustNow
|
||||
? props.justNowLabel
|
||||
: formatTimestampRelative(props.timestamp, 'seconds', props.locale)
|
||||
const absoluteTime = formatTimestampToDate(props.timestamp)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<time
|
||||
dateTime={new Date(timestampMs).toISOString()}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'block truncate font-mono text-xs tabular-nums',
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{relativeTime}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className='font-mono tabular-nums'>{absoluteTime}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
+16
-18
@@ -19,7 +19,6 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Check, Copy, Loader2 } from 'lucide-react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { BadgeCell } from '@/components/data-table'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
@@ -79,17 +78,22 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
)
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const realKey = resolvedFullKey
|
||||
if (!realKey) {
|
||||
void resolveRealKey(apiKey.id)
|
||||
toast.info(t('API key is loading, please try again in a moment'))
|
||||
return
|
||||
}
|
||||
if (realKey) {
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) markKeyCopied(apiKey.id)
|
||||
}
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied, t])
|
||||
const realKey = resolvedFullKey || (await resolveRealKey(apiKey.id))
|
||||
if (!realKey) return
|
||||
|
||||
const ok = await copyToClipboard(realKey)
|
||||
if (ok) markKeyCopied(apiKey.id)
|
||||
}, [resolvedFullKey, resolveRealKey, apiKey.id, markKeyCopied])
|
||||
|
||||
let copyIcon = <Copy className='size-3.5' />
|
||||
let copyTooltip = t('Copy API key')
|
||||
if (isLoading) {
|
||||
copyIcon = <Loader2 className='size-3.5 animate-spin' />
|
||||
copyTooltip = t('Loading...')
|
||||
} else if (isCopied) {
|
||||
copyIcon = <Check className='size-3.5 text-green-600' />
|
||||
copyTooltip = t('Copied!')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 items-center'>
|
||||
@@ -138,12 +142,6 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
size='icon-sm'
|
||||
className='shrink-0'
|
||||
onClick={handleCopy}
|
||||
onFocus={() => {
|
||||
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
}
|
||||
|
||||
+30
-17
@@ -32,11 +32,13 @@ import {
|
||||
import { useGroupRatios } from '@/hooks/use-group-ratios'
|
||||
import { toIntlLocale } from '@/i18n/languages'
|
||||
import { formatQuotaWithCurrency } from '@/lib/currency'
|
||||
import { formatQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import dayjs from '@/lib/dayjs'
|
||||
import { formatQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { API_KEY_STATUSES } from '../constants'
|
||||
import type { ApiKey } from '../types'
|
||||
import { ApiKeyTimestampCell } from './api-key-timestamp-cell'
|
||||
import {
|
||||
ApiKeyCell,
|
||||
ModelLimitsCell,
|
||||
@@ -58,10 +60,12 @@ function getQuotaProgressColor(percentage: number): string {
|
||||
return '[&_[data-slot=progress-indicator]]:bg-success'
|
||||
}
|
||||
|
||||
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
export function useApiKeysColumns(now: number): ColumnDef<ApiKey>[] {
|
||||
const { t, i18n } = useTranslation()
|
||||
const groupRatios = useGroupRatios()
|
||||
const locale = toIntlLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const justNowLabel = t('Just now')
|
||||
const staleAccessThreshold = dayjs(now).subtract(3, 'month').valueOf()
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
@@ -281,9 +285,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
accessorKey: 'created_time',
|
||||
header: t('Created'),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground block truncate text-xs tabular-nums'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</span>
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={row.getValue('created_time')}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className='text-muted-foreground'
|
||||
/>
|
||||
),
|
||||
size: 180,
|
||||
meta: {
|
||||
@@ -297,13 +305,17 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
header: t('Last Used'),
|
||||
cell: ({ row }) => {
|
||||
const accessedTime = row.getValue('accessed_time') as number
|
||||
if (!accessedTime) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
const isStale =
|
||||
accessedTime > 0 && accessedTime * 1000 < staleAccessThreshold
|
||||
|
||||
return (
|
||||
<span className='text-muted-foreground block truncate text-xs tabular-nums'>
|
||||
{formatTimestampToDate(accessedTime)}
|
||||
</span>
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={accessedTime}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className={isStale ? 'text-warning' : 'text-muted-foreground'}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
@@ -321,16 +333,17 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
if (expiredTime === -1) {
|
||||
return <StatusBadge variant='neutral'>{t('Never')}</StatusBadge>
|
||||
}
|
||||
const isExpired = expiredTime * 1000 < Date.now()
|
||||
const isExpired = expiredTime * 1000 < now
|
||||
return (
|
||||
<span
|
||||
<ApiKeyTimestampCell
|
||||
timestamp={expiredTime}
|
||||
now={now}
|
||||
locale={locale}
|
||||
justNowLabel={justNowLabel}
|
||||
className={cn(
|
||||
'block truncate text-xs tabular-nums',
|
||||
isExpired ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{formatTimestampToDate(expiredTime)}
|
||||
</span>
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
|
||||
+11
-1
@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -54,7 +55,16 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
|
||||
export function ApiKeysTable() {
|
||||
const { t } = useTranslation()
|
||||
const { refreshTrigger } = useApiKeys()
|
||||
const columns = useApiKeysColumns()
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const columns = useApiKeysColumns(now)
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
setNow(Date.now())
|
||||
}, 30_000)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
globalFilter,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
|
||||
import { isDynamicPricingModel } from '../lib/dynamic-price'
|
||||
import { isTokenBasedModel } from '../lib/model-helpers'
|
||||
import type { PricingModel } from '../types'
|
||||
|
||||
interface ModelBillingModeBadgeProps {
|
||||
model: PricingModel
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Billing-mode indicator for a pricing model: dynamic (expression-based)
|
||||
* pricing, token-based billing, or per-request billing.
|
||||
*/
|
||||
export function ModelBillingModeBadge(props: ModelBillingModeBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
let label = t('Per Request')
|
||||
let variant: StatusVariant = 'neutral'
|
||||
|
||||
if (isDynamicPricingModel(props.model)) {
|
||||
label = t('Dynamic Pricing')
|
||||
variant = 'warning'
|
||||
} else if (isTokenBasedModel(props.model)) {
|
||||
label = t('Token-based')
|
||||
variant = 'info'
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge variant={variant} size='sm' className={props.className}>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { getIdentityTextColorClass } from '@/lib/colors'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
|
||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||
import { DEFAULT_TOKEN_UNIT } from '../constants'
|
||||
import {
|
||||
getDynamicDisplayGroupRatio,
|
||||
getDynamicPricingSummary,
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
stripTrailingZeros,
|
||||
} from '../lib/price'
|
||||
import type { PricingModel, TokenUnit } from '../types'
|
||||
import { ModelBillingModeBadge } from './model-billing-mode-badge'
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Pricing Table Columns
|
||||
@@ -98,18 +99,10 @@ export function usePricingColumns(
|
||||
{
|
||||
accessorKey: 'quota_type',
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||
return (
|
||||
<StatusBadge
|
||||
variant={isTokenBased ? 'info' : 'neutral'}
|
||||
className='-ml-1.5'
|
||||
>
|
||||
{isTokenBased ? t('Token') : t('Request')}
|
||||
</StatusBadge>
|
||||
)
|
||||
},
|
||||
size: 80,
|
||||
cell: ({ row }) => (
|
||||
<ModelBillingModeBadge model={row.original} className='-ml-1.5' />
|
||||
),
|
||||
size: 110,
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
|
||||
+37
-87
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { CircleAlert, GitBranch, Sparkles } from 'lucide-react'
|
||||
import { GitBranch, Sparkles } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -38,19 +38,13 @@ import { useGroupRatios } from '@/hooks/use-group-ratios'
|
||||
import { getUserAvatarFallback, getUserAvatarProps } from '@/lib/avatar'
|
||||
import { getIdentityTextColorClass } from '@/lib/colors'
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
import {
|
||||
formatUseTime,
|
||||
formatLogQuota,
|
||||
formatTimestampToDate,
|
||||
} from '@/lib/format'
|
||||
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { LOG_TYPE_ALL_VALUE } from '../../constants'
|
||||
import type { UsageLog } from '../../data/schema'
|
||||
import {
|
||||
formatModelName,
|
||||
getFirstResponseTimeColor,
|
||||
getResponseTimeColor,
|
||||
getTieredBillingSummary,
|
||||
hasAnyCacheTokens,
|
||||
parseLogOther,
|
||||
@@ -66,6 +60,7 @@ import {
|
||||
import type { LogOtherData } from '../../types'
|
||||
import { DetailsDialog } from '../dialogs/details-dialog'
|
||||
import { ModelBadge } from '../model-badge'
|
||||
import { TimingMetricsCell, StreamTpsCell } from '../timing-metrics-cell'
|
||||
import { useUsageLogsContext } from '../usage-logs-provider'
|
||||
|
||||
interface DetailSegment {
|
||||
@@ -656,104 +651,34 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
header: t('Timing'),
|
||||
accessorKey: 'is_stream',
|
||||
header: t('Stream'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isTimingLogType(log.type)) return null
|
||||
|
||||
const useTime = row.getValue('use_time') as number
|
||||
const other = parseLogOther(log.other)
|
||||
const frt = other?.frt
|
||||
const tokensPerSecond =
|
||||
useTime > 0 && log.completion_tokens > 0
|
||||
? log.completion_tokens / useTime
|
||||
: null
|
||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||
const frtVariant = frt
|
||||
? getFirstResponseTimeColor(frt / 1000)
|
||||
: 'neutral'
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<StatusBadge
|
||||
variant={timeVariant}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{formatUseTime(useTime)}
|
||||
</StatusBadge>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
<StatusBadge
|
||||
variant={frtVariant}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{formatUseTime(frt / 1000)}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
N/A
|
||||
</StatusBadge>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs leading-none'>
|
||||
<span className='text-subtle-foreground text-xs leading-none'>
|
||||
{log.is_stream ? t('Stream') : t('Non-stream')}
|
||||
{tokensPerSecond != null && (
|
||||
<>
|
||||
{' · '}
|
||||
<span className='tabular-nums'>
|
||||
{Math.round(tokensPerSecond)}
|
||||
</span>
|
||||
{' t/s'}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{log.is_stream &&
|
||||
other?.stream_status &&
|
||||
other.stream_status.status !== 'ok' && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<CircleAlert className='text-destructive size-3' />
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className='space-y-0.5 text-xs'>
|
||||
<p>
|
||||
{t('Stream Status')}: {t('Error')}
|
||||
</p>
|
||||
<p>{other.stream_status.end_reason || 'unknown'}</p>
|
||||
{(other.stream_status.error_count ?? 0) > 0 && (
|
||||
<p>
|
||||
{t('Soft Errors')}:{' '}
|
||||
{other.stream_status.error_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<StreamTpsCell
|
||||
isStream={log.is_stream}
|
||||
tokensPerSecond={tokensPerSecond}
|
||||
streamStatus={other?.stream_status}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: t('Stream'),
|
||||
cardRole: 'primary',
|
||||
cardOrder: 50,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'prompt_tokens',
|
||||
header: 'Tokens',
|
||||
@@ -807,7 +732,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'quota',
|
||||
header: t('Cost'),
|
||||
@@ -852,6 +776,32 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
header: t('Timing'),
|
||||
cell: ({ row }) => {
|
||||
const log = row.original
|
||||
if (!isTimingLogType(log.type)) return null
|
||||
|
||||
const useTime = row.getValue('use_time') as number
|
||||
const other = parseLogOther(log.other)
|
||||
|
||||
return (
|
||||
<TimingMetricsCell
|
||||
useTimeSec={useTime}
|
||||
completionTokens={log.completion_tokens}
|
||||
frtMs={other?.frt}
|
||||
isStream={log.is_stream}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 55,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: 'content',
|
||||
header: t('Details'),
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
|
||||
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
@@ -50,7 +49,7 @@ import {
|
||||
LogsFilterInput,
|
||||
LogsFilterToolbar,
|
||||
} from './logs-filter-toolbar'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -117,7 +116,7 @@ export function CommonLogsFilterBar<TData>(
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const searchParams = route.useSearch()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { isAdminView: isAdmin } = useLogsViewScope()
|
||||
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
|
||||
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
|
||||
|
||||
|
||||
@@ -22,13 +22,12 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { formatLogQuota } from '@/lib/format'
|
||||
|
||||
import { getLogStats, getUserLogStats } from '../api'
|
||||
import { DEFAULT_LOG_STATS } from '../constants'
|
||||
import { buildApiParams } from '../lib/utils'
|
||||
import { useUsageLogsContext } from './usage-logs-provider'
|
||||
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -56,7 +55,7 @@ function StatBadge(props: {
|
||||
|
||||
export function CommonLogsStats() {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { isAdminView: isAdmin } = useLogsViewScope()
|
||||
const searchParams = route.useSearch()
|
||||
const { sensitiveVisible } = useUsageLogsContext()
|
||||
|
||||
|
||||
@@ -22,8 +22,6 @@ import { type Table } from '@tanstack/react-table'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
|
||||
import { buildSearchParams } from '../lib/filter'
|
||||
import { getDefaultTimeRange } from '../lib/utils'
|
||||
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
|
||||
@@ -33,6 +31,7 @@ import {
|
||||
LogsFilterInput,
|
||||
LogsFilterToolbar,
|
||||
} from './logs-filter-toolbar'
|
||||
import { useLogsViewScope } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -70,7 +69,7 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const searchParams = route.useSearch()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { isAdminView: isAdmin } = useLogsViewScope()
|
||||
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
|
||||
|
||||
const [filters, setFilters] = useState<TaskLogsFilters>(() => {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
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 { CircleAlert } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatUseTime } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getFirstResponseTimeColor, getResponseTimeColor } from '../lib/format'
|
||||
import type { LogOtherData } from '../types'
|
||||
|
||||
type TimingVariant = 'success' | 'warning' | 'destructive' | 'neutral'
|
||||
|
||||
const timingTextColorMap: Record<TimingVariant, string> = {
|
||||
success: 'text-status-success',
|
||||
warning: 'text-status-warning',
|
||||
destructive: 'text-status-destructive',
|
||||
neutral: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
interface TimingMetricsCellProps {
|
||||
useTimeSec: number
|
||||
completionTokens: number
|
||||
frtMs?: number
|
||||
isStream: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-line timing readout for request logs: first-token latency (stream
|
||||
* requests only) and total duration, each colored by the shared
|
||||
* response-time thresholds.
|
||||
*/
|
||||
export function TimingMetricsCell(props: TimingMetricsCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const showFirstToken = props.isStream
|
||||
const firstTokenSeconds =
|
||||
props.frtMs != null && props.frtMs > 0 ? props.frtMs / 1000 : null
|
||||
const firstTokenVariant: TimingVariant =
|
||||
firstTokenSeconds == null
|
||||
? 'neutral'
|
||||
: getFirstResponseTimeColor(firstTokenSeconds)
|
||||
const totalTimeVariant: TimingVariant = getResponseTimeColor(
|
||||
props.useTimeSec,
|
||||
props.completionTokens
|
||||
)
|
||||
const firstTokenLabel =
|
||||
firstTokenSeconds == null ? t('N/A') : formatUseTime(firstTokenSeconds)
|
||||
const totalTimeLabel = formatUseTime(props.useTimeSec)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{showFirstToken && (
|
||||
<div className='flex items-baseline gap-1.5'>
|
||||
<span className='text-subtle-foreground shrink-0'>
|
||||
{t('First token')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'tabular-nums',
|
||||
timingTextColorMap[firstTokenVariant]
|
||||
)}
|
||||
>
|
||||
{firstTokenLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-baseline gap-1.5'>
|
||||
<span className='text-subtle-foreground shrink-0'>{t('Duration')}</span>
|
||||
<span
|
||||
className={cn('tabular-nums', timingTextColorMap[totalTimeVariant])}
|
||||
>
|
||||
{totalTimeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StreamTpsCellProps {
|
||||
isStream: boolean
|
||||
tokensPerSecond?: number | null
|
||||
streamStatus?: LogOtherData['stream_status']
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StreamTpsCell(props: StreamTpsCellProps) {
|
||||
const { t } = useTranslation()
|
||||
const showStreamError =
|
||||
props.isStream && props.streamStatus && props.streamStatus.status !== 'ok'
|
||||
const tpsLabel =
|
||||
props.tokensPerSecond != null
|
||||
? `${Math.round(props.tokensPerSecond)} t/s`
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col items-start justify-center gap-0.5 text-xs leading-tight',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<span className='inline-flex items-center gap-1'>
|
||||
<StatusBadge variant={props.isStream ? 'info' : 'neutral'} size='sm'>
|
||||
{props.isStream ? t('Stream') : t('Non-stream')}
|
||||
</StatusBadge>
|
||||
{showStreamError && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleAlert className='text-destructive size-3' />}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<div className='space-y-0.5 text-xs'>
|
||||
<p>
|
||||
{t('Stream Status')}: {t('Error')}
|
||||
</p>
|
||||
<p>{props.streamStatus?.end_reason || 'unknown'}</p>
|
||||
{(props.streamStatus?.error_count ?? 0) > 0 && (
|
||||
<p>
|
||||
{t('Soft Errors')}: {props.streamStatus?.error_count}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</span>
|
||||
<span className='text-subtle-foreground tabular-nums'>{tpsLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,8 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
|
||||
import type { ChannelAffinityInfo } from '../types'
|
||||
|
||||
export type LogsViewScope = 'all' | 'self'
|
||||
|
||||
interface UsageLogsContextValue {
|
||||
selectedUserId: number | null
|
||||
setSelectedUserId: (userId: number | null) => void
|
||||
@@ -32,6 +36,8 @@ interface UsageLogsContextValue {
|
||||
setAffinityDialogOpen: (open: boolean) => void
|
||||
sensitiveVisible: boolean
|
||||
setSensitiveVisible: (visible: boolean) => void
|
||||
viewScope: LogsViewScope
|
||||
setViewScope: (scope: LogsViewScope) => void
|
||||
}
|
||||
|
||||
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
|
||||
@@ -45,6 +51,7 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
useState<ChannelAffinityInfo | null>(null)
|
||||
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
|
||||
const [sensitiveVisible, setSensitiveVisible] = useState(true)
|
||||
const [viewScope, setViewScope] = useState<LogsViewScope>('all')
|
||||
|
||||
return (
|
||||
<UsageLogsContext.Provider
|
||||
@@ -59,6 +66,8 @@ export function UsageLogsProvider({ children }: { children: ReactNode }) {
|
||||
setAffinityDialogOpen,
|
||||
sensitiveVisible,
|
||||
setSensitiveVisible,
|
||||
viewScope,
|
||||
setViewScope,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -73,3 +82,23 @@ export function useUsageLogsContext() {
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective admin scope for usage logs: whether the current
|
||||
* user is allowed to view all users' logs (`canManageScope`), and whether
|
||||
* their current view preference (`viewScope`) has that scope active
|
||||
* (`isAdminView`). Data fetching and admin-only UI should key off
|
||||
* `isAdminView` rather than raw role, so an admin who switches to "only
|
||||
* mine" is treated exactly like a regular user for that view.
|
||||
*/
|
||||
export function useLogsViewScope() {
|
||||
const canManageScope = useIsAdmin()
|
||||
const { viewScope, setViewScope } = useUsageLogsContext()
|
||||
|
||||
return {
|
||||
canManageScope,
|
||||
viewScope,
|
||||
setViewScope,
|
||||
isAdminView: canManageScope && viewScope === 'all',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
DataTableRow,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -43,6 +42,7 @@ import type { LogCategory } from '../types'
|
||||
import { CommonLogsFilterBar } from './common-logs-filter-bar'
|
||||
import { TaskLogsFilterBar } from './task-logs-filter-bar'
|
||||
import { UsageLogsMobileList } from './usage-logs-mobile-card'
|
||||
import { useLogsViewScope } from './usage-logs-provider'
|
||||
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
@@ -78,7 +78,7 @@ interface UsageLogsTableProps {
|
||||
|
||||
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
const { isAdminView: isAdmin } = useLogsViewScope()
|
||||
const searchParams = route.useSearch()
|
||||
|
||||
const {
|
||||
|
||||
+22
@@ -28,7 +28,9 @@ import { useSidebarConfig } from '@/hooks/use-sidebar-config'
|
||||
|
||||
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
|
||||
import {
|
||||
type LogsViewScope,
|
||||
UsageLogsProvider,
|
||||
useLogsViewScope,
|
||||
useUsageLogsContext,
|
||||
} from './components/usage-logs-provider'
|
||||
import { UsageLogsTable } from './components/usage-logs-table'
|
||||
@@ -69,6 +71,7 @@ function UsageLogsContent() {
|
||||
affinityDialogOpen,
|
||||
setAffinityDialogOpen,
|
||||
} = useUsageLogsContext()
|
||||
const { canManageScope, viewScope, setViewScope } = useLogsViewScope()
|
||||
const tabNavGroups = useMemo<NavGroup[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -105,6 +108,15 @@ function UsageLogsContent() {
|
||||
[navigate]
|
||||
)
|
||||
|
||||
const handleViewScopeChange = useCallback(
|
||||
(scope: string) => {
|
||||
if (scope === 'all' || scope === 'self') {
|
||||
setViewScope(scope as LogsViewScope)
|
||||
}
|
||||
},
|
||||
[setViewScope]
|
||||
)
|
||||
|
||||
const pageMeta =
|
||||
activeCategory === 'common' ? SECTION_META.common : SECTION_META.task
|
||||
const showTaskSwitcher =
|
||||
@@ -116,6 +128,16 @@ function UsageLogsContent() {
|
||||
<SectionPageLayout.Title>
|
||||
{t(pageMeta.titleKey)}
|
||||
</SectionPageLayout.Title>
|
||||
{canManageScope && (
|
||||
<SectionPageLayout.Actions>
|
||||
<Tabs value={viewScope} onValueChange={handleViewScopeChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value='all'>{t('All')}</TabsTrigger>
|
||||
<TabsTrigger value='self'>{t('Only Mine')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</SectionPageLayout.Actions>
|
||||
)}
|
||||
<SectionPageLayout.Content>
|
||||
<div className='flex h-full min-h-0 flex-col gap-4'>
|
||||
{showTaskSwitcher && (
|
||||
|
||||
Vendored
+3
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "Find the billing group.",
|
||||
"Find the ratio.": "Find the ratio.",
|
||||
"Finish Time": "Finish Time",
|
||||
"First token": "First token",
|
||||
"First/Last Frame to Video": "First/Last Frame to Video",
|
||||
"Fix Abilities": "Repair Channel Consistency",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Channel consistency repaired: {{success}} succeeded, {{fails}} failed",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Only configured combinations are overridden. All other calls keep the token group base ratio.",
|
||||
"Only enabled parameters are sent with the request.": "Only enabled parameters are sent with the request.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.",
|
||||
"Only Mine": "Only Mine",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.",
|
||||
"Only successful requests": "Only successful requests",
|
||||
"Only successful requests count toward this limit.": "Only successful requests count toward this limit.",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Total quota included in the plan, usable per billing period. 0 means unlimited.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Total requests allowed per period. 0 = unlimited.",
|
||||
"Total requests made": "Total requests made",
|
||||
"Total time": "Total time",
|
||||
"Total tokens": "Total tokens",
|
||||
"Total Tokens": "Total Tokens",
|
||||
"Total Usage": "Total Usage",
|
||||
|
||||
Vendored
+3
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "Trouver le groupe de facturation.",
|
||||
"Find the ratio.": "Trouver le taux.",
|
||||
"Finish Time": "Heure de fin",
|
||||
"First token": "1er token",
|
||||
"First/Last Frame to Video": "Première/Dernière image vers vidéo",
|
||||
"Fix Abilities": "Réparer la cohérence des canaux",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Cohérence des canaux réparée : {{success}} réussie(s), {{fails}} échouée(s)",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Seules les combinaisons configurées sont remplacées. Les autres appels conservent le ratio de base du groupe du jeton.",
|
||||
"Only enabled parameters are sent with the request.": "Seuls les paramètres activés sont envoyés avec la requête.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Saisissez uniquement l’origine du site, par exemple https://api.example.com. N’ajoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser l’adresse du serveur.",
|
||||
"Only Mine": "Uniquement les miens",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Seuls les champs sélectionnés seront écrasés. Vous pouvez relancer l'assistant de synchronisation si de nouveaux conflits apparaissent.",
|
||||
"Only successful requests": "Uniquement les requêtes réussies",
|
||||
"Only successful requests count toward this limit.": "Seules les requêtes réussies comptent pour cette limite.",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Quota total inclus dans le forfait, utilisable par période de facturation. 0 signifie illimité.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Total des requêtes autorisées par période. 0 = illimité.",
|
||||
"Total requests made": "Requêtes totales effectuées",
|
||||
"Total time": "Durée totale",
|
||||
"Total tokens": "Jetons totaux",
|
||||
"Total Tokens": "Jetons totaux",
|
||||
"Total Usage": "Utilisation totale",
|
||||
|
||||
Vendored
+3
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "課金グループを特定する。",
|
||||
"Find the ratio.": "倍率を特定する。",
|
||||
"Finish Time": "完了時刻",
|
||||
"First token": "先頭トークン",
|
||||
"First/Last Frame to Video": "先頭/末尾フレームから動画",
|
||||
"Fix Abilities": "チャネル整合性を修復",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "チャネル整合性を修復しました:成功 {{success}} 件、失敗 {{fails}} 件",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "設定済みの組み合わせだけが上書きされます。他の呼び出しはトークングループの基本倍率を維持します。",
|
||||
"Only enabled parameters are sent with the request.": "有効なパラメータだけがリクエストに送信されます。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "サイトのオリジンのみを入力してください。例: https://api.example.com。/api/user/epay/notify などのパスは含めないでください。空欄の場合はサーバーアドレスを使用します。",
|
||||
"Only Mine": "自分のみ",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "選択されたフィールドのみが上書きされます。新しい競合が発生した場合は、同期ウィザードを再実行できます。",
|
||||
"Only successful requests": "成功したリクエストのみ",
|
||||
"Only successful requests count toward this limit.": "成功したリクエストのみがこの制限にカウントされます。",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "プランに含まれる合計クォータ。請求期間ごとに使用可能。0 は無制限を意味します。",
|
||||
"Total requests allowed per period. 0 = unlimited.": "期間ごとに許可されるリクエストの総数。0 = 無制限。",
|
||||
"Total requests made": "合計リクエスト数",
|
||||
"Total time": "総時間",
|
||||
"Total tokens": "合計トークン",
|
||||
"Total Tokens": "合計トークン",
|
||||
"Total Usage": "総使用量",
|
||||
|
||||
Vendored
+3
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "Определите тарифную группу.",
|
||||
"Find the ratio.": "Определите коэффициент.",
|
||||
"Finish Time": "Время завершения",
|
||||
"First token": "Первый токен",
|
||||
"First/Last Frame to Video": "Первый/последний кадр в видео",
|
||||
"Fix Abilities": "Восстановить согласованность каналов",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Согласованность каналов восстановлена: успешно {{success}}, ошибок {{fails}}",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Переопределяются только настроенные комбинации. Остальные вызовы используют базовый коэффициент группы токена.",
|
||||
"Only enabled parameters are sent with the request.": "С запросом отправляются только включенные параметры.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Введите только origin сайта, например https://api.example.com. Не добавляйте пути, например /api/user/epay/notify. Оставьте пустым, чтобы использовать адрес сервера.",
|
||||
"Only Mine": "Только мои",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Будут перезаписаны только выбранные поля. Вы можете повторно запустить мастер синхронизации, если появятся новые конфликты.",
|
||||
"Only successful requests": "Только успешные запросы",
|
||||
"Only successful requests count toward this limit.": "Только успешные запросы учитываются в этом лимите.",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Общая квота, включённая в тариф, доступна за каждый расчётный период. 0 означает безлимит.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Общее количество запросов, разрешенных за период. 0 = без ограничений.",
|
||||
"Total requests made": "Всего сделанных запросов",
|
||||
"Total time": "Общее время",
|
||||
"Total tokens": "Всего токенов",
|
||||
"Total Tokens": "Всего токенов",
|
||||
"Total Usage": "Общее использование",
|
||||
|
||||
Vendored
+3
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "Xác định nhóm tính phí.",
|
||||
"Find the ratio.": "Xác định hệ số.",
|
||||
"Finish Time": "Thời gian hoàn thành",
|
||||
"First token": "Token đầu",
|
||||
"First/Last Frame to Video": "Khung đầu/cuối sang video",
|
||||
"Fix Abilities": "Sửa tính nhất quán kênh",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "Đã sửa tính nhất quán kênh: {{success}} thành công, {{fails}} thất bại",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "Chỉ các tổ hợp đã cấu hình mới bị ghi đè. Các lệnh gọi khác giữ tỷ lệ cơ bản của nhóm token.",
|
||||
"Only enabled parameters are sent with the request.": "Chỉ các tham số đã bật mới được gửi trong yêu cầu.",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "Chỉ nhập origin của trang, ví dụ https://api.example.com. Không nhập đường dẫn như /api/user/epay/notify. Để trống để dùng địa chỉ máy chủ.",
|
||||
"Only Mine": "Chỉ của tôi",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "Chỉ các trường được chọn sẽ bị ghi đè. Bạn có thể chạy lại trình hướng dẫn đồng bộ hóa nếu có xung đột mới xuất hiện.",
|
||||
"Only successful requests": "Chỉ các yêu cầu thành công",
|
||||
"Only successful requests count toward this limit.": "Chỉ những yêu cầu thành công mới được tính vào giới hạn này.",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "Tổng hạn ngạch bao gồm trong gói, dùng được mỗi kỳ thanh toán. 0 nghĩa là không giới hạn.",
|
||||
"Total requests allowed per period. 0 = unlimited.": "Tổng số yêu cầu được phép mỗi kỳ. 0 = không giới hạn.",
|
||||
"Total requests made": "Tổng lượt yêu cầu",
|
||||
"Total time": "Tổng thời gian",
|
||||
"Total tokens": "Tổng số token",
|
||||
"Total Tokens": "Tổng số token",
|
||||
"Total Usage": "Tổng Mức Sử dụng",
|
||||
|
||||
+5
-2
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "確定收費分組。",
|
||||
"Find the ratio.": "確定倍率。",
|
||||
"Finish Time": "完成時間",
|
||||
"First token": "首字",
|
||||
"First/Last Frame to Video": "首尾生影片",
|
||||
"Fix Abilities": "修復渠道一致性",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修復完成:{{success}} 個成功,{{fails}} 個失敗",
|
||||
@@ -2953,7 +2954,7 @@
|
||||
"Node Name": "節點名稱",
|
||||
"Node role": "節點職責",
|
||||
"Nodes reporting from this deployment and their latest heartbeat.": "目前部署中上報的節點及其最新心跳。",
|
||||
"Non-stream": "非串流",
|
||||
"Non-stream": "非流",
|
||||
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀請獎勵需要先在支付閘道設定中確認合規條款。",
|
||||
"None": "無",
|
||||
"noreply@example.com": "noreply@example.com",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已設定的組合會被覆蓋,其他呼叫仍使用令牌分組的基礎倍率。",
|
||||
"Only enabled parameters are sent with the request.": "只有啟用的參數會隨請求傳送。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填寫站點根域名,例如 https://api.example.com。不要填寫 /api/user/epay/notify 這類路徑。留空則使用伺服器地址。",
|
||||
"Only Mine": "僅自己",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "僅選定的欄位將會被覆蓋。如果出現新的衝突,您可以重新執行同步精靈。",
|
||||
"Only successful requests": "僅成功的請求",
|
||||
"Only successful requests count toward this limit.": "僅成功的請求計入此限制。",
|
||||
@@ -3258,7 +3260,7 @@
|
||||
"Per 1M tokens": "每 1M tokens",
|
||||
"per request": "每次請求",
|
||||
"Per request": "每次請求",
|
||||
"Per Request": "按請求",
|
||||
"Per Request": "按次計費",
|
||||
"Per-call": "每次呼叫",
|
||||
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加收費能力窗口。",
|
||||
"Per-group performance": "各分組效能",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的總額度,每個收費周期可用;0 表示不限量",
|
||||
"Total requests allowed per period. 0 = unlimited.": "每周期允許的總請求數。0 = 無限制。",
|
||||
"Total requests made": "總請求數",
|
||||
"Total time": "總耗時",
|
||||
"Total tokens": "總 Token",
|
||||
"Total Tokens": "總 Token 數",
|
||||
"Total Usage": "總用量",
|
||||
|
||||
Vendored
+5
-2
@@ -1976,6 +1976,7 @@
|
||||
"Find the billing group.": "确定计费分组。",
|
||||
"Find the ratio.": "确定倍率。",
|
||||
"Finish Time": "完成时间",
|
||||
"First token": "首字",
|
||||
"First/Last Frame to Video": "首尾生视频",
|
||||
"Fix Abilities": "修复渠道一致性",
|
||||
"Fixed abilities: {{success}} succeeded, {{fails}} failed": "渠道一致性修复完成:{{success}} 个成功,{{fails}} 个失败",
|
||||
@@ -2953,7 +2954,7 @@
|
||||
"Node Name": "节点名称",
|
||||
"Node role": "节点职责",
|
||||
"Nodes reporting from this deployment and their latest heartbeat.": "当前部署中上报的节点及其最新心跳。",
|
||||
"Non-stream": "非流式",
|
||||
"Non-stream": "非流",
|
||||
"Non-zero invitation rewards require compliance confirmation in Payment Gateway settings.": "非零邀请奖励需要先在支付网关设置中确认合规条款。",
|
||||
"None": "无",
|
||||
"noreply@example.com": "noreply@example.com",
|
||||
@@ -3039,6 +3040,7 @@
|
||||
"Only configured combinations are overridden. All other calls keep the token group base ratio.": "只有已配置的组合会被覆盖,其他调用仍使用令牌分组的基础倍率。",
|
||||
"Only enabled parameters are sent with the request.": "只有启用的参数会随请求发送。",
|
||||
"Only enter the site origin, for example https://api.example.com. Do not include any path such as /api/user/epay/notify. Leave blank to use the server address.": "只填写站点根域名,例如 https://api.example.com。不要填写 /api/user/epay/notify 这类路径。留空则使用服务器地址。",
|
||||
"Only Mine": "仅自己",
|
||||
"Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.": "仅选定的字段将被覆盖。如果出现新的冲突,您可以重新运行同步向导。",
|
||||
"Only successful requests": "仅成功的请求",
|
||||
"Only successful requests count toward this limit.": "仅成功的请求计入此限制。",
|
||||
@@ -3258,7 +3260,7 @@
|
||||
"Per 1M tokens": "每 1M tokens",
|
||||
"per request": "每次请求",
|
||||
"Per request": "每次请求",
|
||||
"Per Request": "按请求",
|
||||
"Per Request": "按次计费",
|
||||
"Per-call": "每次调用",
|
||||
"Per-feature metered windows split by model or capability.": "按模型或能力拆分的附加计费能力窗口。",
|
||||
"Per-group performance": "各分组性能",
|
||||
@@ -4620,6 +4622,7 @@
|
||||
"Total quota included in the plan, usable per billing period. 0 means unlimited.": "套餐包含的总额度,每个计费周期可用;0 表示不限量",
|
||||
"Total requests allowed per period. 0 = unlimited.": "每周期允许的总请求数。0 = 无限制。",
|
||||
"Total requests made": "总请求数",
|
||||
"Total time": "总耗时",
|
||||
"Total tokens": "总 Token",
|
||||
"Total Tokens": "总 Token 数",
|
||||
"Total Usage": "总用量",
|
||||
|
||||
Reference in New Issue
Block a user