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