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:
t0ng7u
2026-07-11 20:28:20 +08:00
22 changed files with 496 additions and 154 deletions
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
@@ -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
View File
@@ -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 && (
+3
View File
@@ -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",
+3
View File
@@ -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 lorigine du site, par exemple https://api.example.com. Najoutez aucun chemin comme /api/user/epay/notify. Laissez vide pour utiliser ladresse 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",
+3
View File
@@ -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": "総使用量",
+3
View File
@@ -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": "Общее использование",
+3
View File
@@ -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
View File
@@ -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": "總用量",
+5 -2
View File
@@ -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": "总用量",