feat(logs): add username to TaskLog interface and implement log avatar styling
This commit is contained in:
+84
-27
@@ -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 },
|
||||
}
|
||||
|
||||
+61
-48
@@ -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>
|
||||
)
|
||||
},
|
||||
|
||||
+142
-83
@@ -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'),
|
||||
|
||||
+139
-97
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user