feat(logs): add username to TaskLog interface and implement log avatar styling
This commit is contained in:
parent
db48108d21
commit
75af3db11f
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 },
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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'),
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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')} />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
24
web/default/src/features/usage-logs/lib/avatar-color.ts
vendored
Normal file
24
web/default/src/features/usage-logs/lib/avatar-color.ts
vendored
Normal 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',
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
1
web/default/src/features/usage-logs/types.ts
vendored
1
web/default/src/features/usage-logs/types.ts
vendored
@ -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.
|
||||||
|
|||||||
4
web/default/src/i18n/locales/en.json
vendored
4
web/default/src/i18n/locales/en.json
vendored
@ -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)",
|
||||||
|
|||||||
4
web/default/src/i18n/locales/fr.json
vendored
4
web/default/src/i18n/locales/fr.json
vendored
@ -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)",
|
||||||
|
|||||||
4
web/default/src/i18n/locales/ja.json
vendored
4
web/default/src/i18n/locales/ja.json
vendored
@ -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にはできません)",
|
||||||
|
|||||||
4
web/default/src/i18n/locales/ru.json
vendored
4
web/default/src/i18n/locales/ru.json
vendored
@ -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)",
|
||||||
|
|||||||
4
web/default/src/i18n/locales/vi.json
vendored
4
web/default/src/i18n/locales/vi.json
vendored
@ -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)",
|
||||||
|
|||||||
8
web/default/src/i18n/locales/zh.json
vendored
8
web/default/src/i18n/locales/zh.json
vendored
@ -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)",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user