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
19 changed files with 899 additions and 561 deletions
@@ -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(
@@ -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>
)
@@ -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>
@@ -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 },
}
@@ -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>
)
},
@@ -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'),
@@ -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}
@@ -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')} />
@@ -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>
@@ -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
+24
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',
}
}
+39 -4
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)
}
/**
+1
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.