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

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

View File

@ -110,7 +110,7 @@ export function ApiKeyGroupCombobox({
role='combobox'
aria-expanded={open}
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='min-w-0'>
@ -128,7 +128,7 @@ export function ApiKeyGroupCombobox({
<ChevronsUpDown className='h-4 w-4 shrink-0 opacity-50' />
</Button>
</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}>
<CommandInput
placeholder={t('Search...')}
@ -143,7 +143,7 @@ export function ApiKeyGroupCombobox({
key={option.value}
value={option.value}
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
className={cn(

View File

@ -31,6 +31,16 @@ function getQuotaProgressColor(percentage: number): string {
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> {
const isAdmin = useAuthStore((s) =>
Boolean(s.auth.user?.role && s.auth.user.role >= 10)
@ -242,15 +252,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
}
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>
{ratio != null && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/60 font-mono tabular-nums'>
{ratio}x
</span>
</>
<span
className={cn(
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
getGroupRatioClassName(ratio)
)}
>
<span className='size-1 rounded-full bg-current opacity-60' />
<span>{ratio}x</span>
</span>
)}
</span>
)

View File

@ -1,12 +1,19 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
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 { toast } from 'sonner'
import { getUserModels, getUserGroups } from '@/lib/api'
import { getCurrencyDisplay, getCurrencyLabel } from '@/lib/currency'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Collapsible,
@ -59,6 +66,34 @@ type ApiKeyMutateDrawerProps = {
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({
open,
onOpenChange,
@ -201,6 +236,8 @@ export function ApiKeysMutateDrawer({
const quotaPlaceholder = tokensOnly
? t('Enter quota in tokens')
: t('Enter quota in {{currency}}', { currency: currencyLabel })
const selectedGroup = form.watch('group')
const unlimitedQuota = form.watch('unlimited_quota')
return (
<Sheet
@ -214,10 +251,10 @@ export function ApiKeysMutateDrawer({
>
<SheetContent
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'>
<SheetTitle>
<SheetHeader className='bg-background border-b px-5 py-4 text-start'>
<SheetTitle className='text-lg'>
{isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle>
<SheetDescription>
@ -231,278 +268,314 @@ export function ApiKeysMutateDrawer({
<form
id='api-key-form'
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
control={form.control}
name='name'
render={({ field }) => (
<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' && (
<ApiKeyFormSection
title={t('Basic Information')}
description={t('Set API key basic information')}
icon={KeyRound}
>
<FormField
control={form.control}
name='cross_group_retry'
name='name'
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('Cross-group retry')}
</FormLabel>
<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>
<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>
{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>
<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>
<FormControl>
<Switch
checked={!!field.value}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<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>
)}
/>
)}
</ApiKeyFormSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button
type='button'
variant='outline'
className='flex w-full items-center justify-between'
>
<span className='font-medium'>{t('Advanced Options')}</span>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
advancedOpen ? 'rotate-180' : ''
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<FormField
control={form.control}
name='model_limits'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Limits')}</FormLabel>
<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>
)}
/>
<section className='bg-card rounded-lg border'>
<CollapsibleTrigger asChild>
<button
type='button'
className='hover:bg-muted/50 flex w-full items-center gap-3 px-4 py-3 text-left transition-colors'
>
<div className='bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-lg border'>
<Settings2 className='size-5' />
</div>
<div className='min-w-0 flex-1'>
<h3 className='text-sm font-medium leading-none'>
{t('Advanced Settings')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Set API key access restrictions')}
</p>
</div>
<ChevronDown
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform',
advancedOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='space-y-4 border-t p-4'>
<FormField
control={form.control}
name='model_limits'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model Limits')}</FormLabel>
<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
control={form.control}
name='allow_ips'
render={({ field }) => (
<FormItem>
<FormLabel>{t('IP Whitelist (supports CIDR)')}</FormLabel>
<FormControl>
<Textarea
{...field}
placeholder={t(
'One IP per line (empty for no restriction)'
)}
rows={3}
/>
</FormControl>
<FormDescription>
{t(
'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
<FormField
control={form.control}
name='allow_ips'
render={({ field }) => (
<FormItem>
<FormLabel>
{t('IP Whitelist (supports CIDR)')}
</FormLabel>
<FormControl>
<Textarea
{...field}
className='min-h-20 resize-none'
placeholder={t(
'One IP per line (empty for no restriction)'
)}
rows={3}
/>
</FormControl>
<FormDescription>
{t(
'Do not over-trust this feature. IP may be spoofed. Please use with nginx, CDN and other gateways.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</section>
</Collapsible>
</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>
<Button variant='outline'>{t('Close')}</Button>
</SheetClose>

View File

@ -1,9 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import { useState } from 'react'
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 { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import {
Tooltip,
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: {
accessorKey: string
@ -66,10 +66,13 @@ export function createTimestampColumn<T>(config: {
),
cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number
if (!timestamp) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<div className='min-w-[140px] font-mono text-sm'>
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp, unit)}
</div>
</span>
)
},
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: {
submitTimeKey: string
finishTimeKey: string
unit?: 'seconds' | 'milliseconds'
headerLabel: string
warningThresholdSec?: number
}): ColumnDef<T> {
const {
submitTimeKey,
finishTimeKey,
unit = 'milliseconds',
headerLabel,
warningThresholdSec = 60,
} = config
return {
@ -106,17 +141,28 @@ export function createDurationColumn<T>(config: {
)
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 (
<StatusBadge
label={`${duration.durationSec.toFixed(1)}s`}
variant={duration.variant}
icon={Clock}
size='sm'
copyable={false}
/>
<span
className={cn(
'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
durationPillBg[variant],
durationTextColor[variant]
)}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
durationDotColor[variant]
)}
aria-hidden='true'
/>
{duration.durationSec.toFixed(1)}s
</span>
)
},
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: {
accessorKey?: string
@ -139,11 +185,16 @@ export function createChannelColumn<T>(config: {
),
cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number
if (!channelId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<StatusBadge
label={`${channelId}`}
autoColor={`channel-${channelId}`}
label={`#${channelId}`}
autoColor={String(channelId)}
copyText={String(channelId)}
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: {
accessorKey?: string
@ -171,19 +222,21 @@ export function createFailReasonColumn<T>(config: {
const [dialogOpen, setDialogOpen] = useState(false)
if (!failReason) {
return <span className='text-muted-foreground text-sm'>-</span>
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
<button
type='button'
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
onClick={() => setDialogOpen(true)}
title={cellTitle}
>
<span className='truncate'>{failReason}</span>
</Button>
<span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
{failReason}
</span>
</button>
<FailReasonDialog
failReason={failReason}
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: {
accessorKey?: string
@ -213,9 +266,13 @@ export function createProgressColumn<T>(config: {
cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string
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 },
}

View File

@ -8,8 +8,8 @@ import {
formatLogQuota,
formatTimestampToDate,
} from '@/lib/format'
import { getAvatarColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Popover,
PopoverContent,
@ -30,13 +30,15 @@ import {
} from '@/components/status-badge'
import type { UsageLog } from '../../data/schema'
import {
getTimeColor,
formatModelName,
getFirstResponseTimeColor,
getResponseTimeColor,
getTieredBillingSummary,
hasAnyCacheTokens,
parseLogOther,
isViolationFeeLog,
} from '../../lib/format'
import { getLogAvatarStyle } from '../../lib/avatar-color'
import {
isDisplayableLogType,
isTimingLogType,
@ -55,7 +57,27 @@ interface DetailSegment {
function formatRatioCompact(ratio: number | undefined): string {
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(
@ -382,16 +404,23 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
setUserInfoDialogOpen(true)
}}
>
<span
className={cn(
'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',
sensitiveVisible
? getAvatarColorClass(log.username)
: 'bg-muted text-muted-foreground'
)}
>
{sensitiveVisible ? log.username.charAt(0).toUpperCase() : '•'}
</span>
<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(log.username)
: undefined
}
>
{sensitiveVisible
? log.username.charAt(0).toUpperCase()
: '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? log.username : '••••'}
</span>
@ -423,11 +452,10 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<StatusBadge
label={displayName}
icon={KeyRound}
autoColor={tokenName}
copyText={sensitiveVisible ? tokenName : undefined}
size='sm'
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>
)
@ -504,7 +532,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)
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 (
<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 other = parseLogOther(log.other)
const frt = other?.frt
const timeVariant = getTimeColor(useTime)
const frtVariant = frt ? getTimeColor(frt / 1000) : null
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
const timeVariant = getResponseTimeColor(
useTime,
log.completion_tokens
)
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
const pillBg: Record<string, string> = {
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',
danger:
'border border-rose-200/70 bg-rose-50/60 dark:border-rose-800/50 dark:bg-rose-950/25',
}
return (
@ -581,15 +621,11 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<div className='flex items-center gap-1 text-[11px]'>
<span className='text-muted-foreground/60'>
{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'>
{Math.round(
(log.is_stream
? log.completion_tokens
: log.prompt_tokens + log.completion_tokens) / useTime
)}
{Math.round(tokensPerSecond)}
</span>
{' 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'>
{quotaStr}
</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>
)
},

View File

@ -1,8 +1,28 @@
import { useState } from 'react'
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 { Button } from '@/components/ui/button'
import { formatTimestampToDate } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { MJ_TASK_TYPES } from '../../constants'
import {
mjTaskTypeMapper,
mjStatusMapper,
@ -12,84 +32,136 @@ import type { MidjourneyLog } from '../../types'
import { ImageDialog } from '../dialogs/image-dialog'
import { PromptDialog } from '../dialogs/prompt-dialog'
import {
createTimestampColumn,
createDurationColumn,
createChannelColumn,
createProgressColumn,
createFailReasonColumn,
} 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(
isAdmin: boolean
): ColumnDef<MidjourneyLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<MidjourneyLog>[] = [
createTimestampColumn<MidjourneyLog>({
{
accessorKey: 'submit_time',
title: t('Submit Time'),
}),
createDurationColumn<MidjourneyLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
headerLabel: t('Duration'),
}),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Submit Time')} />
),
cell: ({ row }) => {
const log = row.original
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) {
columns.push(
createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') })
)
}
columns.push(
// Type (using 'action' field from backend)
{
accessorKey: 'action',
header: t('Type'),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(mjTaskTypeMapper.getLabel(action))}
variant={mjTaskTypeMapper.getVariant(action)}
size='sm'
copyable={false}
/>
)
},
meta: { label: t('Type') },
columns.push({
accessorKey: 'action',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Type')} />
),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(mjTaskTypeMapper.getLabel(action))}
variant={mjTaskTypeMapper.getVariant(action)}
icon={getDrawingTypeIcon(action)}
size='sm'
copyable={false}
showDot={false}
/>
)
},
meta: { label: t('Type') },
})
// Task ID
{
accessorKey: 'mj_id',
header: t('Task ID'),
cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string
columns.push({
accessorKey: 'mj_id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Task ID')} />
),
cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string
if (!mjId) {
return <span className='text-muted-foreground text-sm'>-</span>
}
if (!mjId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
return (
<div className='flex max-w-[160px] flex-col gap-0.5'>
<StatusBadge
label={mjId}
autoColor={mjId}
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'
/>
)
},
meta: { label: t('Task ID'), mobileHidden: true },
}
</div>
)
},
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) {
columns.push({
accessorKey: 'code',
header: t('Submit Result'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Submit Result')} />
),
cell: ({ row }) => {
const code = row.getValue('code') as number
@ -108,49 +180,33 @@ export function useDrawingLogsColumns(
}
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') }),
// Image
{
accessorKey: 'image_url',
header: t('Image'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Image')} />
),
cell: function ImageCell({ row }) {
const log = row.original
const imageUrl = row.getValue('image_url') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) {
return <span className='text-muted-foreground text-sm'>-</span>
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<Button
variant='ghost'
className='text-primary h-auto p-0 text-sm font-normal hover:underline'
<button
type='button'
className='group text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view image')}
>
{t('View')}
</Button>
<span className='text-foreground truncate leading-snug group-hover:underline'>
{t('View')}
</span>
</button>
<ImageDialog
imageUrl={imageUrl}
taskId={log.mj_id}
@ -162,30 +218,32 @@ export function useDrawingLogsColumns(
},
meta: { label: t('Image'), mobileHidden: true },
},
// Prompt (clickable)
{
accessorKey: 'prompt',
header: t('Prompt'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Prompt')} />
),
cell: function PromptCell({ row }) {
const log = row.original
const prompt = row.getValue('prompt') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) {
return <span className='text-muted-foreground text-sm'>-</span>
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[300px] justify-start overflow-hidden p-0 text-left text-sm font-normal hover:underline'
<button
type='button'
className='group flex max-w-[220px] items-center text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view full prompt')}
>
<span className='truncate'>{prompt}</span>
</Button>
<span className='text-muted-foreground truncate leading-snug group-hover:underline'>
{prompt}
</span>
</button>
<PromptDialog
prompt={prompt}
promptEn={log.prompt_en}
@ -196,8 +254,9 @@ export function useDrawingLogsColumns(
)
},
meta: { label: t('Prompt'), mobileHidden: true },
size: 200,
maxSize: 220,
},
createFailReasonColumn<MidjourneyLog>({
headerLabel: t('Fail Reason'),
cellTitle: t('Click to view full error message'),

View File

@ -3,22 +3,25 @@ import { useState, useMemo } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Music } from 'lucide-react'
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 { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import {
taskActionMapper,
taskStatusMapper,
taskPlatformMapper,
} from '../../lib/mappers'
import type { TaskLog } from '../../types'
import { getLogAvatarStyle } from '../../lib/avatar-color'
import { useUsageLogsContext } from '../usage-logs-provider'
import {
AudioPreviewDialog,
type AudioClip,
} from '../dialogs/audio-preview-dialog'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
import {
createTimestampColumn,
createDurationColumn,
createChannelColumn,
createProgressColumn,
@ -52,14 +55,16 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
return (
<>
<Button
variant='link'
className='h-auto p-0 text-sm'
<button
type='button'
className='group flex items-center gap-1 text-left text-xs'
onClick={() => setOpen(true)}
>
<Music className='mr-1 h-3 w-3' />
{t('Click to preview audio')}
</Button>
<Music className='size-3 text-muted-foreground' />
<span className='text-foreground leading-snug group-hover:underline'>
{t('Click to preview audio')}
</span>
</button>
<AudioPreviewDialog
open={open}
onOpenChange={setOpen}
@ -72,88 +77,128 @@ function AudioPreviewCell({ log }: { log: TaskLog }) {
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<TaskLog>[] = [
createTimestampColumn<TaskLog>({
{
accessorKey: 'submit_time',
title: t('Submit Time'),
unit: 'seconds',
}),
createTimestampColumn<TaskLog>({
accessorKey: 'finish_time',
title: t('Finish Time'),
unit: 'seconds',
}),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Submit Time')} />
),
cell: ({ row }) => {
const log = row.original
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, '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>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
unit: 'seconds',
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',
header: t('Status'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Status')} />
),
cell: ({ row }) => {
const status = row.getValue('status') as string
return (
@ -168,20 +213,18 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
},
meta: { label: t('Status') },
},
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
// Result/Fail Reason - Combined column
{
accessorKey: 'fail_reason',
header: t('Details'),
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Details')} />
),
cell: function DetailsCell({ row }) {
const log = row.original
const failReason = row.getValue('fail_reason') as string
const status = log.status
const [dialogOpen, setDialogOpen] = useState(false)
// Suno audio preview
const isSunoSuccess =
log.platform === 'suno' && status === TASK_STATUS.SUCCESS
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 =
log.action === TASK_ACTIONS.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 isUrl = failReason?.startsWith('http')
// If success and is a URL, show as result link
if (isSuccess && isVideoTask && isUrl) {
const videoUrl = `/v1/videos/${log.task_id}/content`
return (
@ -216,28 +257,29 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
href={videoUrl}
target='_blank'
rel='noopener noreferrer'
className='text-primary text-sm hover:underline'
className='text-xs text-foreground hover:underline'
>
{t('Click to preview video')}
</a>
)
}
// Otherwise, show fail reason (if any) using the existing dialog
if (!failReason) {
return <span className='text-muted-foreground text-sm'>-</span>
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<Button
variant='ghost'
className='h-auto max-w-[200px] justify-start overflow-hidden p-0 text-left text-sm font-normal text-red-600 hover:underline'
<button
type='button'
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view full error message')}
>
<span className='truncate'>{failReason}</span>
</Button>
<span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
{failReason}
</span>
</button>
<FailReasonDialog
failReason={failReason}
open={dialogOpen}

View File

@ -22,6 +22,13 @@ import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import { useUsageLogsContext } from './usage-logs-provider'
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 {
stats?: ReactNode
@ -45,7 +52,7 @@ export function CommonLogsFilterBar({
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [logType, setLogType] = useState<string>('')
const [logType, setLogType] = useState<LogTypeValue | ''>('')
useEffect(() => {
const next: Partial<CommonLogFilters> = {}
@ -163,7 +170,9 @@ export function CommonLogsFilterBar({
/>
<Select
value={logType}
onValueChange={(v) => setLogType(v === 'all' ? '' : v)}
onValueChange={(value) => {
setLogType(isLogTypeValue(value) ? value : '')
}}
>
<SelectTrigger className='h-9'>
<SelectValue placeholder={t('All Types')} />

View File

@ -38,7 +38,8 @@ import {
getTieredBillingSummary,
hasAnyCacheTokens,
isViolationFeeLog,
getTimeColor,
getFirstResponseTimeColor,
getResponseTimeColor,
} from '../../lib/format'
import {
getLogTypeConfig,
@ -47,6 +48,14 @@ import {
} from '../../lib/utils'
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: {
label: React.ReactNode
value: React.ReactNode
@ -545,18 +554,26 @@ export function DetailsDialog(props: DetailsDialogProps) {
<span
className={cn(
'font-medium',
getTimeColor(props.log.use_time) === 'success'
? 'text-emerald-600'
: getTimeColor(props.log.use_time) === 'info'
? 'text-sky-600'
: 'text-amber-600'
timingTextColorClass(
getResponseTimeColor(
props.log.use_time,
props.log.completion_tokens
)
)
)}
>
{formatUseTime(props.log.use_time)}
{props.log.is_stream &&
other?.frt != null &&
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)})
</span>

View File

@ -43,15 +43,6 @@ import { CommonLogsStats } from './common-logs-stats'
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> = {
[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',
@ -76,7 +67,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
pagination: { defaultPage: 1, defaultPageSize: 100 },
globalFilter: { enabled: false },
columnFilters: [
{ 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
| number
| undefined
const borderClass =
isCommon && logType != null
? logTypeBorderColor[logType] ?? 'border-l-transparent'
: ''
const tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
return (
<TableRow
key={row.id}
className={cn(
'!border-l-[3px] transition-colors',
borderClass,
tintClass
)}
className={cn('transition-colors', tintClass)}
>
{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())}
</TableCell>
))}
@ -236,7 +219,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
<Table>
<TableHeader className='bg-muted/30 sticky top-0 z-10'>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='border-l-[3px] border-l-transparent'>
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder

View File

@ -0,0 +1,24 @@
export interface LogAvatarStyle {
backgroundColor: string
color: string
}
function hashString(value: string): number {
let hash = 0
for (let i = 0; i < value.length; i++) {
hash = (hash * 31 + value.charCodeAt(i)) >>> 0
}
return hash
}
export function getLogAvatarStyle(name: string): LogAvatarStyle {
const hash = hashString(name)
const hue = hash % 360
const saturation = 54 + (hash % 8)
const lightness = 52 + ((hash >> 4) % 8)
return {
backgroundColor: `hsl(${hue} ${saturation}% ${lightness}% / 0.82)`,
color: 'white',
}
}

View File

@ -87,10 +87,45 @@ export function parseLogOther(other: string): LogOtherData | null {
/**
* Get time color based on duration (in seconds)
*/
export function getTimeColor(seconds: number): 'success' | 'info' | 'warning' {
if (seconds < 3) return 'success'
if (seconds < 10) return 'info'
return 'warning'
export function getTimeColor(
seconds: number
): 'success' | 'warning' | 'danger' {
if (seconds < 10) return 'success'
if (seconds < 30) return 'warning'
return 'danger'
}
/**
* Get first-response-token color based on latency (in seconds)
*/
export function getFirstResponseTimeColor(
seconds: number
): 'success' | 'warning' | 'danger' {
if (seconds < 5) return 'success'
if (seconds < 10) return 'warning'
return 'danger'
}
/**
* Get throughput color based on generated tokens per second
*/
export function getThroughputColor(
tokensPerSecond: number
): 'success' | 'warning' | 'danger' {
if (tokensPerSecond >= 30) return 'success'
if (tokensPerSecond >= 15) return 'warning'
return 'danger'
}
/**
* Get response color using throughput only when enough output tokens exist.
*/
export function getResponseTimeColor(
seconds: number,
completionTokens: number
): 'success' | 'warning' | 'danger' {
if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
return getThroughputColor(completionTokens / seconds)
}
/**

View File

@ -216,6 +216,7 @@ export interface MidjourneyLog {
export interface TaskLog {
id: number
user_id: number
username?: string
platform: string // suno, kling, runway, etc.
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.

View File

@ -365,7 +365,6 @@
"Auto detect (default)": "Auto detect (default)",
"Auto Disabled": "Auto Disabled",
"Auto Group Chain": "Auto Group Chain",
"Auto group enables circuit breaker mechanism": "Auto 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": "Set API key access restrictions",
"Set API key basic information": "Set API key basic information",
"Set Header": "Set Header",
"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 for selected channels": "Set tag for selected channels",
"Set the user's role (cannot be Root)": "Set the user's role (cannot be Root)",

View File

@ -365,7 +365,6 @@
"Auto detect (default)": "Détection automatique (par défaut)",
"Auto Disabled": "Désactivé automatiquement",
"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 Sync Upstream Models": "Synchronisation automatique des modèles en amont",
"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 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 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 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 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)",

View File

@ -365,7 +365,6 @@
"Auto detect (default)": "自動検出 (デフォルト)",
"Auto Disabled": "自動無効化",
"Auto Group Chain": "自動グループチェーン",
"Auto group enables circuit breaker mechanism": "自動グループはサーキットブレーカーメカニズムを有効にします",
"Auto refresh": "自動更新",
"Auto Sync Upstream Models": "アップストリームモデルの自動同期",
"Auto-disable status codes": "自動無効化するステータスコード",
@ -2975,8 +2974,11 @@
"Set a tag for": "のタグを設定",
"Set filters to customize your dashboard statistics and charts.": "ダッシュボードの統計とグラフをカスタマイズするためにフィルターを設定します。",
"Set filters to narrow down your log search results.": "ログ検索結果を絞り込むためにフィルターを設定します。",
"Set API key access restrictions": "API キーのアクセス制限を設定",
"Set API key basic information": "API キーの基本情報を設定",
"Set Header": "ヘッダーを設定",
"Set Project to io.cloud when creating/selecting key": "キーを作成/選択する際にプロジェクトを io.cloud に設定",
"Set quota amount and limits": "クォータ量と制限を設定",
"Set Tag": "タグを設定",
"Set tag for selected channels": "選択したチャネルにタグを設定",
"Set the user's role (cannot be Root)": "ユーザーのロールを設定しますRootにはできません",

View File

@ -365,7 +365,6 @@
"Auto detect (default)": "Автоматическое определение (по умолчанию)",
"Auto Disabled": "Автоматически отключено",
"Auto Group Chain": "Автоматическая цепочка групп",
"Auto group enables circuit breaker mechanism": "Автоматическая группировка включает механизм автоматического выключателя",
"Auto refresh": "Автообновление",
"Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера",
"Auto-disable status codes": "Коды автоотключения",
@ -2975,8 +2974,11 @@
"Set a tag for": "Установить тег для",
"Set filters to customize your dashboard statistics and charts.": "Установите фильтры, чтобы настроить статистику и диаграммы вашей панели управления.",
"Set filters to narrow down your log search results.": "Установите фильтры, чтобы сузить результаты поиска по журналам.",
"Set API key access restrictions": "Настройте ограничения доступа API-ключа",
"Set API key basic information": "Настройте основные сведения API-ключа",
"Set Header": "Установить заголовок",
"Set Project to io.cloud when creating/selecting key": "Установите Проект в io.cloud при создании/выборе ключа",
"Set quota amount and limits": "Настройте квоту и лимиты",
"Set Tag": "Установить тег",
"Set tag for selected channels": "Установить тег для выбранных каналов",
"Set the user's role (cannot be Root)": "Установить роль пользователя (не может быть Root)",

View File

@ -365,7 +365,6 @@
"Auto detect (default)": "Tự động phát hiện (mặc định)",
"Auto Disabled": "Vô hiệu hóa 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 Sync Upstream Models": "Tự động đồng bộ mô hình nguồn",
"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 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 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 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 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)",

View File

@ -359,13 +359,12 @@
"Authorization Endpoint (Optional)": "授权端点(可选)",
"Authorize": "授权",
"Auto": "自动",
"Auto (Circuit Breaker)": "自动(熔断机制",
"Auto (Circuit Breaker)": "自动分组(熔断)",
"Auto assignment order": "自动分配顺序",
"Auto Ban": "自动封禁",
"Auto detect (default)": "自动检测(默认)",
"Auto Disabled": "自动禁用",
"Auto Group Chain": "自动分组链",
"Auto group enables circuit breaker mechanism": "自动分组启用熔断机制",
"Auto refresh": "自动刷新",
"Auto Sync Upstream Models": "自动同步上游模型",
"Auto-disable status codes": "自动禁用状态码",
@ -377,7 +376,7 @@
"Automatically disable channels when tests fail": "当测试失败时自动禁用渠道",
"Automatically probe all channels in the background": "在后台自动探测所有渠道",
"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 test channels and notify users when limits are hit": "自动测试渠道并在达到限制时通知用户",
"Available": "可用",
@ -2975,8 +2974,11 @@
"Set a tag for": "设置标签为",
"Set filters to customize your dashboard statistics and charts.": "设置筛选器以自定义您的仪表板统计数据和图表。",
"Set filters to narrow down your log search results.": "设置筛选器以缩小日志搜索结果范围。",
"Set API key access restrictions": "设置令牌的访问限制",
"Set API key basic information": "设置令牌的基本信息",
"Set Header": "设请求头",
"Set Project to io.cloud when creating/selecting key": "创建/选择密钥时将项目设置为 io.cloud",
"Set quota amount and limits": "设置令牌可用额度和数量",
"Set Tag": "设置标签",
"Set tag for selected channels": "为选定的渠道设置标签",
"Set the user's role (cannot be Root)": "设置用户角色(不能是 Root",