Commit 75af3db1 by CaIon

feat(logs): add username to TaskLog interface and implement log avatar styling

parent db48108d
...@@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({ ...@@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({
role='combobox' role='combobox'
aria-expanded={open} aria-expanded={open}
disabled={disabled} disabled={disabled}
className='h-auto min-h-10 w-full justify-between gap-3 px-3 py-2 text-start' className='border-input bg-muted/40 h-auto min-h-20 w-full justify-between gap-3 rounded-lg px-4 py-3 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 hover:bg-muted/55 hover:text-foreground active:bg-background data-[state=open]:border-ring data-[state=open]:bg-background data-[state=open]:ring-ring/20 data-[state=open]:ring-[3px]'
> >
<span className='flex min-w-0 flex-1 items-center justify-between gap-3'> <span className='flex min-w-0 flex-1 items-center justify-between gap-3'>
<span className='min-w-0'> <span className='min-w-0'>
...@@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({ ...@@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' /> <ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className='w-[var(--radix-popover-trigger-width)] p-0'> <PopoverContent className='data-[state=closed]:zoom-out-100 data-[state=open]:zoom-in-100 data-[side=bottom]:slide-in-from-top-0 data-[side=left]:slide-in-from-right-0 data-[side=right]:slide-in-from-left-0 data-[side=top]:slide-in-from-bottom-0 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl p-0 shadow-lg data-[state=closed]:duration-75 data-[state=open]:duration-100'>
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder={t('Search...')} placeholder={t('Search...')}
...@@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({ ...@@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({
key={option.value} key={option.value}
value={option.value} value={option.value}
onSelect={handleSelect} onSelect={handleSelect}
className='items-start gap-3 px-3 py-3' className='items-start gap-3 rounded-lg px-3 py-3 transition-colors data-[selected=true]:bg-muted'
> >
<Check <Check
className={cn( className={cn(
......
...@@ -31,6 +31,16 @@ function getQuotaProgressColor(percentage: number): string { ...@@ -31,6 +31,16 @@ function getQuotaProgressColor(percentage: number): string {
return '[&_[data-slot=progress-indicator]]:bg-emerald-500' return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
} }
function getGroupRatioClassName(ratio: number): string {
if (ratio > 1) {
return 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/60 dark:bg-amber-950/40 dark:text-amber-300'
}
if (ratio < 1) {
return 'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900/60 dark:bg-sky-950/40 dark:text-sky-300'
}
return 'border-border bg-muted text-muted-foreground'
}
function useGroupRatios(): Record<string, number> { function useGroupRatios(): Record<string, number> {
const isAdmin = useAuthStore((s) => const isAdmin = useAuthStore((s) =>
Boolean(s.auth.user?.role && s.auth.user.role >= 10) Boolean(s.auth.user?.role && s.auth.user.role >= 10)
...@@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] { ...@@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
) )
} }
return ( return (
<span className='inline-flex items-center gap-1.5 text-xs'> <span className='inline-flex items-center gap-2 text-xs'>
<span className='font-medium'>{group || t('Default')}</span> <span className='font-medium'>{group || t('Default')}</span>
{ratio != null && ( {ratio != null && (
<> <span
<span className='text-muted-foreground/30'>·</span> className={cn(
<span className='text-muted-foreground/60 font-mono tabular-nums'> 'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
{ratio}x getGroupRatioClassName(ratio)
</span> )}
</> >
<span className='size-1 rounded-full bg-current opacity-60' />
<span>{ratio}x</span>
</span>
)} )}
</span> </span>
) )
......
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import { useState } from 'react' import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { Clock, Zap } from 'lucide-react' import { Zap } from 'lucide-react'
import { formatTimestampToDate, formatTokens } from '@/lib/format' import { formatTimestampToDate, formatTokens } from '@/lib/format'
import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
...@@ -50,7 +50,7 @@ export function CacheTooltip({ ...@@ -50,7 +50,7 @@ export function CacheTooltip({
// ============================================================================ // ============================================================================
/** /**
* Create a timestamp column * Create a timestamp column - compact mono style matching common logs
*/ */
export function createTimestampColumn<T>(config: { export function createTimestampColumn<T>(config: {
accessorKey: string accessorKey: string
...@@ -66,10 +66,13 @@ export function createTimestampColumn<T>(config: { ...@@ -66,10 +66,13 @@ export function createTimestampColumn<T>(config: {
), ),
cell: ({ row }) => { cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number const timestamp = row.getValue(accessorKey) as number
if (!timestamp) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return ( return (
<div className='min-w-[140px] font-mono text-sm'> <span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp, unit)} {formatTimestampToDate(timestamp, unit)}
</div> </span>
) )
}, },
meta: { label: title }, meta: { label: title },
...@@ -77,19 +80,51 @@ export function createTimestampColumn<T>(config: { ...@@ -77,19 +80,51 @@ export function createTimestampColumn<T>(config: {
} }
/** /**
* Create a duration column * Duration pill colors matching common logs timing column
*/
const durationPillBg: Record<string, string> = {
green:
'border border-emerald-200/60 bg-emerald-50/70 dark:border-emerald-800/50 dark:bg-emerald-950/25',
red: 'border border-rose-200/70 bg-rose-50/70 dark:border-rose-800/50 dark:bg-rose-950/25',
success:
'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
warning:
'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
}
const durationTextColor: Record<string, string> = {
green: 'text-emerald-700 dark:text-emerald-400',
red: 'text-rose-700 dark:text-rose-400',
success: 'text-emerald-700 dark:text-emerald-400',
info: 'text-sky-700 dark:text-sky-400',
warning: 'text-amber-700 dark:text-amber-400',
}
const durationDotColor: Record<string, string> = {
green: 'bg-emerald-500',
red: 'bg-rose-500',
success: 'bg-emerald-500',
info: 'bg-sky-500',
warning: 'bg-amber-500',
}
/**
* Create a duration column - pill style matching common logs timing
*/ */
export function createDurationColumn<T>(config: { export function createDurationColumn<T>(config: {
submitTimeKey: string submitTimeKey: string
finishTimeKey: string finishTimeKey: string
unit?: 'seconds' | 'milliseconds' unit?: 'seconds' | 'milliseconds'
headerLabel: string headerLabel: string
warningThresholdSec?: number
}): ColumnDef<T> { }): ColumnDef<T> {
const { const {
submitTimeKey, submitTimeKey,
finishTimeKey, finishTimeKey,
unit = 'milliseconds', unit = 'milliseconds',
headerLabel, headerLabel,
warningThresholdSec = 60,
} = config } = config
return { return {
...@@ -106,17 +141,28 @@ export function createDurationColumn<T>(config: { ...@@ -106,17 +141,28 @@ export function createDurationColumn<T>(config: {
) )
if (!duration) { if (!duration) {
return <div className='text-muted-foreground text-sm'>-</div> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
const variant = duration.durationSec > warningThresholdSec ? 'red' : 'green'
return ( return (
<StatusBadge <span
label={`${duration.durationSec.toFixed(1)}s`} className={cn(
variant={duration.variant} 'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
icon={Clock} durationPillBg[variant],
size='sm' durationTextColor[variant]
copyable={false} )}
/> >
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
durationDotColor[variant]
)}
aria-hidden='true'
/>
{duration.durationSec.toFixed(1)}s
</span>
) )
}, },
meta: { label: headerLabel }, meta: { label: headerLabel },
...@@ -124,7 +170,7 @@ export function createDurationColumn<T>(config: { ...@@ -124,7 +170,7 @@ export function createDurationColumn<T>(config: {
} }
/** /**
* Create a channel column (admin only) * Create a channel column (admin only) - #id badge matching common logs
*/ */
export function createChannelColumn<T>(config: { export function createChannelColumn<T>(config: {
accessorKey?: string accessorKey?: string
...@@ -139,11 +185,16 @@ export function createChannelColumn<T>(config: { ...@@ -139,11 +185,16 @@ export function createChannelColumn<T>(config: {
), ),
cell: ({ row }) => { cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number const channelId = row.getValue(accessorKey) as number
if (!channelId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return ( return (
<StatusBadge <StatusBadge
label={`${channelId}`} label={`#${channelId}`}
autoColor={`channel-${channelId}`} autoColor={String(channelId)}
copyText={String(channelId)}
size='sm' size='sm'
className='font-mono'
/> />
) )
}, },
...@@ -152,7 +203,7 @@ export function createChannelColumn<T>(config: { ...@@ -152,7 +203,7 @@ export function createChannelColumn<T>(config: {
} }
/** /**
* Create a fail reason column * Create a fail reason column - text-xs truncate, hover underline, dialog
*/ */
export function createFailReasonColumn<T>(config: { export function createFailReasonColumn<T>(config: {
accessorKey?: string accessorKey?: string
...@@ -171,19 +222,21 @@ export function createFailReasonColumn<T>(config: { ...@@ -171,19 +222,21 @@ export function createFailReasonColumn<T>(config: {
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
if (!failReason) { if (!failReason) {
return <span className='text-muted-foreground text-sm'>-</span> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
return ( return (
<> <>
<Button <button
variant='ghost' type='button'
className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline' className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
title={cellTitle} title={cellTitle}
> >
<span className='truncate'>{failReason}</span> <span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
</Button> {failReason}
</span>
</button>
<FailReasonDialog <FailReasonDialog
failReason={failReason} failReason={failReason}
open={dialogOpen} open={dialogOpen}
...@@ -197,7 +250,7 @@ export function createFailReasonColumn<T>(config: { ...@@ -197,7 +250,7 @@ export function createFailReasonColumn<T>(config: {
} }
/** /**
* Create a progress column * Create a progress column - compact mono pill
*/ */
export function createProgressColumn<T>(config: { export function createProgressColumn<T>(config: {
accessorKey?: string accessorKey?: string
...@@ -213,9 +266,13 @@ export function createProgressColumn<T>(config: { ...@@ -213,9 +266,13 @@ export function createProgressColumn<T>(config: {
cell: ({ row }) => { cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string const progress = row.getValue(accessorKey) as string
if (!progress) { if (!progress) {
return <span className='text-muted-foreground text-sm'>-</span> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
return <div className='font-mono text-sm'>{progress}</div> return (
<span className='inline-flex items-center rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-xs'>
{progress}
</span>
)
}, },
meta: { label: headerLabel }, meta: { label: headerLabel },
} }
......
...@@ -8,8 +8,8 @@ import { ...@@ -8,8 +8,8 @@ import {
formatLogQuota, formatLogQuota,
formatTimestampToDate, formatTimestampToDate,
} from '@/lib/format' } from '@/lib/format'
import { getAvatarColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
...@@ -30,13 +30,15 @@ import { ...@@ -30,13 +30,15 @@ import {
} from '@/components/status-badge' } from '@/components/status-badge'
import type { UsageLog } from '../../data/schema' import type { UsageLog } from '../../data/schema'
import { import {
getTimeColor,
formatModelName, formatModelName,
getFirstResponseTimeColor,
getResponseTimeColor,
getTieredBillingSummary, getTieredBillingSummary,
hasAnyCacheTokens, hasAnyCacheTokens,
parseLogOther, parseLogOther,
isViolationFeeLog, isViolationFeeLog,
} from '../../lib/format' } from '../../lib/format'
import { getLogAvatarStyle } from '../../lib/avatar-color'
import { import {
isDisplayableLogType, isDisplayableLogType,
isTimingLogType, isTimingLogType,
...@@ -55,7 +57,27 @@ interface DetailSegment { ...@@ -55,7 +57,27 @@ interface DetailSegment {
function formatRatioCompact(ratio: number | undefined): string { function formatRatioCompact(ratio: number | undefined): string {
if (ratio == null || !Number.isFinite(ratio)) return '-' if (ratio == null || !Number.isFinite(ratio)) return '-'
return ratio % 1 === 0 ? String(ratio) : ratio.toFixed(4) return ratio % 1 === 0
? String(ratio)
: ratio.toFixed(4).replace(/\.?0+$/, '')
}
function getGroupRatioText(other: LogOtherData | null): string | null {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return `${formatRatioCompact(userGroupRatio)}x`
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
return `${formatRatioCompact(groupRatio)}x`
}
return null
} }
function buildDetailSegments( function buildDetailSegments(
...@@ -382,16 +404,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -382,16 +404,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
setUserInfoDialogOpen(true) setUserInfoDialogOpen(true)
}} }}
> >
<span <Avatar className='size-6 ring-1 ring-border/60'>
className={cn( <AvatarFallback
'flex size-6 items-center justify-center rounded-full text-xs font-bold ring-1 ring-border/60 saturate-[1.2] brightness-95 dark:brightness-110', className={cn(
sensitiveVisible 'text-[11px] font-semibold',
? getAvatarColorClass(log.username) !sensitiveVisible && 'bg-muted text-muted-foreground'
: 'bg-muted text-muted-foreground' )}
)} style={
> sensitiveVisible
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'} ? getLogAvatarStyle(log.username)
</span> : undefined
}
>
{sensitiveVisible
? log.username.charAt(0).toUpperCase()
: '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'> <span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'} {sensitiveVisible ? log.username : '••••'}
</span> </span>
...@@ -423,11 +452,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -423,11 +452,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<StatusBadge <StatusBadge
label={displayName} label={displayName}
icon={KeyRound} icon={KeyRound}
autoColor={tokenName}
copyText={sensitiveVisible ? tokenName : undefined} copyText={sensitiveVisible ? tokenName : undefined}
size='sm' size='sm'
showDot={false} showDot={false}
className='max-w-full overflow-hidden rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono' className='max-w-full overflow-hidden rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono text-foreground'
/> />
</div> </div>
) )
...@@ -504,7 +532,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -504,7 +532,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
) )
const metaParts: string[] = [] const metaParts: string[] = []
if (group) metaParts.push(sensitiveVisible ? group : '••••') const groupRatioText = getGroupRatioText(other)
if (group) {
metaParts.push(sensitiveVisible ? group : '••••')
}
if (groupRatioText) metaParts.push(groupRatioText)
return ( return (
<div className='flex max-w-[220px] flex-col gap-0.5'> <div className='flex max-w-[220px] flex-col gap-0.5'>
...@@ -532,15 +564,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -532,15 +564,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const useTime = row.getValue('use_time') as number const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other) const other = parseLogOther(log.other)
const frt = other?.frt const frt = other?.frt
const timeVariant = getTimeColor(useTime) const tokensPerSecond =
const frtVariant = frt ? getTimeColor(frt / 1000) : null useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
const timeVariant = getResponseTimeColor(
useTime,
log.completion_tokens
)
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
const pillBg: Record<string, string> = { const pillBg: Record<string, string> = {
success: success:
'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20', 'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
warning: warning:
'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20', 'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
danger:
'border border-rose-200/70 bg-rose-50/60 dark:border-rose-800/50 dark:bg-rose-950/25',
} }
return ( return (
...@@ -581,15 +621,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -581,15 +621,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<div className='flex items-center gap-1 text-[11px]'> <div className='flex items-center gap-1 text-[11px]'>
<span className='text-muted-foreground/60'> <span className='text-muted-foreground/60'>
{log.is_stream ? t('Stream') : t('Non-stream')} {log.is_stream ? t('Stream') : t('Non-stream')}
{useTime > 0 && (log.prompt_tokens + log.completion_tokens) > 0 && ( {tokensPerSecond != null && (
<> <>
{' · '} {' · '}
<span className='font-mono tabular-nums'> <span className='font-mono tabular-nums'>
{Math.round( {Math.round(tokensPerSecond)}
(log.is_stream
? log.completion_tokens
: log.prompt_tokens + log.completion_tokens) / useTime
)}
</span> </span>
{' t/s'} {' t/s'}
</> </>
...@@ -717,29 +753,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] { ...@@ -717,29 +753,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<span className='border-border/80 inline-flex w-fit items-center rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-semibold tabular-nums'> <span className='border-border/80 inline-flex w-fit items-center rounded-md border bg-muted/60 px-1.5 py-0.5 font-mono text-xs font-semibold tabular-nums'>
{quotaStr} {quotaStr}
</span> </span>
{(() => {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return (
<span className='text-muted-foreground/60 text-[11px]'>
{t('User Group: {{ratio}}x', { ratio: userGroupRatio })}
</span>
)
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1) {
return (
<span className='text-muted-foreground/60 text-[11px]'>
{t('Group: {{ratio}}x', { ratio: groupRatio })}
</span>
)
}
return null
})()}
</div> </div>
) )
}, },
......
import { useState } from 'react' import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import {
Blend,
FileText,
HelpCircle,
ImageIcon,
Maximize2,
Move,
Paintbrush,
RefreshCw,
Scissors,
Shuffle,
Upload,
UserRound,
Video,
WandSparkles,
ZoomIn,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button' import { formatTimestampToDate } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { MJ_TASK_TYPES } from '../../constants'
import { import {
mjTaskTypeMapper, mjTaskTypeMapper,
mjStatusMapper, mjStatusMapper,
...@@ -12,84 +32,136 @@ import type { MidjourneyLog } from '../../types' ...@@ -12,84 +32,136 @@ import type { MidjourneyLog } from '../../types'
import { ImageDialog } from '../dialogs/image-dialog' import { ImageDialog } from '../dialogs/image-dialog'
import { PromptDialog } from '../dialogs/prompt-dialog' import { PromptDialog } from '../dialogs/prompt-dialog'
import { import {
createTimestampColumn,
createDurationColumn, createDurationColumn,
createChannelColumn, createChannelColumn,
createProgressColumn, createProgressColumn,
createFailReasonColumn, createFailReasonColumn,
} from './column-helpers' } from './column-helpers'
const drawingTypeIconMap: Record<string, LucideIcon> = {
[MJ_TASK_TYPES.IMAGINE]: ImageIcon,
[MJ_TASK_TYPES.UPSCALE]: Maximize2,
[MJ_TASK_TYPES.VIDEO]: Video,
[MJ_TASK_TYPES.EDITS]: Paintbrush,
[MJ_TASK_TYPES.VARIATION]: Shuffle,
[MJ_TASK_TYPES.HIGH_VARIATION]: Shuffle,
[MJ_TASK_TYPES.LOW_VARIATION]: Shuffle,
[MJ_TASK_TYPES.PAN]: Move,
[MJ_TASK_TYPES.DESCRIBE]: FileText,
[MJ_TASK_TYPES.BLEND]: Blend,
[MJ_TASK_TYPES.UPLOAD]: Upload,
[MJ_TASK_TYPES.SHORTEN]: Scissors,
[MJ_TASK_TYPES.REROLL]: RefreshCw,
[MJ_TASK_TYPES.INPAINT]: WandSparkles,
[MJ_TASK_TYPES.SWAP_FACE]: UserRound,
[MJ_TASK_TYPES.ZOOM]: ZoomIn,
[MJ_TASK_TYPES.CUSTOM_ZOOM]: ZoomIn,
}
function getDrawingTypeIcon(action: string): LucideIcon {
return drawingTypeIconMap[action] ?? HelpCircle
}
export function useDrawingLogsColumns( export function useDrawingLogsColumns(
isAdmin: boolean isAdmin: boolean
): ColumnDef<MidjourneyLog>[] { ): ColumnDef<MidjourneyLog>[] {
const { t } = useTranslation() const { t } = useTranslation()
const columns: ColumnDef<MidjourneyLog>[] = [ const columns: ColumnDef<MidjourneyLog>[] = [
createTimestampColumn<MidjourneyLog>({ {
accessorKey: 'submit_time', accessorKey: 'submit_time',
title: t('Submit Time'), header: ({ column }) => (
}), <DataTableColumnHeader column={column} title={t('Submit Time')} />
createDurationColumn<MidjourneyLog>({ ),
submitTimeKey: 'submit_time', cell: ({ row }) => {
finishTimeKey: 'finish_time', const log = row.original
headerLabel: t('Duration'), const submitTime = row.getValue('submit_time') as number
}),
return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(submitTime)}
</span>
<StatusBadge
label={t(mjStatusMapper.getLabel(log.status))}
variant={mjStatusMapper.getVariant(log.status)}
size='sm'
copyable={false}
/>
</div>
)
},
meta: { label: t('Submit Time') },
},
] ]
// Channel (admin only)
if (isAdmin) { if (isAdmin) {
columns.push( columns.push(
createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') }) createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') })
) )
} }
columns.push( columns.push({
// Type (using 'action' field from backend) accessorKey: 'action',
{ header: ({ column }) => (
accessorKey: 'action', <DataTableColumnHeader column={column} title={t('Type')} />
header: t('Type'), ),
cell: ({ row }) => { cell: ({ row }) => {
const action = row.getValue('action') as string const action = row.getValue('action') as string
return ( return (
<StatusBadge <StatusBadge
label={t(mjTaskTypeMapper.getLabel(action))} label={t(mjTaskTypeMapper.getLabel(action))}
variant={mjTaskTypeMapper.getVariant(action)} variant={mjTaskTypeMapper.getVariant(action)}
size='sm' icon={getDrawingTypeIcon(action)}
copyable={false} size='sm'
/> copyable={false}
) showDot={false}
}, />
meta: { label: t('Type') }, )
}, },
meta: { label: t('Type') },
})
// Task ID columns.push({
{ accessorKey: 'mj_id',
accessorKey: 'mj_id', header: ({ column }) => (
header: t('Task ID'), <DataTableColumnHeader column={column} title={t('Task ID')} />
cell: ({ row }) => { ),
const mjId = row.getValue('mj_id') as string cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string
if (!mjId) { if (!mjId) {
return <span className='text-muted-foreground text-sm'>-</span> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
return ( return (
<div className='flex max-w-[160px] flex-col gap-0.5'>
<StatusBadge <StatusBadge
label={mjId} label={mjId}
autoColor={mjId} autoColor={mjId}
size='sm' size='sm'
className='font-mono' showDot={false}
className='max-w-full truncate rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono'
/> />
) </div>
}, )
meta: { label: t('Task ID'), mobileHidden: true }, },
} meta: { label: t('Task ID'), mobileTitle: true },
})
columns.push(
createDurationColumn<MidjourneyLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
headerLabel: t('Duration'),
})
) )
// Submit Result (admin only)
if (isAdmin) { if (isAdmin) {
columns.push({ columns.push({
accessorKey: 'code', accessorKey: 'code',
header: t('Submit Result'), header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Submit Result')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const code = row.getValue('code') as number const code = row.getValue('code') as number
...@@ -108,49 +180,33 @@ export function useDrawingLogsColumns( ...@@ -108,49 +180,33 @@ export function useDrawingLogsColumns(
} }
columns.push( columns.push(
// Status
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return (
<StatusBadge
label={t(mjStatusMapper.getLabel(status))}
variant={mjStatusMapper.getVariant(status)}
size='sm'
copyable={false}
showDot
/>
)
},
meta: { label: t('Status') },
},
createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }), createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
// Image
{ {
accessorKey: 'image_url', accessorKey: 'image_url',
header: t('Image'), header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Image')} />
),
cell: function ImageCell({ row }) { cell: function ImageCell({ row }) {
const log = row.original const log = row.original
const imageUrl = row.getValue('image_url') as string const imageUrl = row.getValue('image_url') as string
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) { if (!imageUrl) {
return <span className='text-muted-foreground text-sm'>-</span> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
return ( return (
<> <>
<Button <button
variant='ghost' type='button'
className='text-primary h-auto p-0 text-sm font-normal hover:underline' className='group text-left text-xs'
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
title={t('Click to view image')}
> >
{t('View')} <span className='text-foreground truncate leading-snug group-hover:underline'>
</Button> {t('View')}
</span>
</button>
<ImageDialog <ImageDialog
imageUrl={imageUrl} imageUrl={imageUrl}
taskId={log.mj_id} taskId={log.mj_id}
...@@ -162,30 +218,32 @@ export function useDrawingLogsColumns( ...@@ -162,30 +218,32 @@ export function useDrawingLogsColumns(
}, },
meta: { label: t('Image'), mobileHidden: true }, meta: { label: t('Image'), mobileHidden: true },
}, },
// Prompt (clickable)
{ {
accessorKey: 'prompt', accessorKey: 'prompt',
header: t('Prompt'), header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Prompt')} />
),
cell: function PromptCell({ row }) { cell: function PromptCell({ row }) {
const log = row.original const log = row.original
const prompt = row.getValue('prompt') as string const prompt = row.getValue('prompt') as string
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) { if (!prompt) {
return <span className='text-muted-foreground text-sm'>-</span> return <span className='text-muted-foreground/60 text-xs'>-</span>
} }
return ( return (
<> <>
<Button <button
variant='ghost' type='button'
className='h-auto max-w-[300px] justify-start overflow-hidden p-0 text-left text-sm font-normal hover:underline' className='group flex max-w-[220px] items-center text-left text-xs'
onClick={() => setDialogOpen(true)} onClick={() => setDialogOpen(true)}
title={t('Click to view full prompt')} title={t('Click to view full prompt')}
> >
<span className='truncate'>{prompt}</span> <span className='text-muted-foreground truncate leading-snug group-hover:underline'>
</Button> {prompt}
</span>
</button>
<PromptDialog <PromptDialog
prompt={prompt} prompt={prompt}
promptEn={log.prompt_en} promptEn={log.prompt_en}
...@@ -196,8 +254,9 @@ export function useDrawingLogsColumns( ...@@ -196,8 +254,9 @@ export function useDrawingLogsColumns(
) )
}, },
meta: { label: t('Prompt'), mobileHidden: true }, meta: { label: t('Prompt'), mobileHidden: true },
size: 200,
maxSize: 220,
}, },
createFailReasonColumn<MidjourneyLog>({ createFailReasonColumn<MidjourneyLog>({
headerLabel: t('Fail Reason'), headerLabel: t('Fail Reason'),
cellTitle: t('Click to view full error message'), cellTitle: t('Click to view full error message'),
......
...@@ -22,6 +22,13 @@ import { CompactDateTimeRangePicker } from './compact-date-time-range-picker' ...@@ -22,6 +22,13 @@ import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider' import { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
type LogTypeValue = (typeof logTypeValues)[number]
function isLogTypeValue(value: string): value is LogTypeValue {
return (logTypeValues as readonly string[]).includes(value)
}
interface CommonLogsFilterBarProps { interface CommonLogsFilterBarProps {
stats?: ReactNode stats?: ReactNode
...@@ -45,7 +52,7 @@ export function CommonLogsFilterBar({ ...@@ -45,7 +52,7 @@ export function CommonLogsFilterBar({
const { start, end } = getDefaultTimeRange() const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end } return { startTime: start, endTime: end }
}) })
const [logType, setLogType] = useState<string>('') const [logType, setLogType] = useState<LogTypeValue | ''>('')
useEffect(() => { useEffect(() => {
const next: Partial<CommonLogFilters> = {} const next: Partial<CommonLogFilters> = {}
...@@ -163,7 +170,9 @@ export function CommonLogsFilterBar({ ...@@ -163,7 +170,9 @@ export function CommonLogsFilterBar({
/> />
<Select <Select
value={logType} value={logType}
onValueChange={(v) => setLogType(v === 'all' ? '' : v)} onValueChange={(value) => {
setLogType(isLogTypeValue(value) ? value : '')
}}
> >
<SelectTrigger className='h-9'> <SelectTrigger className='h-9'>
<SelectValue placeholder={t('All Types')} /> <SelectValue placeholder={t('All Types')} />
......
...@@ -38,7 +38,8 @@ import { ...@@ -38,7 +38,8 @@ import {
getTieredBillingSummary, getTieredBillingSummary,
hasAnyCacheTokens, hasAnyCacheTokens,
isViolationFeeLog, isViolationFeeLog,
getTimeColor, getFirstResponseTimeColor,
getResponseTimeColor,
} from '../../lib/format' } from '../../lib/format'
import { import {
getLogTypeConfig, getLogTypeConfig,
...@@ -47,6 +48,14 @@ import { ...@@ -47,6 +48,14 @@ import {
} from '../../lib/utils' } from '../../lib/utils'
import type { LogOtherData } from '../../types' import type { LogOtherData } from '../../types'
function timingTextColorClass(
variant: 'success' | 'warning' | 'danger'
): string {
if (variant === 'success') return 'text-emerald-600'
if (variant === 'warning') return 'text-amber-600'
return 'text-rose-600'
}
function DetailRow(props: { function DetailRow(props: {
label: React.ReactNode label: React.ReactNode
value: React.ReactNode value: React.ReactNode
...@@ -545,18 +554,26 @@ export function DetailsDialog(props: DetailsDialogProps) { ...@@ -545,18 +554,26 @@ export function DetailsDialog(props: DetailsDialogProps) {
<span <span
className={cn( className={cn(
'font-medium', 'font-medium',
getTimeColor(props.log.use_time) === 'success' timingTextColorClass(
? 'text-emerald-600' getResponseTimeColor(
: getTimeColor(props.log.use_time) === 'info' props.log.use_time,
? 'text-sky-600' props.log.completion_tokens
: 'text-amber-600' )
)
)} )}
> >
{formatUseTime(props.log.use_time)} {formatUseTime(props.log.use_time)}
{props.log.is_stream && {props.log.is_stream &&
other?.frt != null && other?.frt != null &&
other.frt > 0 && ( other.frt > 0 && (
<span className='text-muted-foreground font-normal'> <span
className={cn(
'font-normal',
timingTextColorClass(
getFirstResponseTimeColor(other.frt / 1000)
)
)}
>
{' '} {' '}
(FRT: {formatUseTime(other.frt / 1000)}) (FRT: {formatUseTime(other.frt / 1000)})
</span> </span>
......
...@@ -43,15 +43,6 @@ import { CommonLogsStats } from './common-logs-stats' ...@@ -43,15 +43,6 @@ import { CommonLogsStats } from './common-logs-stats'
const route = getRouteApi('/_authenticated/usage-logs/$section') const route = getRouteApi('/_authenticated/usage-logs/$section')
const logTypeBorderColor: Record<number, string> = {
[LOG_TYPE_ENUM.TOPUP]: 'border-l-cyan-400 dark:border-l-cyan-500',
[LOG_TYPE_ENUM.CONSUME]: 'border-l-emerald-400 dark:border-l-emerald-500',
[LOG_TYPE_ENUM.MANAGE]: 'border-l-orange-400 dark:border-l-orange-500',
[LOG_TYPE_ENUM.SYSTEM]: 'border-l-purple-400 dark:border-l-purple-500',
[LOG_TYPE_ENUM.ERROR]: 'border-l-rose-400 dark:border-l-rose-500',
[LOG_TYPE_ENUM.REFUND]: 'border-l-blue-400 dark:border-l-blue-500',
}
const logTypeRowTint: Record<number, string> = { const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20', [LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20',
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15', [LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
...@@ -76,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ...@@ -76,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
} = useTableUrlState({ } = useTableUrlState({
search: route.useSearch(), search: route.useSearch(),
navigate: route.useNavigate(), navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 }, pagination: { defaultPage: 1, defaultPageSize: 100 },
globalFilter: { enabled: false }, globalFilter: { enabled: false },
columnFilters: [ columnFilters: [
{ columnId: 'created_at', searchKey: 'type', type: 'array' as const }, { columnId: 'created_at', searchKey: 'type', type: 'array' as const },
...@@ -174,24 +165,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ...@@ -174,24 +165,16 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const logType = (row.original as Record<string, unknown>).type as const logType = (row.original as Record<string, unknown>).type as
| number | number
| undefined | undefined
const borderClass =
isCommon && logType != null
? logTypeBorderColor[logType] ?? 'border-l-transparent'
: ''
const tintClass = const tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : '' isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
return ( return (
<TableRow <TableRow
key={row.id} key={row.id}
className={cn( className={cn('transition-colors', tintClass)}
'!border-l-[3px] transition-colors',
borderClass,
tintClass
)}
> >
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className='py-2'> <TableCell key={cell.id} className={isCommon ? 'py-2' : 'py-3.5'}>
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
))} ))}
...@@ -236,7 +219,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) { ...@@ -236,7 +219,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
<Table> <Table>
<TableHeader className='bg-muted/30 sticky top-0 z-10'> <TableHeader className='bg-muted/30 sticky top-0 z-10'>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='border-l-[3px] border-l-transparent'> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}> <TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder {header.isPlaceholder
......
export interface LogAvatarStyle {
backgroundColor: string
color: string
}
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
}
return hash
}
export function getLogAvatarStyle(name: string): LogAvatarStyle {
const hash = hashString(name)
const hue = hash % 360
const saturation = 54 + (hash % 8)
const lightness = 52 + ((hash >> 4) % 8)
return {
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
color: 'white',
}
}
...@@ -87,10 +87,45 @@ export function parseLogOther(other: string): LogOtherData | null { ...@@ -87,10 +87,45 @@ export function parseLogOther(other: string): LogOtherData | null {
/** /**
* Get time color based on duration (in seconds) * Get time color based on duration (in seconds)
*/ */
export function getTimeColor(seconds: number): 'success' | 'info' | 'warning' { export function getTimeColor(
if (seconds < 3) return 'success' seconds: number
if (seconds < 10) return 'info' ): 'success' | 'warning' | 'danger' {
return 'warning' if (seconds < 10) return 'success'
if (seconds < 30) return 'warning'
return 'danger'
}
/**
* Get first-response-token color based on latency (in seconds)
*/
export function getFirstResponseTimeColor(
seconds: number
): 'success' | 'warning' | 'danger' {
if (seconds < 5) return 'success'
if (seconds < 10) return 'warning'
return 'danger'
}
/**
* Get throughput color based on generated tokens per second
*/
export function getThroughputColor(
tokensPerSecond: number
): 'success' | 'warning' | 'danger' {
if (tokensPerSecond >= 30) return 'success'
if (tokensPerSecond >= 15) return 'warning'
return 'danger'
}
/**
* Get response color using throughput only when enough output tokens exist.
*/
export function getResponseTimeColor(
seconds: number,
completionTokens: number
): 'success' | 'warning' | 'danger' {
if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
return getThroughputColor(completionTokens / seconds)
} }
/** /**
......
...@@ -216,6 +216,7 @@ export interface MidjourneyLog { ...@@ -216,6 +216,7 @@ export interface MidjourneyLog {
export interface TaskLog { export interface TaskLog {
id: number id: number
user_id: number user_id: number
username?: string
platform: string // suno, kling, runway, etc. platform: string // suno, kling, runway, etc.
task_id: string task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc. action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
......
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
"Auto detect (default)": "Auto detect (default)", "Auto detect (default)": "Auto detect (default)",
"Auto Disabled": "Auto Disabled", "Auto Disabled": "Auto Disabled",
"Auto Group Chain": "Auto Group Chain", "Auto Group Chain": "Auto Group Chain",
"Auto group enables circuit breaker mechanism": "Auto group enables circuit breaker mechanism",
"Auto refresh": "Auto refresh", "Auto refresh": "Auto refresh",
"Auto Sync Upstream Models": "Auto Sync Upstream Models", "Auto Sync Upstream Models": "Auto Sync Upstream Models",
"Auto-disable status codes": "Auto-disable status codes", "Auto-disable status codes": "Auto-disable status codes",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "Set a tag for", "Set a tag for": "Set a tag for",
"Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.", "Set filters to customize your dashboard statistics and charts.": "Set filters to customize your dashboard statistics and charts.",
"Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.", "Set filters to narrow down your log search results.": "Set filters to narrow down your log search results.",
"Set API key access restrictions": "Set API key access restrictions",
"Set API key basic information": "Set API key basic information",
"Set Header": "Set Header", "Set Header": "Set Header",
"Set Project to io.cloud when creating/selecting key": "Set Project to io.cloud when creating/selecting key", "Set Project to io.cloud when creating/selecting key": "Set Project to io.cloud when creating/selecting key",
"Set quota amount and limits": "Set quota amount and limits",
"Set Tag": "Set Tag", "Set Tag": "Set Tag",
"Set tag for selected channels": "Set tag for selected channels", "Set tag for selected channels": "Set tag for selected channels",
"Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)", "Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)",
......
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
"Auto detect (default)": "Détection automatique (par défaut)", "Auto detect (default)": "Détection automatique (par défaut)",
"Auto Disabled": "Désactivé automatiquement", "Auto Disabled": "Désactivé automatiquement",
"Auto Group Chain": "Chaîne de groupes automatique", "Auto Group Chain": "Chaîne de groupes automatique",
"Auto group enables circuit breaker mechanism": "Le groupe automatique active le mécanisme de disjoncteur",
"Auto refresh": "Actualisation automatique", "Auto refresh": "Actualisation automatique",
"Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", "Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont",
"Auto-disable status codes": "Codes de statut de désactivation auto", "Auto-disable status codes": "Codes de statut de désactivation auto",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "Définir un tag pour", "Set a tag for": "Définir un tag pour",
"Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.", "Set filters to customize your dashboard statistics and charts.": "Définir des filtres pour personnaliser les statistiques et les graphiques de votre tableau de bord.",
"Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.", "Set filters to narrow down your log search results.": "Définir des filtres pour affiner vos résultats de recherche de journaux.",
"Set API key access restrictions": "Définir les restrictions d'accès de la clé API",
"Set API key basic information": "Définir les informations de base de la clé API",
"Set Header": "Définir l'en-tête", "Set Header": "Définir l'en-tête",
"Set Project to io.cloud when creating/selecting key": "Définir le projet sur io.cloud lors de la création/sélection de la clé", "Set Project to io.cloud when creating/selecting key": "Définir le projet sur io.cloud lors de la création/sélection de la clé",
"Set quota amount and limits": "Définir le quota et les limites",
"Set Tag": "Définir un tag", "Set Tag": "Définir un tag",
"Set tag for selected channels": "Définir un tag pour les canaux sélectionnés", "Set tag for selected channels": "Définir un tag pour les canaux sélectionnés",
"Set the user's role (cannot be Root)": "Définir le rôle de l'utilisateur (ne peut pas être Root)", "Set the user's role (cannot be Root)": "Définir le rôle de l'utilisateur (ne peut pas être Root)",
......
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
"Auto detect (default)": "自動検出 (デフォルト)", "Auto detect (default)": "自動検出 (デフォルト)",
"Auto Disabled": "自動無効化", "Auto Disabled": "自動無効化",
"Auto Group Chain": "自動グループチェーン", "Auto Group Chain": "自動グループチェーン",
"Auto group enables circuit breaker mechanism": "自動グループはサーキットブレーカーメカニズムを有効にします",
"Auto refresh": "自動更新", "Auto refresh": "自動更新",
"Auto Sync Upstream Models": "アップストリームモデルの自動同期", "Auto Sync Upstream Models": "アップストリームモデルの自動同期",
"Auto-disable status codes": "自動無効化するステータスコード", "Auto-disable status codes": "自動無効化するステータスコード",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "のタグを設定", "Set a tag for": "のタグを設定",
"Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。", "Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。",
"Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。", "Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。",
"Set API key access restrictions": "API キーのアクセス制限を設定",
"Set API key basic information": "API キーの基本情報を設定",
"Set Header": "ヘッダーを設定", "Set Header": "ヘッダーを設定",
"Set Project to io.cloud when creating/selecting key": "キーを作成/選択する際にプロジェクトを io.cloud に設定", "Set Project to io.cloud when creating/selecting key": "キーを作成/選択する際にプロジェクトを io.cloud に設定",
"Set quota amount and limits": "クォータ量と制限を設定",
"Set Tag": "タグを設定", "Set Tag": "タグを設定",
"Set tag for selected channels": "選択したチャネルにタグを設定", "Set tag for selected channels": "選択したチャネルにタグを設定",
"Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)", "Set the user's role (cannot be Root)": "ユーザーのロールを設定します(Rootにはできません)",
......
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
"Auto detect (default)": "Автоматическое определение (по умолчанию)", "Auto detect (default)": "Автоматическое определение (по умолчанию)",
"Auto Disabled": "Автоматически отключено", "Auto Disabled": "Автоматически отключено",
"Auto Group Chain": "Автоматическая цепочка групп", "Auto Group Chain": "Автоматическая цепочка групп",
"Auto group enables circuit breaker mechanism": "Автоматическая группировка включает механизм автоматического выключателя",
"Auto refresh": "Автообновление", "Auto refresh": "Автообновление",
"Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера", "Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера",
"Auto-disable status codes": "Коды автоотключения", "Auto-disable status codes": "Коды автоотключения",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "Установить тег для", "Set a tag for": "Установить тег для",
"Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.", "Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.",
"Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.", "Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.",
"Set API key access restrictions": "Настройте ограничения доступа API-ключа",
"Set API key basic information": "Настройте основные сведения API-ключа",
"Set Header": "Установить заголовок", "Set Header": "Установить заголовок",
"Set Project to io.cloud when creating/selecting key": "Установите Проект в io.cloud при создании/выборе ключа", "Set Project to io.cloud when creating/selecting key": "Установите Проект в io.cloud при создании/выборе ключа",
"Set quota amount and limits": "Настройте квоту и лимиты",
"Set Tag": "Установить тег", "Set Tag": "Установить тег",
"Set tag for selected channels": "Установить тег для выбранных каналов", "Set tag for selected channels": "Установить тег для выбранных каналов",
"Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)", "Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)",
......
...@@ -365,7 +365,6 @@ ...@@ -365,7 +365,6 @@
"Auto detect (default)": "Tự động phát hiện (mặc định)", "Auto detect (default)": "Tự động phát hiện (mặc định)",
"Auto Disabled": "Vô hiệu hóa tự động", "Auto Disabled": "Vô hiệu hóa tự động",
"Auto Group Chain": "Chuỗi nhóm tự động", "Auto Group Chain": "Chuỗi nhóm tự động",
"Auto group enables circuit breaker mechanism": "Nhóm tự động kích hoạt cơ chế ngắt mạch",
"Auto refresh": "Tự động làm mới", "Auto refresh": "Tự động làm mới",
"Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", "Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn",
"Auto-disable status codes": "Mã trạng thái tự tắt", "Auto-disable status codes": "Mã trạng thái tự tắt",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "Gắn thẻ cho", "Set a tag for": "Gắn thẻ cho",
"Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.", "Set filters to customize your dashboard statistics and charts.": "Đặt bộ lọc để tùy chỉnh số liệu thống kê và biểu đồ trên bảng điều khiển của bạn.",
"Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.", "Set filters to narrow down your log search results.": "Đặt bộ lọc để thu hẹp kết quả tìm kiếm nhật ký của bạn.",
"Set API key access restrictions": "Thiết lập hạn chế truy cập cho khóa API",
"Set API key basic information": "Thiết lập thông tin cơ bản cho khóa API",
"Set Header": "Đặt tiêu đề", "Set Header": "Đặt tiêu đề",
"Set Project to io.cloud when creating/selecting key": "Đặt Dự án thành io.cloud khi tạo/chọn khóa", "Set Project to io.cloud when creating/selecting key": "Đặt Dự án thành io.cloud khi tạo/chọn khóa",
"Set quota amount and limits": "Thiết lập hạn mức và giới hạn",
"Set Tag": "Gán Thẻ", "Set Tag": "Gán Thẻ",
"Set tag for selected channels": "Đặt thẻ cho các kênh đã chọn", "Set tag for selected channels": "Đặt thẻ cho các kênh đã chọn",
"Set the user's role (cannot be Root)": "Đặt vai trò của người dùng (không được là Root)", "Set the user's role (cannot be Root)": "Đặt vai trò của người dùng (không được là Root)",
......
...@@ -359,13 +359,12 @@ ...@@ -359,13 +359,12 @@
"Authorization Endpoint (Optional)": "授权端点(可选)", "Authorization Endpoint (Optional)": "授权端点(可选)",
"Authorize": "授权", "Authorize": "授权",
"Auto": "自动", "Auto": "自动",
"Auto (Circuit Breaker)": "自动(熔断机制)", "Auto (Circuit Breaker)": "自动分组(熔断)",
"Auto assignment order": "自动分配顺序", "Auto assignment order": "自动分配顺序",
"Auto Ban": "自动封禁", "Auto Ban": "自动封禁",
"Auto detect (default)": "自动检测(默认)", "Auto detect (default)": "自动检测(默认)",
"Auto Disabled": "自动禁用", "Auto Disabled": "自动禁用",
"Auto Group Chain": "自动分组链", "Auto Group Chain": "自动分组链",
"Auto group enables circuit breaker mechanism": "自动分组启用熔断机制",
"Auto refresh": "自动刷新", "Auto refresh": "自动刷新",
"Auto Sync Upstream Models": "自动同步上游模型", "Auto Sync Upstream Models": "自动同步上游模型",
"Auto-disable status codes": "自动禁用状态码", "Auto-disable status codes": "自动禁用状态码",
...@@ -377,7 +376,7 @@ ...@@ -377,7 +376,7 @@
"Automatically disable channels when tests fail": "当测试失败时自动禁用渠道", "Automatically disable channels when tests fail": "当测试失败时自动禁用渠道",
"Automatically probe all channels in the background": "在后台自动探测所有渠道", "Automatically probe all channels in the background": "在后台自动探测所有渠道",
"Automatically replaces upstream callback URLs with the server address.": "自动将上游回调 URL 替换为服务器地址。", "Automatically replaces upstream callback URLs with the server address.": "自动将上游回调 URL 替换为服务器地址。",
"Automatically selects the best available group with circuit breaker mechanism": "自动选择最佳可用组,带有断路器机制", "Automatically selects the best available group with circuit breaker mechanism": "自动选择可用分组,失败时触发熔断切换",
"Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表", "Automatically sync model list when upstream changes are detected": "检测到上游模型变更时自动同步模型列表",
"Automatically test channels and notify users when limits are hit": "自动测试渠道并在达到限制时通知用户", "Automatically test channels and notify users when limits are hit": "自动测试渠道并在达到限制时通知用户",
"Available": "可用", "Available": "可用",
...@@ -2975,8 +2974,11 @@ ...@@ -2975,8 +2974,11 @@
"Set a tag for": "设置标签为", "Set a tag for": "设置标签为",
"Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。", "Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。",
"Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。", "Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。",
"Set API key access restrictions": "设置令牌的访问限制",
"Set API key basic information": "设置令牌的基本信息",
"Set Header": "设请求头", "Set Header": "设请求头",
"Set Project to io.cloud when creating/selecting key": "创建/选择密钥时将项目设置为 io.cloud", "Set Project to io.cloud when creating/selecting key": "创建/选择密钥时将项目设置为 io.cloud",
"Set quota amount and limits": "设置令牌可用额度和数量",
"Set Tag": "设置标签", "Set Tag": "设置标签",
"Set tag for selected channels": "为选定的渠道设置标签", "Set tag for selected channels": "为选定的渠道设置标签",
"Set the user's role (cannot be Root)": "设置用户角色(不能是 Root)", "Set the user's role (cannot be Root)": "设置用户角色(不能是 Root)",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment