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

This commit is contained in:
CaIon 2026-04-29 09:52:45 +08:00
parent db48108d21
commit 75af3db11f
No known key found for this signature in database
GPG Key ID: 0CFA613529A9921D
19 changed files with 899 additions and 561 deletions

View File

@ -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({
<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({
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(

View File

@ -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>[] {
) )
} }
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>
) )

View File

@ -1,12 +1,19 @@
import { useEffect, useState } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { ChevronDown } from 'lucide-react' import {
ChevronDown,
KeyRound,
Settings2,
WalletCards,
type LucideIcon,
} from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { getUserModels, getUserGroups } from '@/lib/api' import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency' import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Collapsible, Collapsible,
@ -59,6 +66,34 @@ type ApiKeyMutateDrawerProps = {
side?: 'left' | 'right' side?: 'left' | 'right'
} }
type ApiKeyFormSectionProps = {
title: string
description: string
icon: LucideIcon
children: ReactNode
}
function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
const Icon = props.icon
return (
<section className='bg-card rounded-lg border'>
<div className='flex items-center gap-3 border-b px-4 py-3'>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Icon className='size-5' />
</div>
<div className='min-w-0'>
<h3 className='text-sm font-medium leading-none'>{props.title}</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{props.description}
</p>
</div>
</div>
<div className='space-y-4 p-4'>{props.children}</div>
</section>
)
}
export function ApiKeysMutateDrawer({ export function ApiKeysMutateDrawer({
open, open,
onOpenChange, onOpenChange,
@ -201,6 +236,8 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens') ? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel }) : t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const unlimitedQuota = form.watch('unlimited_quota')
return ( return (
<Sheet <Sheet
@ -214,10 +251,10 @@ export function ApiKeysMutateDrawer({
> >
<SheetContent <SheetContent
side={side} side={side}
className='flex w-full flex-col sm:max-w-[600px]' className='bg-background flex w-full gap-0 overflow-hidden p-0 sm:max-w-[620px]'
> >
<SheetHeader className='text-start'> <SheetHeader className='bg-background border-b px-5 py-4 text-start'>
<SheetTitle> <SheetTitle className='text-lg'>
{isUpdate ? t('Update API Key') : t('Create API Key')} {isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle> </SheetTitle>
<SheetDescription> <SheetDescription>
@ -231,278 +268,314 @@ export function ApiKeysMutateDrawer({
<form <form
id='api-key-form' id='api-key-form'
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-6 overflow-y-auto px-4' className='flex-1 space-y-4 overflow-y-auto px-4 py-4'
> >
<FormField <ApiKeyFormSection
control={form.control} title={t('Basic Information')}
name='name' description={t('Set API key basic information')}
render={({ field }) => ( icon={KeyRound}
<FormItem> >
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormDescription>
{t('Auto group enables circuit breaker mechanism')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{form.watch('group') === 'auto' && (
<FormField <FormField
control={form.control} control={form.control}
name='cross_group_retry' name='name'
render={({ field }) => ( render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'> <FormItem>
<div className='space-y-0.5'> <FormLabel>{t('Name')}</FormLabel>
<FormLabel className='text-base'> <FormControl>
{t('Cross-group retry')} <Input
</FormLabel> {...field}
placeholder={t('Enter a name')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='group'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Group')}</FormLabel>
<FormControl>
<ApiKeyGroupCombobox
options={groups}
value={field.value}
onValueChange={field.onChange}
placeholder={t('Select a group')}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{selectedGroup === 'auto' && (
<FormField
control={form.control}
name='cross_group_retry'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Cross-group retry')}
</FormLabel>
<FormDescription className='text-xs'>
{t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.'
)}
</FormDescription>
</div>
<FormControl>
<Switch
checked={!!field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
className='min-w-0'
/>
</FormControl>
<div className='grid grid-cols-4 gap-2 sm:flex'>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
className='px-3'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
</Button>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='tokenCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
placeholder={t('Number of keys to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription> <FormDescription>
{t( {t(
'When enabled, if channels in the current group fail, it will try channels in the next group in order.' 'Create multiple API keys at once (random suffix will be added to names)'
)} )}
</FormDescription> </FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</ApiKeyFormSection>
<ApiKeyFormSection
title={t('Quota Settings')}
description={t('Set quota amount and limits')}
icon={WalletCards}
>
{!unlimitedQuota && (
<FormField
control={form.control}
name='remain_quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex min-h-20 flex-row items-center justify-between gap-4 rounded-lg border px-4 py-3'>
<div className='space-y-0.5'>
<FormLabel className='text-sm'>
{t('Unlimited Quota')}
</FormLabel>
<FormDescription className='text-xs'>
{t('Enable unlimited quota for this API key')}
</FormDescription>
</div> </div>
<FormControl> <FormControl>
<Switch <Switch
checked={!!field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
/> />
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
)} </ApiKeyFormSection>
<FormField
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
{t('Unlimited Quota')}
</FormLabel>
<FormDescription>
{t('Enable unlimited quota for this API key')}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{!form.watch('unlimited_quota') && (
<FormField
control={form.control}
name='remain_quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='space-y-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='tokenCount'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
placeholder={t('Number of keys to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription>
{t(
'Create multiple API keys at once (random suffix will be added to names)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}> <Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild> <section className='bg-card rounded-lg border'>
<Button <CollapsibleTrigger asChild>
type='button' <button
variant='outline' type='button'
className='flex w-full items-center justify-between' className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
> >
<span className='font-medium'>{t('Advanced Options')}</span> <div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<ChevronDown <Settings2 className='size-5' />
className={`h-4 w-4 transition-transform duration-200 ${ </div>
advancedOpen ? 'rotate-180' : '' <div className='min-w-0 flex-1'>
}`} <h3 className='text-sm font-medium leading-none'>
/> {t('Advanced Settings')}
</Button> </h3>
</CollapsibleTrigger> <p className='text-muted-foreground mt-1 text-xs'>
<CollapsibleContent className='space-y-6 pt-6'> {t('Set API key access restrictions')}
<FormField </p>
control={form.control} </div>
name='model_limits' <ChevronDown
render={({ field }) => ( className={cn(
<FormItem> 'text-muted-foreground size-4 shrink-0 transition-transform',
<FormLabel>{t('Model Limits')}</FormLabel> advancedOpen && 'rotate-180'
<FormControl> )}
<MultiSelect />
options={models.map((m) => ({ label: m, value: m }))} </button>
selected={field.value} </CollapsibleTrigger>
onChange={field.onChange} <CollapsibleContent>
placeholder={t('Select models (empty for allow all)')} <div className='space-y-4 border-t p-4'>
/> <FormField
</FormControl> control={form.control}
<FormDescription> name='model_limits'
{t('Limit which models can be used with this key')} render={({ field }) => (
</FormDescription> <FormItem>
<FormMessage /> <FormLabel>{t('Model Limits')}</FormLabel>
</FormItem> <FormControl>
)} <MultiSelect
/> options={models.map((m) => ({
label: m,
value: m,
}))}
selected={field.value}
onChange={field.onChange}
placeholder={t(
'Select models (empty for allow all)'
)}
/>
</FormControl>
<FormDescription>
{t('Limit which models can be used with this key')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name='allow_ips' name='allow_ips'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('IP Whitelist (supports CIDR)')}</FormLabel> <FormLabel>
<FormControl> {t('IP Whitelist (supports CIDR)')}
<Textarea </FormLabel>
{...field} <FormControl>
placeholder={t( <Textarea
'One IP per line (empty for no restriction)' {...field}
)} className='min-h-20 resize-none'
rows={3} placeholder={t(
/> 'One IP per line (empty for no restriction)'
</FormControl> )}
<FormDescription> rows={3}
{t( />
'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.' </FormControl>
)} <FormDescription>
</FormDescription> {t(
<FormMessage /> 'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
</FormItem> )}
)} </FormDescription>
/> <FormMessage />
</CollapsibleContent> </FormItem>
)}
/>
</div>
</CollapsibleContent>
</section>
</Collapsible> </Collapsible>
</form> </form>
</Form> </Form>
<SheetFooter className='gap-2'> <SheetFooter className='bg-background gap-2 border-t px-5 py-4 sm:flex-row sm:justify-end'>
<SheetClose asChild> <SheetClose asChild>
<Button variant='outline'>{t('Close')}</Button> <Button variant='outline'>{t('Close')}</Button>
</SheetClose> </SheetClose>

View File

@ -1,9 +1,9 @@
/* 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({
// ============================================================================ // ============================================================================
/** /**
* 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: {
), ),
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: {
} }
/** /**
* 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: {
) )
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: {
} }
/** /**
* 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: {
), ),
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: {
} }
/** /**
* 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: {
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: {
} }
/** /**
* 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: {
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 },
} }

View File

@ -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 {
} 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 {
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>[] {
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>[] {
<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>[] {
) )
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>[] {
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>[] {
<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>[] {
<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>
) )
}, },

View File

@ -1,8 +1,28 @@
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'
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(
} }
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(
}, },
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(
) )
}, },
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'),

View File

@ -3,22 +3,25 @@ import { useState, useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table' import type { ColumnDef } from '@tanstack/react-table'
import { Music } from 'lucide-react' import { Music } 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 { cn } from '@/lib/utils'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants' import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import { import {
taskActionMapper, taskActionMapper,
taskStatusMapper, taskStatusMapper,
taskPlatformMapper,
} from '../../lib/mappers' } from '../../lib/mappers'
import type { TaskLog } from '../../types' import type { TaskLog } from '../../types'
import { getLogAvatarStyle } from '../../lib/avatar-color'
import { useUsageLogsContext } from '../usage-logs-provider'
import { import {
AudioPreviewDialog, AudioPreviewDialog,
type AudioClip, type AudioClip,
} from '../dialogs/audio-preview-dialog' } from '../dialogs/audio-preview-dialog'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog' import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
import { import {
createTimestampColumn,
createDurationColumn, createDurationColumn,
createChannelColumn, createChannelColumn,
createProgressColumn, createProgressColumn,
@ -52,14 +55,16 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
return ( return (
<> <>
<Button <button
variant='link' type='button'
className='h-auto p-0 text-sm' className='group flex items-center gap-1 text-left text-xs'
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
> >
<Music className='mr-1 h-3 w-3' /> <Music className='size-3 text-muted-foreground' />
{t('Click to preview audio')} <span className='text-foreground leading-snug group-hover:underline'>
</Button> {t('Click to preview audio')}
</span>
</button>
<AudioPreviewDialog <AudioPreviewDialog
open={open} open={open}
onOpenChange={setOpen} onOpenChange={setOpen}
@ -72,88 +77,128 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] { export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const { t } = useTranslation() const { t } = useTranslation()
const columns: ColumnDef<TaskLog>[] = [ const columns: ColumnDef<TaskLog>[] = [
createTimestampColumn<TaskLog>({ {
accessorKey: 'submit_time', accessorKey: 'submit_time',
title: t('Submit Time'), header: ({ column }) => (
unit: 'seconds', <DataTableColumnHeader column={column} title={t('Submit Time')} />
}), ),
createTimestampColumn<TaskLog>({ cell: ({ row }) => {
accessorKey: 'finish_time', const log = row.original
title: t('Finish Time'), const submitTime = row.getValue('submit_time') as number
unit: 'seconds',
}), return (
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(submitTime, 'seconds')}
</span>
{log.finish_time ? (
<span className='text-muted-foreground/60 font-mono text-[11px] tabular-nums'>
{formatTimestampToDate(log.finish_time, 'seconds')}
</span>
) : (
<span className='text-muted-foreground/50 text-[11px]'>-</span>
)}
</div>
)
},
meta: { label: t('Submit Time') },
},
]
if (isAdmin) {
columns.push(
createChannelColumn<TaskLog>({ headerLabel: t('Channel') }),
{
id: 'user',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('User')} />
),
cell: function UserCell({ row }) {
const {
sensitiveVisible,
setSelectedUserId,
setUserInfoDialogOpen,
} = useUsageLogsContext()
const log = row.original
const displayName = log.username || String(log.user_id || '?')
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='size-6 ring-1 ring-border/60'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getLogAvatarStyle(displayName) : undefined
}
>
{sensitiveVisible
? displayName.charAt(0).toUpperCase()
: '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? displayName : '••••'}
</span>
</button>
)
},
meta: { label: t('User'), mobileHidden: true },
}
)
}
columns.push(
{
accessorKey: 'task_id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Task ID')} />
),
cell: ({ row }) => {
const log = row.original
const taskId = row.getValue('task_id') as string
if (!taskId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<div className='flex max-w-[170px] flex-col gap-0.5'>
<StatusBadge
label={taskId}
autoColor={taskId}
size='sm'
showDot={false}
className='max-w-full truncate rounded-md border border-border/60 bg-muted/30 px-1.5 py-0.5 font-mono'
/>
<span className='text-muted-foreground/60 truncate text-[11px]'>
{t(log.platform)} · {t(taskActionMapper.getLabel(log.action))}
</span>
</div>
)
},
meta: { label: t('Task ID'), mobileTitle: true },
},
createDurationColumn<TaskLog>({ createDurationColumn<TaskLog>({
submitTimeKey: 'submit_time', submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time', finishTimeKey: 'finish_time',
unit: 'seconds', unit: 'seconds',
headerLabel: t('Duration'), headerLabel: t('Duration'),
warningThresholdSec: 300,
}), }),
]
// Channel (admin only)
if (isAdmin) {
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }))
}
columns.push(
// Platform
{
accessorKey: 'platform',
header: t('Platform'),
cell: ({ row }) => {
const platform = row.getValue('platform') as string
return (
<StatusBadge
label={t(platform)}
variant={taskPlatformMapper.getVariant(platform)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Platform') },
},
// Type/Action
{
accessorKey: 'action',
header: t('Type'),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(taskActionMapper.getLabel(action))}
variant={taskActionMapper.getVariant(action)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Type') },
},
// Task ID
{
accessorKey: 'task_id',
header: t('Task ID'),
cell: ({ row }) => {
const taskId = row.getValue('task_id') as string
return (
<StatusBadge
label={taskId}
autoColor={taskId}
size='sm'
className='font-mono'
/>
)
},
meta: { label: t('Task ID'), mobileHidden: true },
},
// Status
{ {
accessorKey: 'status', accessorKey: 'status',
header: t('Status'), header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue('status') as string const status = row.getValue('status') as string
return ( return (
@ -168,20 +213,18 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
}, },
meta: { label: t('Status') }, meta: { label: t('Status') },
}, },
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }), createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
// Result/Fail Reason - Combined column
{ {
accessorKey: 'fail_reason', accessorKey: 'fail_reason',
header: t('Details'), header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Details')} />
),
cell: function DetailsCell({ row }) { cell: function DetailsCell({ row }) {
const log = row.original const log = row.original
const failReason = row.getValue('fail_reason') as string const failReason = row.getValue('fail_reason') as string
const status = log.status const status = log.status
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false)
// Suno audio preview
const isSunoSuccess = const isSunoSuccess =
log.platform === 'suno' && status === TASK_STATUS.SUCCESS log.platform === 'suno' && status === TASK_STATUS.SUCCESS
if (isSunoSuccess) { if (isSunoSuccess) {
@ -198,7 +241,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
} }
} }
// For video generation tasks that succeeded, fail_reason contains the result URL
const isVideoTask = const isVideoTask =
log.action === TASK_ACTIONS.GENERATE || log.action === TASK_ACTIONS.GENERATE ||
log.action === TASK_ACTIONS.TEXT_GENERATE || log.action === TASK_ACTIONS.TEXT_GENERATE ||
@ -208,7 +250,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const isSuccess = status === TASK_STATUS.SUCCESS const isSuccess = status === TASK_STATUS.SUCCESS
const isUrl = failReason?.startsWith('http') const isUrl = failReason?.startsWith('http')
// If success and is a URL, show as result link
if (isSuccess && isVideoTask && isUrl) { if (isSuccess && isVideoTask && isUrl) {
const videoUrl = `/v1/videos/${log.task_id}/content` const videoUrl = `/v1/videos/${log.task_id}/content`
return ( return (
@ -216,28 +257,29 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
href={videoUrl} href={videoUrl}
target='_blank' target='_blank'
rel='noopener noreferrer' rel='noopener noreferrer'
className='text-primary text-sm hover:underline' className='text-xs text-foreground hover:underline'
> >
{t('Click to preview video')} {t('Click to preview video')}
</a> </a>
) )
} }
// Otherwise, show fail reason (if any) using the existing dialog
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={t('Click to view full error message')} title={t('Click to view full error message')}
> >
<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}

View File

@ -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({
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({
/> />
<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')} />

View File

@ -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 {
} 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) {
<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>

View File

@ -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) {
} = 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) {
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) {
<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

View File

@ -0,0 +1,24 @@
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',
}
}

View File

@ -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)
} }
/** /**

View File

@ -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.

View File

@ -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 @@
"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)",

View File

@ -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 @@
"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)",

View File

@ -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 @@
"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にはできません",

View File

@ -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 @@
"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)",

View File

@ -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 @@
"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)",

View File

@ -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 @@
"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 @@
"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",