revert: undo t0ng7u UI design-system refactor
Revert the following commits: -308e3e347feat(web): polish themed data views and add task log details -b2a890e75fix: Fontsource asset resolution across workspace layouts -9d1ca545erefactor(web): refine data-table cards and pricing page layout -0918bdb49refactor(web): consolidate design-system primitives and responsive data views -262ab9312style(web): unify design system across default frontend
This commit is contained in:
+40
-52
@@ -22,7 +22,7 @@ import { Zap } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { DataTableColumnHeader } from '@/components/data-table'
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { formatTimestampToDate, formatTokens } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { formatDuration } from '../../lib/format'
|
||||
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
|
||||
@@ -53,7 +54,7 @@ export function CacheTooltip({
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Zap className={`size-3 flex-shrink-0 ${color}`} />}
|
||||
/>
|
||||
></TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<p className='text-xs'>
|
||||
{label}: {formatTokens(tokens)}
|
||||
@@ -86,20 +87,15 @@ export function createTimestampColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue(accessorKey) as number
|
||||
if (!timestamp) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<span className='text-xs tabular-nums'>
|
||||
<span className='font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(timestamp, unit)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: title,
|
||||
cardRole: 'primary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { label: title },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,24 +131,32 @@ export function createDurationColumn<T>(config: {
|
||||
)
|
||||
|
||||
if (!duration) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
const variant =
|
||||
duration.durationSec > warningThresholdSec ? 'destructive' : 'success'
|
||||
duration.durationSec > warningThresholdSec ? 'danger' : 'success'
|
||||
|
||||
const durationBgMap: Record<string, string> = {
|
||||
success:
|
||||
'border border-emerald-200/40 bg-emerald-50/35 !text-emerald-600 dark:border-emerald-900/40 dark:bg-emerald-950/15 dark:!text-emerald-400',
|
||||
warning:
|
||||
'border border-amber-200/45 bg-amber-50/35 !text-amber-600 dark:border-amber-900/40 dark:bg-amber-950/15 dark:!text-amber-400',
|
||||
danger:
|
||||
'border border-rose-200/50 bg-rose-50/35 !text-red-600 dark:border-rose-900/40 dark:bg-rose-950/15 dark:!text-red-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge variant={variant} size='sm' className='tabular-nums'>
|
||||
{duration.durationSec.toFixed(1)}s
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`${duration.durationSec.toFixed(1)}s`}
|
||||
variant={variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className={cn('rounded-md font-mono', durationBgMap[variant])}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: headerLabel,
|
||||
cardRole: 'primary',
|
||||
cardOrder: 40,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { label: headerLabel },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,25 +177,20 @@ export function createChannelColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const channelId = row.getValue(accessorKey) as number
|
||||
if (!channelId) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<CopyableStatusBadge
|
||||
value={String(channelId)}
|
||||
variant='neutral'
|
||||
<StatusBadge
|
||||
label={`#${channelId}`}
|
||||
autoColor={String(channelId)}
|
||||
copyText={String(channelId)}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='font-mono'
|
||||
>
|
||||
#{channelId}
|
||||
</CopyableStatusBadge>
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: headerLabel,
|
||||
cardRole: 'primary',
|
||||
cardOrder: 20,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { label: headerLabel },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +214,7 @@ export function createFailReasonColumn<T>(config: {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!failReason) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -226,7 +225,7 @@ export function createFailReasonColumn<T>(config: {
|
||||
onClick={() => setDialogOpen(true)}
|
||||
title={cellTitle}
|
||||
>
|
||||
<span className='text-destructive truncate leading-snug group-hover:underline'>
|
||||
<span className='truncate leading-snug text-red-600 group-hover:underline dark:text-red-400'>
|
||||
{failReason}
|
||||
</span>
|
||||
</button>
|
||||
@@ -238,13 +237,7 @@ export function createFailReasonColumn<T>(config: {
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: headerLabel,
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 30,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { label: headerLabel },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,19 +258,14 @@ export function createProgressColumn<T>(config: {
|
||||
cell: ({ row }) => {
|
||||
const progress = row.getValue(accessorKey) as string
|
||||
if (!progress) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant='neutral' className='font-mono'>
|
||||
<span className='border-border/60 bg-muted/30 inline-flex items-center rounded-md border px-1.5 py-0.5 font-mono text-xs'>
|
||||
{progress}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: headerLabel,
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { label: headerLabel },
|
||||
}
|
||||
}
|
||||
|
||||
+132
-173
@@ -16,12 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { CircleAlert, GitBranch, Sparkles } from 'lucide-react'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { CircleAlert, GitBranch, Sparkles, KeyRound } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
Popover,
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useGroupRatios } from '@/hooks/use-group-ratios'
|
||||
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
|
||||
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
|
||||
import {
|
||||
@@ -80,10 +79,7 @@ function formatRatioCompact(ratio: number | undefined): string {
|
||||
: ratio.toFixed(4).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
function getGroupRatioText(
|
||||
other: LogOtherData | null,
|
||||
configuredGroupRatio?: number
|
||||
): string | null {
|
||||
function getGroupRatioText(other: LogOtherData | null): string | null {
|
||||
const userGroupRatio = other?.user_group_ratio
|
||||
if (
|
||||
userGroupRatio != null &&
|
||||
@@ -93,14 +89,20 @@ function getGroupRatioText(
|
||||
return `${formatRatioCompact(userGroupRatio)}x`
|
||||
}
|
||||
|
||||
const groupRatio = other?.group_ratio ?? configuredGroupRatio
|
||||
if (groupRatio != null && Number.isFinite(groupRatio)) {
|
||||
const groupRatio = other?.group_ratio
|
||||
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
|
||||
return `${formatRatioCompact(groupRatio)}x`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function splitQuotaDisplay(value: string): { prefix: string; amount: string } {
|
||||
const match = value.match(/^([^0-9+\-.,\s]+)(.+)$/)
|
||||
if (!match) return { prefix: '', amount: value }
|
||||
return { prefix: match[1], amount: match[2] }
|
||||
}
|
||||
|
||||
function buildDetailSegments(
|
||||
log: UsageLog,
|
||||
other: LogOtherData | null,
|
||||
@@ -221,10 +223,10 @@ function buildTypeDetailSegments(
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const modelPrice = other.model_price
|
||||
if (modelPrice != null && isPerCallBilling(modelPrice)) {
|
||||
const isPerCall = isPerCallBilling(other.model_price)
|
||||
if (isPerCall) {
|
||||
segments.push({
|
||||
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(modelPrice, priceOpts)}`,
|
||||
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(other.model_price!, priceOpts)}`,
|
||||
})
|
||||
} else if (other.model_ratio != null) {
|
||||
const inputPriceUSD = other.model_ratio * 2.0
|
||||
@@ -291,7 +293,6 @@ function buildTypeDetailSegments(
|
||||
|
||||
export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const { t } = useTranslation()
|
||||
const groupRatios = useGroupRatios()
|
||||
const columns: ColumnDef<UsageLog>[] = [
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
@@ -303,12 +304,16 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='text-xs tabular-nums'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</span>
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{t(config.label)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(config.label)}
|
||||
variant={config.color as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='!text-xs [&_span]:!text-xs'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -319,11 +324,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
},
|
||||
enableHiding: false,
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -370,23 +370,24 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
}
|
||||
>
|
||||
<div className='relative inline-flex w-fit items-center gap-1'>
|
||||
<CopyableStatusBadge
|
||||
value={String(log.channel)}
|
||||
variant='neutral'
|
||||
<StatusBadge
|
||||
label={channelIdDisplay}
|
||||
autoColor={String(log.channel)}
|
||||
copyText={String(log.channel)}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='font-mono'
|
||||
>
|
||||
{channelIdDisplay}
|
||||
</CopyableStatusBadge>
|
||||
/>
|
||||
{showMultiKeyIndex && (
|
||||
<StatusBadge
|
||||
label={String(multiKeyIndex)}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
variant='neutral'
|
||||
className='min-w-5 justify-center font-mono'
|
||||
className='h-5 min-w-5 justify-center rounded-full px-1 font-mono text-xs'
|
||||
aria-label={`${t('Key')} ${multiKeyIndex}`}
|
||||
>
|
||||
{multiKeyIndex}
|
||||
</StatusBadge>
|
||||
/>
|
||||
)}
|
||||
{hasRetryChain && (
|
||||
<Popover>
|
||||
@@ -401,7 +402,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
}
|
||||
>
|
||||
<GitBranch
|
||||
className='text-warning size-3.5'
|
||||
className='size-3.5 text-amber-500'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
@@ -422,7 +423,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
{affinity && (
|
||||
<button
|
||||
type='button'
|
||||
className='text-warning absolute -top-1 -right-1 leading-none'
|
||||
className='absolute -top-1 -right-1 leading-none text-amber-500'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setAffinityTarget({
|
||||
@@ -442,7 +443,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)}
|
||||
</div>
|
||||
{log.channel_name && (
|
||||
<span className='text-subtle-foreground truncate text-xs'>
|
||||
<span className='text-muted-foreground/70 truncate [font-family:var(--font-body)] !text-xs'>
|
||||
{channelName}
|
||||
</span>
|
||||
)}
|
||||
@@ -484,11 +485,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</TooltipProvider>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 20,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
@@ -514,7 +510,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
'text-[11px] font-semibold',
|
||||
!sensitiveVisible && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
style={
|
||||
@@ -543,11 +539,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 30,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -569,33 +560,25 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
if (!group) group = other?.group || ''
|
||||
|
||||
const metaParts: string[] = []
|
||||
const groupRatioText = getGroupRatioText(
|
||||
other,
|
||||
group ? groupRatios[group] : undefined
|
||||
)
|
||||
const groupRatioText = getGroupRatioText(other)
|
||||
if (group) {
|
||||
metaParts.push(sensitiveVisible ? group : '••••')
|
||||
}
|
||||
if (groupRatioText) metaParts.push(groupRatioText)
|
||||
|
||||
return (
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<div className='flex max-w-[200px] flex-col gap-0.5'>
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='shrink-0' />}>
|
||||
{sensitiveVisible ? (
|
||||
<CopyableStatusBadge
|
||||
value={tokenName}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
>
|
||||
{displayName}
|
||||
</CopyableStatusBadge>
|
||||
) : (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{displayName}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<TooltipTrigger render={<div className='max-w-full' />}>
|
||||
<StatusBadge
|
||||
label={displayName}
|
||||
icon={KeyRound}
|
||||
copyText={sensitiveVisible ? tokenName : undefined}
|
||||
size='sm'
|
||||
showDot={false}
|
||||
className='border-border/60 bg-muted/30 text-foreground h-6 max-w-full gap-1.5 overflow-hidden rounded-md border px-2 py-0.5 [font-family:var(--font-body)]'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
{sensitiveVisible && tokenName.length > 16 && (
|
||||
<TooltipContent side='top' className='max-w-xs break-all'>
|
||||
@@ -605,7 +588,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{metaParts.length > 0 && (
|
||||
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
|
||||
<span className='text-muted-foreground/60 truncate [font-family:var(--font-body)] !text-xs'>
|
||||
{metaParts.join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
@@ -613,12 +596,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
},
|
||||
size: 160,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 40,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
})
|
||||
columns.push(
|
||||
{
|
||||
@@ -639,12 +616,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'use_time',
|
||||
@@ -665,37 +637,53 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
? getFirstResponseTimeColor(frt / 1000)
|
||||
: 'neutral'
|
||||
|
||||
const timingBgMap: Record<string, string> = {
|
||||
success:
|
||||
'border border-emerald-200/40 bg-emerald-50/35 !text-emerald-600 dark:border-emerald-900/40 dark:bg-emerald-950/15 dark:!text-emerald-400',
|
||||
warning:
|
||||
'border border-amber-200/45 bg-amber-50/35 !text-amber-600 dark:border-amber-900/40 dark:bg-amber-950/15 dark:!text-amber-400',
|
||||
danger:
|
||||
'border border-rose-200/50 bg-rose-50/35 !text-red-600 dark:border-rose-900/40 dark:bg-rose-950/15 dark:!text-red-400',
|
||||
neutral:
|
||||
'border border-border/60 bg-muted/30 dark:border-border/40 dark:bg-muted/20',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<StatusBadge
|
||||
variant={timeVariant}
|
||||
label={formatUseTime(useTime)}
|
||||
variant={timeVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{formatUseTime(useTime)}
|
||||
</StatusBadge>
|
||||
copyable={false}
|
||||
className={cn('rounded-md font-mono', timingBgMap[timeVariant])}
|
||||
/>
|
||||
{log.is_stream &&
|
||||
(frt != null && frt > 0 ? (
|
||||
<StatusBadge
|
||||
variant={frtVariant}
|
||||
label={formatUseTime(frt / 1000)}
|
||||
variant={frtVariant as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{formatUseTime(frt / 1000)}
|
||||
</StatusBadge>
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn(
|
||||
'rounded-md font-mono',
|
||||
timingBgMap[frtVariant]
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge
|
||||
label='N/A'
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
N/A
|
||||
</StatusBadge>
|
||||
showDot={false}
|
||||
copyable={false}
|
||||
className={cn('rounded-md font-mono', timingBgMap.neutral)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className='flex items-center gap-1 text-xs leading-none'>
|
||||
<span className='text-subtle-foreground text-xs leading-none'>
|
||||
<div className='flex items-center gap-1 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
<span className='text-muted-foreground/60 [font-family:var(--font-body)] !text-xs leading-none'>
|
||||
{log.is_stream ? t('Stream') : t('Non-stream')}
|
||||
{tokensPerSecond != null && (
|
||||
<>
|
||||
@@ -713,10 +701,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<CircleAlert className='text-destructive size-3' />
|
||||
}
|
||||
/>
|
||||
render={<CircleAlert className='size-3 text-red-500' />}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className='space-y-0.5 text-xs'>
|
||||
<p>
|
||||
@@ -738,11 +724,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 50,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -770,19 +751,19 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='text-xs font-medium tabular-nums'>
|
||||
<span className='font-mono text-xs font-medium tabular-nums'>
|
||||
{promptTokens.toLocaleString()} /{' '}
|
||||
{completionTokens.toLocaleString()}
|
||||
</span>
|
||||
{(cacheReadTokens > 0 || cacheWriteTokens > 0) && (
|
||||
<div className='flex items-center gap-1 text-xs'>
|
||||
<div className='flex items-center gap-1 text-[11px]'>
|
||||
{cacheReadTokens > 0 && (
|
||||
<span className='text-subtle-foreground'>
|
||||
<span className='text-muted-foreground/60'>
|
||||
{t('Cache')}↓ {cacheReadTokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{cacheWriteTokens > 0 && (
|
||||
<span className='text-subtle-foreground'>
|
||||
<span className='text-muted-foreground/60'>
|
||||
↑ {cacheWriteTokens.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
@@ -791,12 +772,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 60,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -816,11 +791,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<span className='text-success cursor-help text-sm font-medium' />
|
||||
<StatusBadge
|
||||
label={t('Subscription')}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t('Subscription')}
|
||||
</TooltipTrigger>
|
||||
/>
|
||||
<TooltipContent>
|
||||
<span>
|
||||
{t('Deducted by subscription')}: {formatLogQuota(quota)}
|
||||
@@ -831,16 +810,20 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
)
|
||||
}
|
||||
|
||||
const quotaStr = formatLogQuota(quota)
|
||||
const quotaDisplay = splitQuotaDisplay(quotaStr)
|
||||
|
||||
return (
|
||||
<span className='text-sm font-medium tabular-nums'>
|
||||
{formatLogQuota(quota)}
|
||||
</span>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<span className='border-border/80 bg-muted/60 inline-flex h-6 w-fit items-center rounded-md border px-2 [font-family:var(--font-body)] text-sm leading-none font-semibold tabular-nums'>
|
||||
{quotaDisplay.prefix && (
|
||||
<span className='mr-1'>{quotaDisplay.prefix}</span>
|
||||
)}
|
||||
<span>{quotaDisplay.amount}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'badge',
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
@@ -850,59 +833,43 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const log = row.original
|
||||
const other = parseLogOther(log.other)
|
||||
const ip = log.ip.trim()
|
||||
|
||||
const segments = buildDetailSegments(log, other, t, isAdmin)
|
||||
const primary = segments[0]
|
||||
const hasMore = segments.length > 1
|
||||
let detailsContent = <span className='text-faint-foreground'>—</span>
|
||||
|
||||
if (log.content) {
|
||||
detailsContent = (
|
||||
<span className='text-muted-foreground truncate group-hover:underline'>
|
||||
{log.content}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (primary) {
|
||||
let primaryClassName = 'text-foreground'
|
||||
if (primary.muted) {
|
||||
primaryClassName = 'text-subtle-foreground'
|
||||
} else if (primary.danger) {
|
||||
primaryClassName = 'text-destructive'
|
||||
}
|
||||
|
||||
detailsContent = (
|
||||
<span
|
||||
className={cn(
|
||||
'truncate leading-snug group-hover:underline',
|
||||
primaryClassName
|
||||
)}
|
||||
>
|
||||
{primary.text}
|
||||
{hasMore && (
|
||||
<span className='text-faint-foreground ml-0.5'>
|
||||
+{segments.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='group flex max-w-[200px] flex-col gap-0.5 text-left text-xs'
|
||||
className='group flex max-w-[200px] items-center gap-1 text-left text-xs'
|
||||
onClick={() => setDialogOpen(true)}
|
||||
title={t('Click to view full details')}
|
||||
>
|
||||
{detailsContent}
|
||||
{ip && (
|
||||
<span className='text-subtle-foreground max-w-full truncate tabular-nums'>
|
||||
{ip}
|
||||
{primary ? (
|
||||
<span
|
||||
className={cn(
|
||||
'truncate leading-snug group-hover:underline',
|
||||
primary.muted
|
||||
? 'text-muted-foreground/60'
|
||||
: primary.danger
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{primary.text}
|
||||
{hasMore && (
|
||||
<span className='text-muted-foreground/40 ml-0.5'>
|
||||
+{segments.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : log.content ? (
|
||||
<span className='text-muted-foreground truncate group-hover:underline'>
|
||||
{log.content}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-muted-foreground/40'>—</span>
|
||||
)}
|
||||
</button>
|
||||
<DetailsDialog
|
||||
@@ -916,14 +883,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
||||
},
|
||||
size: 180,
|
||||
maxSize: 200,
|
||||
meta: {
|
||||
pinned: 'right',
|
||||
contentSized: true,
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+26
-55
@@ -38,7 +38,7 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
|
||||
import { MJ_TASK_TYPES } from '../../constants'
|
||||
@@ -95,24 +95,19 @@ export function useDrawingLogsColumns(
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='text-xs tabular-nums'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(submitTime, 'milliseconds')}
|
||||
</span>
|
||||
<StatusBadge
|
||||
label={t(mjStatusMapper.getLabel(log.status))}
|
||||
variant={mjStatusMapper.getVariant(log.status)}
|
||||
size='sm'
|
||||
>
|
||||
{t(mjStatusMapper.getLabel(log.status))}
|
||||
</StatusBadge>
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -127,19 +122,16 @@ export function useDrawingLogsColumns(
|
||||
header: t('Type'),
|
||||
cell: ({ row }) => {
|
||||
const action = row.getValue('action') as string
|
||||
const TypeIcon = getDrawingTypeIcon(action)
|
||||
return (
|
||||
<StatusBadge variant={mjTaskTypeMapper.getVariant(action)} size='sm'>
|
||||
<TypeIcon data-icon='inline-start' aria-hidden='true' />
|
||||
{t(mjTaskTypeMapper.getLabel(action))}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(mjTaskTypeMapper.getLabel(action))}
|
||||
variant={mjTaskTypeMapper.getVariant(action)}
|
||||
icon={getDrawingTypeIcon(action)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
})
|
||||
|
||||
columns.push({
|
||||
@@ -149,28 +141,22 @@ export function useDrawingLogsColumns(
|
||||
const mjId = row.getValue('mj_id') as string
|
||||
|
||||
if (!mjId) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<CopyableStatusBadge
|
||||
value={mjId}
|
||||
<div className='flex max-w-[160px] flex-col gap-0.5'>
|
||||
<StatusBadge
|
||||
label={mjId}
|
||||
copyText={mjId}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
>
|
||||
{mjId}
|
||||
</CopyableStatusBadge>
|
||||
className='border-border/60 bg-muted/30 !text-foreground max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 30,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
})
|
||||
|
||||
columns.push(
|
||||
@@ -190,17 +176,13 @@ export function useDrawingLogsColumns(
|
||||
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(mjSubmitResultMapper.getLabel(String(code)))}
|
||||
variant={mjSubmitResultMapper.getVariant(String(code))}
|
||||
size='sm'
|
||||
>
|
||||
{t(mjSubmitResultMapper.getLabel(String(code)))}
|
||||
</StatusBadge>
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'badge',
|
||||
contentMode: 'full',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -215,7 +197,7 @@ export function useDrawingLogsColumns(
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!imageUrl) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -226,7 +208,7 @@ export function useDrawingLogsColumns(
|
||||
onClick={() => setDialogOpen(true)}
|
||||
title={t('Click to view image')}
|
||||
>
|
||||
<span className='text-foreground leading-snug group-hover:underline'>
|
||||
<span className='text-foreground truncate leading-snug group-hover:underline'>
|
||||
{t('View')}
|
||||
</span>
|
||||
</button>
|
||||
@@ -239,11 +221,6 @@ export function useDrawingLogsColumns(
|
||||
</>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 50,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'prompt',
|
||||
@@ -254,7 +231,7 @@ export function useDrawingLogsColumns(
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
if (!prompt) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -280,12 +257,6 @@ export function useDrawingLogsColumns(
|
||||
},
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
},
|
||||
createFailReasonColumn<MidjourneyLog>({
|
||||
headerLabel: t('Fail Reason'),
|
||||
|
||||
+148
-88
@@ -17,23 +17,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { Music } from 'lucide-react'
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useState } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import {
|
||||
getTaskPlatformName,
|
||||
taskActionMapper,
|
||||
taskStatusMapper,
|
||||
} from '../../lib/mappers'
|
||||
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
|
||||
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
|
||||
import type { TaskLog } from '../../types'
|
||||
import { TaskDetailsDialog } from '../dialogs/task-details-dialog'
|
||||
import {
|
||||
AudioPreviewDialog,
|
||||
type AudioClip,
|
||||
} from '../dialogs/audio-preview-dialog'
|
||||
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
|
||||
import { useUsageLogsContext } from '../usage-logs-provider'
|
||||
import {
|
||||
createDurationColumn,
|
||||
@@ -41,6 +43,53 @@ import {
|
||||
createProgressColumn,
|
||||
} from './column-helpers'
|
||||
|
||||
function parseTaskData(data: unknown): unknown[] {
|
||||
if (Array.isArray(data)) return data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function AudioPreviewCell({ log }: { log: TaskLog }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const clips = useMemo(() => {
|
||||
const data = parseTaskData(log.data)
|
||||
return data.filter(
|
||||
(c) =>
|
||||
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
|
||||
)
|
||||
}, [log.data])
|
||||
|
||||
if (clips.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='group flex items-center gap-1 text-left text-xs'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Music className='text-muted-foreground size-3' />
|
||||
<span className='text-foreground leading-snug group-hover:underline'>
|
||||
{t('Click to preview audio')}
|
||||
</span>
|
||||
</button>
|
||||
<AudioPreviewDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
clips={clips as AudioClip[]}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const { t } = useTranslation()
|
||||
const columns: ColumnDef<TaskLog>[] = [
|
||||
@@ -53,25 +102,20 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col gap-0.5'>
|
||||
<span className='text-xs tabular-nums'>
|
||||
<span className='truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(submitTime, 'seconds')}
|
||||
</span>
|
||||
{log.finish_time ? (
|
||||
<span className='text-subtle-foreground text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground/60 truncate font-mono text-[11px] tabular-nums'>
|
||||
{formatTimestampToDate(log.finish_time, 'seconds')}
|
||||
</span>
|
||||
) : (
|
||||
<span className='text-subtle-foreground text-xs'>-</span>
|
||||
<span className='text-muted-foreground/50 text-[11px]'>-</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -99,7 +143,7 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
|
||||
<AvatarFallback
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
'text-[11px] font-semibold',
|
||||
!sensitiveVisible && 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
style={
|
||||
@@ -109,17 +153,12 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
{sensitiveVisible ? getUserAvatarFallback(displayName) : '•'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className='text-muted-foreground text-sm [overflow-wrap:anywhere] break-words hover:underline'>
|
||||
<span className='text-muted-foreground truncate text-sm hover:underline'>
|
||||
{sensitiveVisible ? displayName : '••••'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 30,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -131,30 +170,24 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
const log = row.original
|
||||
const taskId = row.getValue('task_id') as string
|
||||
if (!taskId) {
|
||||
return <span className='text-subtle-foreground text-xs'>-</span>
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<div className='flex w-max flex-col gap-0.5'>
|
||||
<CopyableStatusBadge
|
||||
value={taskId}
|
||||
<div className='flex max-w-[170px] flex-col gap-0.5'>
|
||||
<StatusBadge
|
||||
label={taskId}
|
||||
copyText={taskId}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
>
|
||||
{taskId}
|
||||
</CopyableStatusBadge>
|
||||
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
|
||||
{getTaskPlatformName(log.platform)} ·{' '}
|
||||
{t(taskActionMapper.getLabel(log.action))}
|
||||
className='border-border/60 bg-muted/30 !text-foreground max-w-full truncate rounded-md border 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: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
createDurationColumn<TaskLog>({
|
||||
submitTimeKey: 'submit_time',
|
||||
@@ -169,65 +202,92 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as string
|
||||
return (
|
||||
<StatusBadge variant={taskStatusMapper.getVariant(status)} size='sm'>
|
||||
{t(taskStatusMapper.getLabel(status, status || 'Submitting'))}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(taskStatusMapper.getLabel(status, status || 'Submitting'))}
|
||||
variant={taskStatusMapper.getVariant(status)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
cardRole: 'badge',
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
|
||||
{
|
||||
id: 'details',
|
||||
accessorKey: 'fail_reason',
|
||||
header: t('Details'),
|
||||
enableSorting: false,
|
||||
cell: function DetailsCell({ row }) {
|
||||
return <TaskDetailsCell log={row.original} isAdmin={isAdmin} />
|
||||
},
|
||||
size: 120,
|
||||
maxSize: 140,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
const log = row.original
|
||||
const failReason = row.getValue('fail_reason') as string
|
||||
const status = log.status
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
|
||||
const isSunoSuccess =
|
||||
log.platform === 'suno' && status === TASK_STATUS.SUCCESS
|
||||
if (isSunoSuccess) {
|
||||
const data = parseTaskData(log.data)
|
||||
if (
|
||||
data.some(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
(c as Record<string, unknown>).audio_url
|
||||
)
|
||||
) {
|
||||
return <AudioPreviewCell log={log} />
|
||||
}
|
||||
}
|
||||
|
||||
const isVideoTask =
|
||||
log.action === TASK_ACTIONS.GENERATE ||
|
||||
log.action === TASK_ACTIONS.TEXT_GENERATE ||
|
||||
log.action === TASK_ACTIONS.FIRST_TAIL_GENERATE ||
|
||||
log.action === TASK_ACTIONS.REFERENCE_GENERATE ||
|
||||
log.action === TASK_ACTIONS.REMIX_GENERATE
|
||||
const isSuccess = status === TASK_STATUS.SUCCESS
|
||||
const isUrl = failReason?.startsWith('http')
|
||||
|
||||
if (isSuccess && isVideoTask && isUrl) {
|
||||
const videoUrl = `/v1/videos/${log.task_id}/content`
|
||||
return (
|
||||
<a
|
||||
href={videoUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-foreground text-xs hover:underline'
|
||||
>
|
||||
{t('Click to preview video')}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (!failReason) {
|
||||
return <span className='text-muted-foreground/60 text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 leading-snug text-red-600 group-hover:underline dark:text-red-400'>
|
||||
{failReason}
|
||||
</span>
|
||||
</button>
|
||||
<FailReasonDialog
|
||||
failReason={failReason}
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
size: 200,
|
||||
maxSize: 220,
|
||||
}
|
||||
)
|
||||
|
||||
return columns
|
||||
}
|
||||
|
||||
function TaskDetailsCell({ log, isAdmin }: { log: TaskLog; isAdmin: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const isFailed = !!log.fail_reason && log.fail_reason.trim() !== ''
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'text-xs leading-snug hover:underline',
|
||||
isFailed ? 'text-destructive' : 'text-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(true)
|
||||
}}
|
||||
title={t('View the complete details for this task')}
|
||||
>
|
||||
{t('View')}
|
||||
</button>
|
||||
<TaskDetailsDialog
|
||||
log={log}
|
||||
isAdmin={isAdmin}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -275,10 +275,10 @@ export function CommonLogsFilterBar<TData>(
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
className='text-muted-foreground hover:text-foreground size-7'
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -48,10 +48,10 @@ export function CommonLogsHeaderActions() {
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='icon'
|
||||
onClick={() => setSensitiveVisible(!sensitiveVisible)}
|
||||
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
|
||||
className='text-muted-foreground hover:text-foreground'
|
||||
className='text-muted-foreground hover:text-foreground size-7'
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -20,10 +20,10 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { formatLogQuota } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { getLogStats, getUserLogStats } from '../api'
|
||||
import { DEFAULT_LOG_STATS } from '../constants'
|
||||
@@ -34,23 +34,17 @@ const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
function StatBadge(props: {
|
||||
label: string
|
||||
tone: 'usage' | 'rpm' | 'tpm'
|
||||
value: string | number
|
||||
accent: string
|
||||
}) {
|
||||
let labelClassName = 'text-foreground'
|
||||
if (props.tone === 'usage') {
|
||||
labelClassName = 'text-status-info'
|
||||
} else if (props.tone === 'rpm') {
|
||||
labelClassName = 'text-metric-rpm'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant='outline' className='h-6 gap-2'>
|
||||
<span className={labelClassName}>{props.label}</span>
|
||||
<span className='text-foreground/85 font-semibold tabular-nums'>
|
||||
<span className='border-border/60 bg-muted/25 inline-flex h-7 items-center gap-2 rounded-md border px-2.5 text-xs shadow-xs'>
|
||||
<span className={cn('h-3.5 w-0.5 rounded-full', props.accent)} />
|
||||
<span className='text-muted-foreground'>{props.label}</span>
|
||||
<span className='text-foreground/85 font-mono font-semibold tabular-nums'>
|
||||
{props.value}
|
||||
</span>
|
||||
</Badge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,11 +90,19 @@ export function CommonLogsStats() {
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<StatBadge
|
||||
label={t('Usage')}
|
||||
tone='usage'
|
||||
value={sensitiveVisible ? formatLogQuota(stats?.quota || 0) : '••••'}
|
||||
accent='bg-sky-500/70'
|
||||
/>
|
||||
<StatBadge
|
||||
label={t('RPM')}
|
||||
value={stats?.rpm || 0}
|
||||
accent='bg-rose-500/65'
|
||||
/>
|
||||
<StatBadge
|
||||
label={t('TPM')}
|
||||
value={stats?.tpm || 0}
|
||||
accent='bg-slate-400/70'
|
||||
/>
|
||||
<StatBadge label={t('RPM')} tone='rpm' value={stats?.rpm || 0} />
|
||||
<StatBadge label={t('TPM')} tone='tpm' value={stats?.tpm || 0} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+17
-10
@@ -20,8 +20,8 @@ import { CalendarDays } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -148,7 +148,7 @@ export function CompactDateTimeRangePicker({
|
||||
type='datetime-local'
|
||||
value={draftStart}
|
||||
onChange={(e) => setDraftStart(e.target.value)}
|
||||
className='text-sm leading-5 tabular-nums'
|
||||
className='h-8 text-sm leading-5 tabular-nums'
|
||||
/>
|
||||
</div>
|
||||
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
||||
@@ -162,7 +162,7 @@ export function CompactDateTimeRangePicker({
|
||||
type='datetime-local'
|
||||
value={draftEnd}
|
||||
onChange={(e) => setDraftEnd(e.target.value)}
|
||||
className='text-sm leading-5 tabular-nums'
|
||||
className='h-8 text-sm leading-5 tabular-nums'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,7 +171,8 @@ export function CompactDateTimeRangePicker({
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('today')}
|
||||
>
|
||||
{t('Today')}
|
||||
@@ -179,7 +180,8 @@ export function CompactDateTimeRangePicker({
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('7d')}
|
||||
>
|
||||
{t('7 Days')}
|
||||
@@ -187,7 +189,8 @@ export function CompactDateTimeRangePicker({
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('week')}
|
||||
>
|
||||
{t('This week')}
|
||||
@@ -195,7 +198,8 @@ export function CompactDateTimeRangePicker({
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('30d')}
|
||||
>
|
||||
{t('30 Days')}
|
||||
@@ -203,7 +207,8 @@ export function CompactDateTimeRangePicker({
|
||||
<Button
|
||||
type='button'
|
||||
variant='secondary'
|
||||
className='flex-1'
|
||||
size='sm'
|
||||
className='h-7 flex-1 px-2 text-xs'
|
||||
onClick={() => applyPreset('month')}
|
||||
>
|
||||
{t('This month')}
|
||||
@@ -211,7 +216,9 @@ export function CompactDateTimeRangePicker({
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end'>
|
||||
<Button onClick={applyDraft}>{t('Confirm')}</Button>
|
||||
<Button size='sm' className='h-8' onClick={applyDraft}>
|
||||
{t('Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
+57
-6
@@ -16,13 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { ExternalLink, Copy } from 'lucide-react'
|
||||
import { ExternalLink, Copy, Music } from 'lucide-react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
export interface AudioClip {
|
||||
clip_id?: string
|
||||
@@ -39,6 +41,12 @@ export interface AudioClip {
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioPreviewDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
clips: AudioClip[]
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return '--:--'
|
||||
const m = Math.floor(seconds / 60)
|
||||
@@ -46,7 +54,7 @@ function formatDuration(seconds?: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
@@ -80,9 +88,12 @@ export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
<div className='mb-1 flex items-center gap-2'>
|
||||
<span className='truncate text-sm font-medium'>{title}</span>
|
||||
{duration != null && duration > 0 && (
|
||||
<StatusBadge variant='neutral' className='shrink-0'>
|
||||
{formatDuration(duration)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={formatDuration(duration)}
|
||||
variant='neutral'
|
||||
className='shrink-0'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,6 +108,8 @@ export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1 text-xs'
|
||||
onClick={() => window.open(audioUrl, '_blank')}
|
||||
>
|
||||
<ExternalLink className='h-3 w-3' />
|
||||
@@ -104,6 +117,8 @@ export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 gap-1 text-xs'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(audioUrl)
|
||||
toast.success(t('Copied'))
|
||||
@@ -127,3 +142,39 @@ export function AudioClipCard({ clip }: { clip: AudioClip }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AudioPreviewDialog(props: AudioPreviewDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const clips = Array.isArray(props.clips) ? props.clips : []
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={
|
||||
<>
|
||||
<Music className='h-5 w-5' />
|
||||
{t('Audio Preview')}
|
||||
</>
|
||||
}
|
||||
contentClassName='sm:max-w-lg'
|
||||
titleClassName='flex items-center gap-2'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<p className='text-muted-foreground py-4 text-center text-sm'>
|
||||
{t('None')}
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className='max-h-[60vh]'>
|
||||
<div className='space-y-3 pr-2'>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
+98
-71
@@ -33,9 +33,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { DynamicPricingBreakdown } from '@/features/pricing/components/dynamic-pricing-breakdown'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
@@ -74,6 +74,14 @@ const CHANNEL_FIELD_LABELS: Record<string, string> = {
|
||||
key: 'Key',
|
||||
}
|
||||
|
||||
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
|
||||
@@ -101,16 +109,16 @@ function DetailRow(props: {
|
||||
function DetailSection(props: {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
variant?: 'default' | 'destructive'
|
||||
variant?: 'default' | 'danger'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const isDestructive = props.variant === 'destructive'
|
||||
const isDanger = props.variant === 'danger'
|
||||
return (
|
||||
<div className='min-w-0 space-y-1.5'>
|
||||
<Label
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-xs font-semibold',
|
||||
isDestructive && 'text-destructive'
|
||||
isDanger && 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{props.icon}
|
||||
@@ -119,8 +127,8 @@ function DetailSection(props: {
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 space-y-1 overflow-hidden rounded-md border p-2.5 max-sm:p-2',
|
||||
isDestructive
|
||||
? 'border-destructive/25 bg-destructive/10'
|
||||
isDanger
|
||||
? 'border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950/20'
|
||||
: 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
@@ -327,8 +335,8 @@ function BillingBreakdown(props: {
|
||||
|
||||
return (
|
||||
<DetailSection label={t('Billing Details')}>
|
||||
{rows.map((row) => (
|
||||
<DetailRow key={row.label} label={row.label} value={row.value} mono />
|
||||
{rows.map((row, idx) => (
|
||||
<DetailRow key={idx} label={row.label} value={row.value} mono />
|
||||
))}
|
||||
</DetailSection>
|
||||
)
|
||||
@@ -393,8 +401,8 @@ function TokenBreakdown(props: { log: UsageLog; other: LogOtherData }) {
|
||||
|
||||
return (
|
||||
<DetailSection label={t('Token Breakdown')}>
|
||||
{rows.map((row) => (
|
||||
<DetailRow key={row.label} label={row.label} value={row.value} mono />
|
||||
{rows.map((row, idx) => (
|
||||
<DetailRow key={idx} label={row.label} value={row.value} mono />
|
||||
))}
|
||||
</DetailSection>
|
||||
)
|
||||
@@ -413,12 +421,6 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
const details = props.log.content ?? ''
|
||||
const other = parseLogOther(props.log.other)
|
||||
const typeConfig = getLogTypeConfig(props.log.type)
|
||||
let reasoningEffortVariant: StatusVariant = 'success'
|
||||
if (other?.reasoning_effort === 'high') {
|
||||
reasoningEffortVariant = 'warning'
|
||||
} else if (other?.reasoning_effort === 'medium') {
|
||||
reasoningEffortVariant = 'info'
|
||||
}
|
||||
|
||||
const isViolation = isViolationFeeLog(other)
|
||||
const isRefund = props.log.type === 6
|
||||
@@ -549,9 +551,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
title={
|
||||
<>
|
||||
{t('Log Details')}
|
||||
<StatusBadge variant={typeConfig.variant} size='sm'>
|
||||
{t(typeConfig.label)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(typeConfig.label)}
|
||||
variant={typeConfig.color as StatusBadgeProps['variant']}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
description={t('View the complete details for this log entry')}
|
||||
@@ -623,10 +628,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
label={t('IP Address')}
|
||||
value={
|
||||
<span className='flex items-center gap-1'>
|
||||
<Globe
|
||||
className='text-muted-foreground size-3'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<Globe className='size-3 text-amber-500' aria-hidden='true' />
|
||||
{props.log.ip}
|
||||
</span>
|
||||
}
|
||||
@@ -638,25 +640,32 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailRow
|
||||
label={t('Response Time')}
|
||||
value={
|
||||
<span className='flex flex-wrap items-center gap-1'>
|
||||
<StatusBadge
|
||||
appearance='plain'
|
||||
variant={getResponseTimeColor(
|
||||
props.log.use_time,
|
||||
props.log.completion_tokens
|
||||
)}
|
||||
>
|
||||
{formatUseTime(props.log.use_time)}
|
||||
</StatusBadge>
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium',
|
||||
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 && (
|
||||
<StatusBadge
|
||||
appearance='plain'
|
||||
variant={getFirstResponseTimeColor(other.frt / 1000)}
|
||||
<span
|
||||
className={cn(
|
||||
'font-normal',
|
||||
timingTextColorClass(
|
||||
getFirstResponseTimeColor(other.frt / 1000)
|
||||
)
|
||||
)}
|
||||
>
|
||||
{`(FRT: ${formatUseTime(other.frt / 1000)})`}
|
||||
</StatusBadge>
|
||||
{' '}
|
||||
(FRT: {formatUseTime(other.frt / 1000)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
@@ -670,14 +679,14 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<div className='relative min-w-0'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
className='absolute top-0 right-0'
|
||||
size='sm'
|
||||
className='absolute top-0 right-0 h-5 w-5 p-0'
|
||||
onClick={() => copyToClipboard(conversionLabel)}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === conversionLabel ? (
|
||||
<Check className='text-success size-3' />
|
||||
<Check className='size-3 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
@@ -709,7 +718,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailSection
|
||||
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Quota clamped')}
|
||||
variant='destructive'
|
||||
variant='danger'
|
||||
>
|
||||
<p className='mb-1 text-xs wrap-break-word'>
|
||||
{t('Quota saturation protection triggered')}
|
||||
@@ -744,7 +753,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailSection
|
||||
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Reject Reason')}
|
||||
variant='destructive'
|
||||
variant='danger'
|
||||
>
|
||||
<p className='text-xs wrap-break-word'>{other.reject_reason}</p>
|
||||
</DetailSection>
|
||||
@@ -755,7 +764,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailSection
|
||||
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Violation Fee')}
|
||||
variant='destructive'
|
||||
variant='danger'
|
||||
>
|
||||
{other.violation_fee_code && (
|
||||
<DetailRow
|
||||
@@ -796,16 +805,16 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
icon={<ShieldCheck className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Top-up Audit Info')}
|
||||
>
|
||||
{topupAuditFields.map((field) => (
|
||||
{topupAuditFields.map((field, idx) => (
|
||||
<DetailRow
|
||||
key={field.label}
|
||||
key={idx}
|
||||
label={field.label}
|
||||
value={field.value}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
{showLegacyTopupWarning && (
|
||||
<div className='text-warning flex items-start gap-1.5 text-xs'>
|
||||
<div className='flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400'>
|
||||
<Info className='mt-0.5 size-3.5 shrink-0' aria-hidden='true' />
|
||||
<span>
|
||||
{t(
|
||||
@@ -885,9 +894,9 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
{operationText != null && (
|
||||
<DetailRow label={t('Operation')} value={operationText} />
|
||||
)}
|
||||
{loginAuditFields.map((field) => (
|
||||
{loginAuditFields.map((field, idx) => (
|
||||
<DetailRow
|
||||
key={field.label}
|
||||
key={idx}
|
||||
label={field.label}
|
||||
value={field.value}
|
||||
mono
|
||||
@@ -938,9 +947,18 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailRow
|
||||
label={t('Reasoning Effort')}
|
||||
value={
|
||||
<StatusBadge variant={reasoningEffortVariant} size='sm'>
|
||||
{other.reasoning_effort}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={other.reasoning_effort}
|
||||
variant={
|
||||
other.reasoning_effort === 'high'
|
||||
? 'orange'
|
||||
: other.reasoning_effort === 'medium'
|
||||
? 'yellow'
|
||||
: 'green'
|
||||
}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -950,9 +968,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailRow
|
||||
label={t('System Prompt')}
|
||||
value={
|
||||
<StatusBadge variant='warning' size='sm'>
|
||||
{t('Overwritten')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Overwritten')}
|
||||
variant='orange'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -1009,9 +1030,9 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
value={
|
||||
<span className='flex items-center gap-1'>
|
||||
{other.admin_info.local_count_tokens ? (
|
||||
<Monitor className='text-muted-foreground size-3' />
|
||||
<Monitor className='size-3 text-blue-500' />
|
||||
) : (
|
||||
<Cloud className='text-muted-foreground size-3' />
|
||||
<Cloud className='size-3 text-emerald-500' />
|
||||
)}
|
||||
<span className='text-xs'>
|
||||
{other.admin_info.local_count_tokens
|
||||
@@ -1031,9 +1052,12 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<DetailRow
|
||||
label={t('Status')}
|
||||
value={
|
||||
<StatusBadge variant='destructive' size='sm'>
|
||||
{other.stream_status.status || t('Error')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={other.stream_status.status || t('Error')}
|
||||
variant='red'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{other.stream_status.end_reason && (
|
||||
@@ -1056,7 +1080,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
)}
|
||||
{Array.isArray(other.stream_status.errors) &&
|
||||
other.stream_status.errors.length > 0 && (
|
||||
<pre className='bg-background/60 mt-1 max-h-32 overflow-y-auto rounded border p-2 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap'>
|
||||
<pre className='bg-background/60 mt-1 max-h-32 overflow-y-auto rounded border p-2 font-mono text-[11px] leading-relaxed wrap-break-word whitespace-pre-wrap'>
|
||||
{other.stream_status.errors.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
@@ -1117,18 +1141,21 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
icon={<Settings2 className='size-3.5' aria-hidden='true' />}
|
||||
label={`${t('Param Override')} (${other.po.length})`}
|
||||
>
|
||||
{other.po.filter(Boolean).map((line) => {
|
||||
{other.po.filter(Boolean).map((line, idx) => {
|
||||
const parsed = parseAuditLine(line)
|
||||
if (!parsed) return null
|
||||
return (
|
||||
<div
|
||||
key={line}
|
||||
key={idx}
|
||||
className='bg-background/60 flex min-w-0 flex-col gap-1.5 rounded border p-2 sm:flex-row sm:items-start sm:gap-2'
|
||||
>
|
||||
<StatusBadge variant='neutral' className='shrink-0'>
|
||||
{getParamOverrideActionLabel(parsed.action, t)}
|
||||
</StatusBadge>
|
||||
<span className='min-w-0 font-mono text-xs leading-relaxed break-all sm:wrap-break-word'>
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
label={getParamOverrideActionLabel(parsed.action, t)}
|
||||
className='shrink-0 font-medium'
|
||||
copyable={false}
|
||||
/>
|
||||
<span className='min-w-0 font-mono text-[11px] leading-relaxed break-all sm:wrap-break-word'>
|
||||
{parsed.content}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1144,14 +1171,14 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
||||
<div className='bg-muted/30 relative min-w-0 overflow-hidden rounded-md border p-2.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
className='absolute top-1.5 right-1.5'
|
||||
size='sm'
|
||||
className='absolute top-1.5 right-1.5 h-5 w-5 p-0'
|
||||
onClick={() => copyToClipboard(details)}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === details ? (
|
||||
<Check className='text-success size-3' />
|
||||
<Check className='size-3 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
|
||||
+6
-6
@@ -19,8 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
@@ -55,21 +55,21 @@ export function FailReasonDialog({
|
||||
<Label className='text-sm font-semibold'>
|
||||
{t('Error Message')}
|
||||
</Label>
|
||||
<div className='border-destructive/25 bg-destructive/8 relative rounded-md border p-3'>
|
||||
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute top-2 right-2'
|
||||
size='sm'
|
||||
className='absolute top-2 right-2 h-8 w-8 p-0'
|
||||
onClick={() => copyToClipboard(failReason)}
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === failReason ? (
|
||||
<Check className='text-success size-4' />
|
||||
<Check className='size-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<p className='text-status-destructive overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap'>
|
||||
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
|
||||
{failReason || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
@@ -59,13 +59,13 @@ export function PromptDialog({
|
||||
<div className='bg-muted/50 relative rounded-md border p-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute top-2 right-2'
|
||||
size='sm'
|
||||
className='absolute top-2 right-2 h-8 w-8 p-0'
|
||||
onClick={() => copyToClipboard(prompt)}
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === prompt ? (
|
||||
<Check className='text-success size-4' />
|
||||
<Check className='size-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
@@ -85,13 +85,13 @@ export function PromptDialog({
|
||||
<div className='bg-muted/50 relative rounded-md border p-3'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='absolute top-2 right-2'
|
||||
size='sm'
|
||||
className='absolute top-2 right-2 h-8 w-8 p-0'
|
||||
onClick={() => copyToClipboard(promptEn)}
|
||||
title={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === promptEn ? (
|
||||
<Check className='text-success size-4' />
|
||||
<Check className='size-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='size-4' />
|
||||
)}
|
||||
|
||||
-633
@@ -1,633 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Music,
|
||||
Video,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TASK_ACTIONS, TASK_PLATFORMS, TASK_STATUS } from '../../constants'
|
||||
import { formatDuration } from '../../lib/format'
|
||||
import {
|
||||
getTaskPlatformName,
|
||||
taskActionMapper,
|
||||
taskStatusMapper,
|
||||
} from '../../lib/mappers'
|
||||
import type { TaskLog } from '../../types'
|
||||
import { AudioClipCard, type AudioClip } from './audio-preview-dialog'
|
||||
|
||||
const VIDEO_ACTIONS = new Set<string>([
|
||||
TASK_ACTIONS.GENERATE,
|
||||
TASK_ACTIONS.TEXT_GENERATE,
|
||||
TASK_ACTIONS.FIRST_TAIL_GENERATE,
|
||||
TASK_ACTIONS.REFERENCE_GENERATE,
|
||||
TASK_ACTIONS.REMIX_GENERATE,
|
||||
])
|
||||
|
||||
const MAX_VALUE_LENGTH = 800
|
||||
|
||||
function parseJson(raw: unknown): unknown {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
// snake_case / camelCase -> "Title Case" with common acronym fixes, so any
|
||||
// upstream provider payload renders with readable labels.
|
||||
function humanizeKey(key: string): string {
|
||||
const spaced = key
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.trim()
|
||||
return spaced
|
||||
.replaceAll(/\b\w/g, (c) => c.toUpperCase())
|
||||
.replaceAll(/\bUrl\b/g, 'URL')
|
||||
.replaceAll(/\bId\b/g, 'ID')
|
||||
.replaceAll(/\bApi\b/g, 'API')
|
||||
.replaceAll(/\bFps\b/g, 'FPS')
|
||||
}
|
||||
|
||||
function formatScalar(value: unknown): string {
|
||||
if (value == null) return '-'
|
||||
const str = typeof value === 'string' ? value : String(value)
|
||||
if (str.length > MAX_VALUE_LENGTH) return `${str.slice(0, MAX_VALUE_LENGTH)}…`
|
||||
return str
|
||||
}
|
||||
|
||||
interface FlatEntry {
|
||||
label: string
|
||||
value: string
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
// Flatten a nested upstream payload into readable rows. Nested objects are
|
||||
// prefixed with their parent label ("Cost › Currency"); arrays of scalars are
|
||||
// joined, arrays of objects are shown as compact JSON.
|
||||
function flattenEntries(
|
||||
obj: Record<string, unknown>,
|
||||
parent = ''
|
||||
): FlatEntry[] {
|
||||
const entries: FlatEntry[] = []
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const label = parent ? `${parent} › ${humanizeKey(key)}` : humanizeKey(key)
|
||||
if (isPlainObject(value)) {
|
||||
entries.push(...flattenEntries(value, label))
|
||||
} else if (Array.isArray(value)) {
|
||||
const allScalar = value.every((v) => v == null || typeof v !== 'object')
|
||||
if (allScalar) {
|
||||
entries.push({
|
||||
label,
|
||||
value: value.map(formatScalar).join(', ') || '-',
|
||||
})
|
||||
} else {
|
||||
entries.push({
|
||||
label,
|
||||
value: formatScalar(JSON.stringify(value)),
|
||||
mono: true,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
entries.push({
|
||||
label,
|
||||
value: formatScalar(value),
|
||||
mono: typeof value !== 'boolean',
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// The DTO's result_url falls back to fail_reason when no dedicated URL is
|
||||
// stored, so only treat http(s) links or the internal video proxy path as a
|
||||
// playable URL.
|
||||
function resolveVideoUrl(log: TaskLog): string {
|
||||
const raw = (log.result_url ?? '').trim()
|
||||
const isHttp = /^https?:\/\//i.test(raw)
|
||||
const isProxy = raw.startsWith('/v1/videos/')
|
||||
if (isHttp || isProxy) return raw
|
||||
if (VIDEO_ACTIONS.has(log.action) && log.status === TASK_STATUS.SUCCESS) {
|
||||
if (log.task_id) return `/v1/videos/${log.task_id}/content`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function DetailRow(props: {
|
||||
label: React.ReactNode
|
||||
value: React.ReactNode
|
||||
mono?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className='grid min-w-0 grid-cols-[6rem_minmax(0,1fr)] gap-2 text-sm sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-3'>
|
||||
<span className='text-muted-foreground min-w-0 text-xs'>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-full min-w-0 text-xs break-all sm:wrap-break-word',
|
||||
props.mono && 'font-mono'
|
||||
)}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailSection(props: {
|
||||
icon?: React.ReactNode
|
||||
label: string
|
||||
variant?: 'default' | 'destructive'
|
||||
action?: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const isDestructive = props.variant === 'destructive'
|
||||
return (
|
||||
<div className='min-w-0 space-y-1.5'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<Label
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-xs font-semibold',
|
||||
isDestructive && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{props.icon}
|
||||
{props.label}
|
||||
</Label>
|
||||
{props.action}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 space-y-1 overflow-hidden rounded-md border p-2.5 max-sm:p-2',
|
||||
isDestructive
|
||||
? 'border-destructive/25 bg-destructive/10'
|
||||
: 'bg-muted/30'
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyButton(props: {
|
||||
text: string
|
||||
copied: boolean
|
||||
onCopy: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={props.onCopy}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{props.copied ? (
|
||||
<Check className='text-success size-3' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function VideoPreview({ url }: { url: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Failed to load video')}
|
||||
</span>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
{t('Open in new tab')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload='metadata'
|
||||
onError={() => setHasError(true)}
|
||||
className='bg-background max-h-[420px] w-full rounded-md border'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioPreview({ clips }: { clips: AudioClip[] }) {
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
{clips.map((clip, idx) => (
|
||||
<AudioClipCard key={clip.clip_id || clip.id || idx} clip={clip} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TaskDetailsDialogProps {
|
||||
log: TaskLog
|
||||
isAdmin: boolean
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function TaskDetailsDialog(props: TaskDetailsDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const { log, isAdmin } = props
|
||||
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
|
||||
|
||||
const platformName = getTaskPlatformName(log.platform)
|
||||
const duration = formatDuration(log.submit_time, log.finish_time, 'seconds')
|
||||
const videoUrl = resolveVideoUrl(log)
|
||||
const resultUrl = (log.result_url ?? '').trim()
|
||||
const isResultLink =
|
||||
/^https?:\/\//i.test(resultUrl) || resultUrl.startsWith('/v1/videos/')
|
||||
|
||||
const parsedData = useMemo(() => parseJson(log.data), [log.data])
|
||||
const props_ = useMemo(() => {
|
||||
if (isPlainObject(log.properties)) return log.properties
|
||||
const parsed = parseJson(log.properties)
|
||||
return isPlainObject(parsed) ? parsed : null
|
||||
}, [log.properties])
|
||||
|
||||
const asString = (v: unknown): string | undefined =>
|
||||
typeof v === 'string' && v !== '' ? v : undefined
|
||||
const originModel = asString(props_?.origin_model_name)
|
||||
const upstreamModel = asString(props_?.upstream_model_name)
|
||||
const dataModel = isPlainObject(parsedData)
|
||||
? asString(parsedData.model)
|
||||
: undefined
|
||||
const model = originModel || upstreamModel || dataModel
|
||||
|
||||
const audioClips = useMemo(() => {
|
||||
if (log.platform !== TASK_PLATFORMS.SUNO) return []
|
||||
if (log.status !== TASK_STATUS.SUCCESS) return []
|
||||
if (!Array.isArray(parsedData)) return []
|
||||
return parsedData.filter(
|
||||
(c) =>
|
||||
c && typeof c === 'object' && (c as Record<string, unknown>).audio_url
|
||||
) as AudioClip[]
|
||||
}, [log.platform, log.status, parsedData])
|
||||
|
||||
const upstreamEntries = useMemo(
|
||||
() => (isPlainObject(parsedData) ? flattenEntries(parsedData) : []),
|
||||
[parsedData]
|
||||
)
|
||||
const upstreamRaw = useMemo(() => {
|
||||
if (isPlainObject(parsedData) || parsedData == null) return ''
|
||||
try {
|
||||
return JSON.stringify(parsedData, null, 2)
|
||||
} catch {
|
||||
return String(parsedData)
|
||||
}
|
||||
}, [parsedData])
|
||||
|
||||
const rawJson = useMemo(() => {
|
||||
try {
|
||||
return JSON.stringify(log, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [log])
|
||||
|
||||
const hasFailReason = !!log.fail_reason && log.fail_reason.trim() !== ''
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
title={
|
||||
<>
|
||||
{t('Task Details')}
|
||||
<StatusBadge
|
||||
variant={taskStatusMapper.getVariant(log.status)}
|
||||
size='sm'
|
||||
>
|
||||
{t(
|
||||
taskStatusMapper.getLabel(log.status, log.status || 'Submitting')
|
||||
)}
|
||||
</StatusBadge>
|
||||
</>
|
||||
}
|
||||
description={t('View the complete details for this task')}
|
||||
contentClassName='min-w-0 overflow-hidden max-sm:max-h-[calc(100dvh-1.5rem)] max-sm:w-[calc(100vw-1.5rem)] max-sm:max-w-[calc(100vw-1.5rem)] max-sm:p-4 sm:max-w-lg'
|
||||
headerClassName='max-sm:gap-1'
|
||||
titleClassName='flex items-center gap-2 text-base'
|
||||
descriptionClassName='sr-only'
|
||||
contentHeight='min(78dvh, 760px)'
|
||||
bodyClassName='pr-2 sm:pr-4'
|
||||
>
|
||||
<div className='w-full max-w-full min-w-0 space-y-3 overflow-x-hidden py-1'>
|
||||
{/* Overview */}
|
||||
<DetailSection label={t('Overview')}>
|
||||
{log.task_id && (
|
||||
<DetailRow
|
||||
label={t('Task ID')}
|
||||
value={
|
||||
<span className='flex items-start gap-1'>
|
||||
<span className='min-w-0 break-all'>{log.task_id}</span>
|
||||
<button
|
||||
type='button'
|
||||
className='text-muted-foreground hover:text-foreground mt-0.5 shrink-0'
|
||||
onClick={() => copyToClipboard(log.task_id)}
|
||||
title={t('Copy to clipboard')}
|
||||
aria-label={t('Copy to clipboard')}
|
||||
>
|
||||
{copiedText === log.task_id ? (
|
||||
<Check className='text-success size-3' />
|
||||
) : (
|
||||
<Copy className='size-3' />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{log.id > 0 && (
|
||||
<DetailRow label={t('Internal ID')} value={String(log.id)} mono />
|
||||
)}
|
||||
<DetailRow label={t('Platform')} value={platformName} />
|
||||
<DetailRow
|
||||
label={t('Action')}
|
||||
value={t(taskActionMapper.getLabel(log.action, log.action))}
|
||||
/>
|
||||
<DetailRow
|
||||
label={t('Status')}
|
||||
value={
|
||||
<StatusBadge
|
||||
variant={taskStatusMapper.getVariant(log.status)}
|
||||
size='sm'
|
||||
>
|
||||
{t(
|
||||
taskStatusMapper.getLabel(
|
||||
log.status,
|
||||
log.status || 'Submitting'
|
||||
)
|
||||
)}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
{log.progress && (
|
||||
<DetailRow
|
||||
label={t('Progress')}
|
||||
value={
|
||||
<StatusBadge variant='neutral' size='sm' className='font-mono'>
|
||||
{log.progress}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{model && <DetailRow label={t('Model')} value={model} mono />}
|
||||
</DetailSection>
|
||||
|
||||
{/* Timing */}
|
||||
<DetailSection label={t('Timing')}>
|
||||
{log.created_at ? (
|
||||
<DetailRow
|
||||
label={t('Created At')}
|
||||
value={formatTimestampToDate(log.created_at, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{log.updated_at ? (
|
||||
<DetailRow
|
||||
label={t('Updated At')}
|
||||
value={formatTimestampToDate(log.updated_at, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
<DetailRow
|
||||
label={t('Submit Time')}
|
||||
value={formatTimestampToDate(log.submit_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
{log.start_time ? (
|
||||
<DetailRow
|
||||
label={t('Start Time')}
|
||||
value={formatTimestampToDate(log.start_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{log.finish_time ? (
|
||||
<DetailRow
|
||||
label={t('Finish Time')}
|
||||
value={formatTimestampToDate(log.finish_time, 'seconds')}
|
||||
mono
|
||||
/>
|
||||
) : null}
|
||||
{duration && (
|
||||
<DetailRow
|
||||
label={t('Duration')}
|
||||
value={
|
||||
<StatusBadge
|
||||
variant={duration.variant}
|
||||
size='sm'
|
||||
className='tabular-nums'
|
||||
>
|
||||
{duration.durationSec.toFixed(1)}s
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* Billing */}
|
||||
<DetailSection label={t('Billing')}>
|
||||
{typeof log.quota === 'number' && (
|
||||
<DetailRow
|
||||
label={t('Cost')}
|
||||
value={formatLogQuota(log.quota)}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{log.group && <DetailRow label={t('Group')} value={log.group} mono />}
|
||||
{isAdmin && log.channel_id > 0 && (
|
||||
<DetailRow label={t('Channel')} value={`#${log.channel_id}`} mono />
|
||||
)}
|
||||
{isAdmin && log.username && (
|
||||
<DetailRow label={t('User')} value={log.username} />
|
||||
)}
|
||||
{isAdmin && log.user_id > 0 && (
|
||||
<DetailRow label={t('User ID')} value={String(log.user_id)} mono />
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* Request properties */}
|
||||
{(originModel || upstreamModel) && (
|
||||
<DetailSection label={t('Request Properties')}>
|
||||
{originModel && (
|
||||
<DetailRow
|
||||
label={t('Original Model Name')}
|
||||
value={originModel}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{upstreamModel && (
|
||||
<DetailRow
|
||||
label={t('Upstream Model Name')}
|
||||
value={upstreamModel}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Fail reason */}
|
||||
{hasFailReason && (
|
||||
<DetailSection
|
||||
icon={<AlertTriangle className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Fail Reason')}
|
||||
variant='destructive'
|
||||
action={
|
||||
<CopyButton
|
||||
text={log.fail_reason ?? ''}
|
||||
copied={copiedText === log.fail_reason}
|
||||
onCopy={() => copyToClipboard(log.fail_reason ?? '')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className='text-destructive text-xs leading-relaxed break-all whitespace-pre-wrap sm:wrap-break-word'>
|
||||
{log.fail_reason}
|
||||
</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{(videoUrl || isResultLink || audioClips.length > 0) && (
|
||||
<DetailSection
|
||||
icon={<Video className='size-3.5' aria-hidden='true' />}
|
||||
label={t('Result')}
|
||||
action={
|
||||
isResultLink ? (
|
||||
<div className='flex items-center gap-1'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-xs'
|
||||
onClick={() =>
|
||||
window.open(resultUrl, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
title={t('Open in new tab')}
|
||||
aria-label={t('Open in new tab')}
|
||||
>
|
||||
<ExternalLink className='size-3' />
|
||||
</Button>
|
||||
<CopyButton
|
||||
text={resultUrl}
|
||||
copied={copiedText === resultUrl}
|
||||
onCopy={() => copyToClipboard(resultUrl)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{isResultLink && (
|
||||
<DetailRow label={t('Result URL')} value={resultUrl} mono />
|
||||
)}
|
||||
{videoUrl && <VideoPreview url={videoUrl} />}
|
||||
{audioClips.length > 0 && (
|
||||
<div className='flex items-center gap-1.5 pt-1'>
|
||||
<Music
|
||||
className='text-muted-foreground size-3.5'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-xs font-medium'>
|
||||
{t('Audio Preview')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{audioClips.length > 0 && <AudioPreview clips={audioClips} />}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Upstream response (parsed task data) */}
|
||||
{(upstreamEntries.length > 0 || upstreamRaw) && (
|
||||
<DetailSection label={t('Upstream Response')}>
|
||||
{upstreamEntries.length > 0 ? (
|
||||
upstreamEntries.map((entry) => (
|
||||
<DetailRow
|
||||
key={entry.label}
|
||||
label={entry.label}
|
||||
value={entry.value}
|
||||
mono={entry.mono}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<pre className='bg-background/60 max-h-64 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
|
||||
{upstreamRaw}
|
||||
</pre>
|
||||
)}
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Raw JSON */}
|
||||
{rawJson && (
|
||||
<DetailSection
|
||||
label={t('Raw Data')}
|
||||
action={
|
||||
<CopyButton
|
||||
text={rawJson}
|
||||
copied={copiedText === rawJson}
|
||||
onCopy={() => copyToClipboard(rawJson)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<pre className='bg-background/60 max-h-72 min-w-0 overflow-auto rounded border p-2 font-mono text-xs leading-relaxed whitespace-pre'>
|
||||
{rawJson}
|
||||
</pre>
|
||||
</DetailSection>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -17,13 +17,25 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronDown, Loader2 } from 'lucide-react'
|
||||
import { useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DataTableViewOptions } from '@/components/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DataTableFilterField,
|
||||
DataTableFilterInput,
|
||||
DataTableFilterPanel,
|
||||
} from '@/components/data-table'
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LogsFilterToolbarProps<TData> {
|
||||
table: Table<TData>
|
||||
@@ -43,9 +55,214 @@ interface LogsFilterToolbarProps<TData> {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const LogsFilterField = DataTableFilterField
|
||||
export const LogsFilterInput = DataTableFilterInput
|
||||
interface LogsFilterFieldProps {
|
||||
children: ReactNode
|
||||
wide?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LogsFilterField(props: LogsFilterFieldProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 [&_[data-slot=select-trigger]]:w-full [&_[data-slot=select-trigger]]:text-sm [&_[data-slot=select-value]]:leading-5',
|
||||
props.wide && 'sm:col-span-2',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsFilterInput(props: ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
className={cn('h-8 min-w-0 text-sm leading-5', props.className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LogsFilterToolbar<TData>(props: LogsFilterToolbarProps<TData>) {
|
||||
return <DataTableFilterPanel {...props} />
|
||||
const { t } = useTranslation()
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
|
||||
const hasAdvancedFilters = props.advancedFilters != null
|
||||
const activeAdvancedCount =
|
||||
props.advancedFilterCount ?? (props.hasAdvancedActiveFilters ? 1 : 0)
|
||||
const activeMobileFilterCount = props.mobileFilterCount ?? activeAdvancedCount
|
||||
|
||||
const handleMobileReset = () => {
|
||||
props.onReset()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
const handleMobileSearch = () => {
|
||||
props.onSearch()
|
||||
setMobileFiltersOpen(false)
|
||||
}
|
||||
|
||||
const advancedToggle = hasAdvancedFilters ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => setAdvancedOpen((open) => !open)}
|
||||
aria-expanded={advancedOpen}
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
props.hasAdvancedActiveFilters &&
|
||||
!advancedOpen &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{advancedOpen ? t('Collapse') : t('Expand')}
|
||||
{activeAdvancedCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
|
||||
{activeAdvancedCount}
|
||||
</Badge>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'size-3.5 transition-transform duration-200',
|
||||
advancedOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
if (isMobile && props.mobilePinnedFilters != null) {
|
||||
return (
|
||||
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||
<div
|
||||
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
|
||||
>
|
||||
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
{props.stats}
|
||||
<div className='flex items-center justify-end gap-1.5'>
|
||||
{props.actionStart}
|
||||
<DrawerTrigger asChild>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'text-muted-foreground hover:text-foreground gap-1 px-2',
|
||||
activeMobileFilterCount > 0 &&
|
||||
'text-primary hover:text-primary'
|
||||
)}
|
||||
>
|
||||
{t('Filter')}
|
||||
{activeMobileFilterCount > 0 && (
|
||||
<Badge className='ml-0.5 size-5 justify-center p-0 text-[10px]'>
|
||||
{activeMobileFilterCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DrawerTrigger>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={props.onSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
<DataTableViewOptions table={props.table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerContent className='max-h-[85dvh] p-0'>
|
||||
<div className='mx-auto flex w-full max-w-md flex-1 flex-col overflow-hidden'>
|
||||
<DrawerHeader className='border-border/70 border-b px-4 py-3 text-left'>
|
||||
<DrawerTitle>{t('Filter')}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
{t('Adjust filters, then search to refresh the logs.')}
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className='flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4 py-3'>
|
||||
{props.mobileFilters ?? (
|
||||
<>
|
||||
{props.primaryFilters}
|
||||
{props.advancedFilters}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DrawerFooter className='border-border/70 grid grid-cols-2 gap-2 border-t px-4 py-3'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={handleMobileReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={handleMobileSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
</DrawerFooter>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card/50 rounded-lg border p-2.5 sm:p-3',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-wrap items-start gap-2'>
|
||||
<div className='grid min-w-0 flex-1 grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]'>
|
||||
{props.primaryFilters}
|
||||
</div>
|
||||
{advancedToggle && (
|
||||
<div className='flex shrink-0 items-center justify-end'>
|
||||
{advancedToggle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{advancedOpen && props.advancedFilters && (
|
||||
<div className='mt-2 grid grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]'>
|
||||
{props.advancedFilters}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-2 flex flex-wrap items-center gap-2'>
|
||||
{props.stats}
|
||||
<div className='ms-auto flex flex-wrap items-center justify-end gap-1.5 sm:gap-2'>
|
||||
{props.actionStart}
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={props.onReset}
|
||||
disabled={!props.hasActiveFilters}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={props.onSearch}
|
||||
disabled={props.searchLoading}
|
||||
>
|
||||
{props.searchLoading && <Loader2 className='animate-spin' />}
|
||||
{t('Search')}
|
||||
</Button>
|
||||
<DataTableViewOptions table={props.table} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+29
-60
@@ -19,13 +19,12 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { getIdentityColorClass } from '@/lib/colors'
|
||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -123,60 +122,34 @@ function resolveModelProvider(modelName: string): ModelProvider | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function ModelBadgeContent(
|
||||
props: ModelBadgeProps & {
|
||||
staticOnly: boolean
|
||||
}
|
||||
) {
|
||||
function ModelBadgeContent(props: ModelBadgeProps) {
|
||||
const provider = resolveModelProvider(props.modelName)
|
||||
const colorClassName = getIdentityColorClass(props.modelName)
|
||||
const content = [
|
||||
provider ? (
|
||||
<span
|
||||
key='provider'
|
||||
data-icon='inline-start'
|
||||
className='flex items-center justify-center'
|
||||
aria-label={provider.label}
|
||||
>
|
||||
{getLobeIcon(provider.icon, 12)}
|
||||
</span>
|
||||
) : null,
|
||||
<span key='model' className='whitespace-nowrap'>
|
||||
{props.modelName}
|
||||
</span>,
|
||||
]
|
||||
|
||||
if (props.staticOnly) {
|
||||
return (
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
appearance='soft'
|
||||
size='sm'
|
||||
className={cn(
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
colorClassName,
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CopyableStatusBadge
|
||||
value={props.modelName}
|
||||
variant='neutral'
|
||||
appearance='soft'
|
||||
<StatusBadge
|
||||
copyText={props.modelName}
|
||||
size='sm'
|
||||
showDot={!provider}
|
||||
autoColor={provider ? undefined : props.modelName}
|
||||
className={cn(
|
||||
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
|
||||
colorClassName,
|
||||
'border-border/60 bg-muted/30 h-6 max-w-none gap-1.5 rounded-md border px-2 [font-family:var(--font-body)]',
|
||||
provider && 'text-foreground',
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</CopyableStatusBadge>
|
||||
<span className='flex max-w-none items-center gap-1.5'>
|
||||
{provider && (
|
||||
<span
|
||||
className='flex h-[18px] w-[18px] shrink-0 items-center justify-center'
|
||||
title={provider.label}
|
||||
aria-label={provider.label}
|
||||
>
|
||||
{getLobeIcon(provider.icon, 18)}
|
||||
</span>
|
||||
)}
|
||||
<span className='whitespace-nowrap'>{props.modelName}</span>
|
||||
</span>
|
||||
</StatusBadge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -184,7 +157,7 @@ export function ModelBadge(props: ModelBadgeProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!props.actualModel) {
|
||||
return <ModelBadgeContent {...props} staticOnly={false} />
|
||||
return <ModelBadgeContent {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -194,7 +167,7 @@ export function ModelBadge(props: ModelBadgeProps) {
|
||||
<button type='button' className='inline-flex items-center gap-1' />
|
||||
}
|
||||
>
|
||||
<ModelBadgeContent {...props} staticOnly />
|
||||
<ModelBadgeContent {...props} />
|
||||
<Route className='text-muted-foreground size-3 shrink-0' />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-72'>
|
||||
@@ -203,21 +176,17 @@ export function ModelBadge(props: ModelBadgeProps) {
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Request Model:')}
|
||||
</span>
|
||||
<ModelBadgeContent
|
||||
modelName={props.modelName}
|
||||
staticOnly
|
||||
className='max-w-[11rem]'
|
||||
/>
|
||||
<span className='truncate font-mono text-xs font-medium'>
|
||||
{props.modelName}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
{t('Actual Model:')}
|
||||
</span>
|
||||
<ModelBadgeContent
|
||||
modelName={props.actualModel}
|
||||
staticOnly
|
||||
className='max-w-[11rem]'
|
||||
/>
|
||||
<span className='truncate font-mono text-xs font-medium'>
|
||||
{props.actualModel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
+318
-128
@@ -16,26 +16,35 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import {
|
||||
flexRender,
|
||||
type Cell,
|
||||
type Row,
|
||||
type Table,
|
||||
} from '@tanstack/react-table'
|
||||
import { flexRender, type Cell, type Table } from '@tanstack/react-table'
|
||||
import { Database } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DataTableCardField,
|
||||
DataTableCardRow,
|
||||
MobileCardList,
|
||||
} from '@/components/data-table'
|
||||
dotColorMap,
|
||||
textColorMap,
|
||||
type StatusVariant,
|
||||
} from '@/components/status-badge'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { LOG_TYPE_ENUM } from '../constants'
|
||||
import { getLogTypeConfig } from '../lib/utils'
|
||||
import type { LogCategory } from '../types'
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5 border-destructive/20',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5 border-info/20',
|
||||
[LOG_TYPE_ENUM.ERROR]:
|
||||
'bg-rose-50/40 dark:bg-rose-950/20 border-rose-200/50 dark:border-rose-900/30',
|
||||
[LOG_TYPE_ENUM.REFUND]:
|
||||
'bg-blue-50/30 dark:bg-blue-950/15 border-blue-200/50 dark:border-blue-900/30',
|
||||
}
|
||||
|
||||
interface UsageLogsMobileListProps<TData> {
|
||||
@@ -43,116 +52,267 @@ interface UsageLogsMobileListProps<TData> {
|
||||
isLoading?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
getRowClassName?: (row: Row<TData>) => string | undefined
|
||||
logCategory: LogCategory
|
||||
}
|
||||
|
||||
type CardRole = 'title' | 'badge' | 'primary' | 'secondary' | 'hidden'
|
||||
|
||||
function getCardRole<TData>(cell: Cell<TData, unknown>): CardRole {
|
||||
return cell.column.columnDef.meta?.cardRole ?? 'primary'
|
||||
}
|
||||
|
||||
function getCardLabel<TData>(cell: Cell<TData, unknown>): string {
|
||||
const metaLabel = cell.column.columnDef.meta?.label
|
||||
if (metaLabel) return metaLabel
|
||||
|
||||
const header = cell.column.columnDef.header
|
||||
return typeof header === 'string' ? header : cell.column.id
|
||||
}
|
||||
|
||||
function orderCardCells<TData>(
|
||||
cells: Cell<TData, unknown>[]
|
||||
): Cell<TData, unknown>[] {
|
||||
return [...cells].sort((a, b) => {
|
||||
const aOrder = a.column.columnDef.meta?.cardOrder
|
||||
const bOrder = b.column.columnDef.meta?.cardOrder
|
||||
if (aOrder == null && bOrder == null) return 0
|
||||
if (aOrder == null) return 1
|
||||
if (bOrder == null) return -1
|
||||
return aOrder - bOrder
|
||||
})
|
||||
}
|
||||
|
||||
function isWideField<TData>(cell: Cell<TData, unknown>): boolean {
|
||||
const meta = cell.column.columnDef.meta
|
||||
return meta?.cardSpan === 2 || meta?.contentMode === 'summary'
|
||||
}
|
||||
|
||||
function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
|
||||
const titleCell = props.cells.find((cell) => getCardRole(cell) === 'title')
|
||||
const badgeCell = props.cells.find((cell) => getCardRole(cell) === 'badge')
|
||||
const bodyCells = orderCardCells(
|
||||
props.cells.filter(
|
||||
(cell) =>
|
||||
getCardRole(cell) !== 'title' &&
|
||||
getCardRole(cell) !== 'badge' &&
|
||||
getCardRole(cell) !== 'hidden'
|
||||
)
|
||||
function UsageLogsMobileSkeleton() {
|
||||
return (
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className='border-border/40 space-y-2.5 border-b p-3 last:border-b-0'
|
||||
>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<Skeleton className='h-5 w-40 rounded-md' />
|
||||
<Skeleton className='h-5 w-16 rounded-md' />
|
||||
</div>
|
||||
<div className='grid grid-cols-2 gap-x-4 gap-y-2'>
|
||||
{[1, 2, 3, 4, 5, 6].map((j) => (
|
||||
<div key={j} className='min-w-0 space-y-1'>
|
||||
<Skeleton className='h-3 w-10 rounded' />
|
||||
<Skeleton className='h-4 w-full rounded' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const rowCells = bodyCells.filter((cell) => !isWideField(cell))
|
||||
const wideCells = bodyCells.filter((cell) => isWideField(cell))
|
||||
}
|
||||
|
||||
function CompactCell<TData>({
|
||||
cell,
|
||||
fallback = '-',
|
||||
className,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
cell?: Cell<TData, unknown>
|
||||
fallback?: string
|
||||
className?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden leading-tight [&_button]:max-w-full [&_span]:max-w-full',
|
||||
primaryOnly &&
|
||||
'[&_.flex-col]:min-w-0 [&_.flex-col>*:not(:first-child)]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{cell ? (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
) : (
|
||||
<span className='text-muted-foreground/50'>{fallback}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryField<TData>({
|
||||
label,
|
||||
cell,
|
||||
className,
|
||||
valueClassName,
|
||||
primaryOnly = false,
|
||||
}: {
|
||||
label: string
|
||||
cell?: Cell<TData, unknown>
|
||||
className?: string
|
||||
valueClassName?: string
|
||||
primaryOnly?: boolean
|
||||
}) {
|
||||
if (!cell) return null
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
{(titleCell || badgeCell) && (
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
{titleCell && (
|
||||
<div className='min-w-0 flex-1 text-base leading-tight font-semibold break-words'>
|
||||
{flexRender(
|
||||
titleCell.column.columnDef.cell,
|
||||
titleCell.getContext()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{badgeCell && (
|
||||
<div className='max-w-1/2 shrink text-right tabular-nums'>
|
||||
{flexRender(
|
||||
badgeCell.column.columnDef.cell,
|
||||
badgeCell.getContext()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
|
||||
>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{label}
|
||||
</div>
|
||||
<CompactCell
|
||||
cell={cell}
|
||||
primaryOnly={primaryOnly}
|
||||
className={valueClassName}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{bodyCells.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3',
|
||||
(titleCell || badgeCell) && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{rowCells.length > 0 && (
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
{rowCells.map((cell) => (
|
||||
<DataTableCardRow
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={cell.column.columnDef.meta?.contentMode}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
function MobileLogTimeStatus({
|
||||
createdAt,
|
||||
type,
|
||||
}: {
|
||||
createdAt: unknown
|
||||
type: unknown
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const timestamp = typeof createdAt === 'number' ? createdAt : undefined
|
||||
const logType = typeof type === 'number' ? type : undefined
|
||||
const config = getLogTypeConfig(logType ?? LOG_TYPE_ENUM.UNKNOWN)
|
||||
const variant = config.color as StatusVariant
|
||||
|
||||
{wideCells.length > 0 && (
|
||||
<div className='bg-muted/30 flex flex-col gap-3 rounded-md px-2.5 py-2'>
|
||||
{wideCells.map((cell) => (
|
||||
<DataTableCardField
|
||||
key={cell.id}
|
||||
label={getCardLabel(cell)}
|
||||
contentMode={
|
||||
cell.column.columnDef.meta?.contentMode ?? 'full'
|
||||
}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataTableCardField>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
<div className='font-mono text-xs leading-tight tabular-nums'>
|
||||
{formatTimestampToDate(timestamp)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-xs leading-none font-medium',
|
||||
textColorMap[variant]
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn('size-1.5 shrink-0 rounded-full', dotColorMap[variant])}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span>{t(config.label)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommonLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const modelCell = cells.get('model_name')
|
||||
const quotaCell = cells.get('quota')
|
||||
const rowData = cells.get('created_at')?.row.original as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-center justify-between gap-3'>
|
||||
<CompactCell cell={modelCell} className='flex-1' />
|
||||
<CompactCell
|
||||
cell={quotaCell}
|
||||
className='shrink-0 text-right [&_.flex-col]:items-end'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)] gap-1.5'>
|
||||
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
|
||||
<div className='text-muted-foreground mb-1 text-[11px] leading-none font-medium select-none'>
|
||||
{t('Time')}
|
||||
</div>
|
||||
<MobileLogTimeStatus
|
||||
createdAt={rowData?.created_at}
|
||||
type={rowData?.type}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
valueClassName='[&_.flex-col]:max-w-none'
|
||||
/>
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Token')}
|
||||
cell={cells.get('token_name')}
|
||||
valueClassName='[&_.flex-col]:max-w-none [&_.flex-col>*:not(:first-child)]:text-[11px] [&_.flex-col>*:not(:first-child)]:leading-none'
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Timing')}
|
||||
cell={cells.get('use_time')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Tokens')}
|
||||
cell={cells.get('prompt_tokens')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Details')}
|
||||
cell={cells.get('content')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const taskIdCell = cells.get('task_id')
|
||||
const statusCell = cells.get('status')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={taskIdCell} className='flex-1' />
|
||||
<CompactCell cell={statusCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField label={t('User')} cell={cells.get('user')} primaryOnly />
|
||||
<SummaryField
|
||||
label={t('Result')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawingLogsCard<TData>({
|
||||
cells,
|
||||
}: {
|
||||
cells: Map<string, Cell<TData, unknown>>
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const actionCell = cells.get('action')
|
||||
const codeCell = cells.get('code')
|
||||
const submitTimeCell = cells.get('submit_time')
|
||||
|
||||
return (
|
||||
<div className='space-y-2.5'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<CompactCell cell={actionCell} className='flex-1' />
|
||||
<CompactCell cell={codeCell} className='shrink-0 text-right' />
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-1.5'>
|
||||
<SummaryField label={t('Submit Time')} cell={submitTimeCell} />
|
||||
<SummaryField
|
||||
label={t('Channel')}
|
||||
cell={cells.get('channel')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Task ID')} cell={cells.get('mj_id')} />
|
||||
<SummaryField
|
||||
label={t('Duration')}
|
||||
cell={cells.get('duration')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField label={t('Image')} cell={cells.get('image_url')} />
|
||||
<SummaryField
|
||||
label={t('Prompt')}
|
||||
cell={cells.get('prompt')}
|
||||
primaryOnly
|
||||
/>
|
||||
<SummaryField
|
||||
label={t('Fail Reason')}
|
||||
cell={cells.get('fail_reason')}
|
||||
className='col-span-2 bg-transparent px-0 py-0'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -162,7 +322,7 @@ export function UsageLogsMobileList<TData>({
|
||||
isLoading = false,
|
||||
emptyTitle,
|
||||
emptyDescription,
|
||||
getRowClassName,
|
||||
logCategory,
|
||||
}: UsageLogsMobileListProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -171,24 +331,54 @@ export function UsageLogsMobileList<TData>({
|
||||
emptyDescription ??
|
||||
t('No usage logs available. Logs will appear here once API calls are made.')
|
||||
|
||||
if (isLoading) {
|
||||
return <UsageLogsMobileSkeleton />
|
||||
}
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return (
|
||||
<div className='rounded-lg border p-6'>
|
||||
<Empty className='border-none p-0'>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant='icon'>
|
||||
<Database className='size-6' />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{resolvedEmptyTitle}</EmptyTitle>
|
||||
<EmptyDescription>{resolvedEmptyDescription}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileCardList
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
emptyTitle={resolvedEmptyTitle}
|
||||
emptyDescription={resolvedEmptyDescription}
|
||||
renderCard={(row) => <UsageLogCard cells={row.getVisibleCells()} />}
|
||||
getRowClassName={(row) => {
|
||||
<div className='border-border/50 bg-card overflow-hidden rounded-lg border'>
|
||||
{rows.map((row) => {
|
||||
const cells = new Map(
|
||||
row.getVisibleCells().map((cell) => [cell.column.id, cell])
|
||||
)
|
||||
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
const tintClass = logType != null ? (logTypeRowTint[logType] ?? '') : ''
|
||||
return cn(
|
||||
'border-l-2 border-l-transparent transition-colors',
|
||||
tintClass,
|
||||
getRowClassName?.(row)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className={cn(
|
||||
'border-border/40 border-b border-l-2 border-l-transparent p-3 transition-colors last:border-b-0',
|
||||
tintClass
|
||||
)}
|
||||
>
|
||||
{logCategory === 'common' && <CommonLogsCard cells={cells} />}
|
||||
{logCategory === 'task' && <TaskLogsCard cells={cells} />}
|
||||
{logCategory === 'drawing' && <DrawingLogsCard cells={cells} />}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { type ColumnDef } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DataTableRow,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useIsAdmin } from '@/hooks/use-admin'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -47,13 +48,13 @@ import { UsageLogsMobileList } from './usage-logs-mobile-card'
|
||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||
|
||||
const logTypeRowTint: Record<number, string> = {
|
||||
[LOG_TYPE_ENUM.ERROR]: 'bg-destructive/5',
|
||||
[LOG_TYPE_ENUM.REFUND]: 'bg-info/5',
|
||||
[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',
|
||||
}
|
||||
|
||||
// Warning tint for logs where a quota conversion saturated (admin-only marker).
|
||||
// Takes precedence over the per-type tint since it flags a billing anomaly.
|
||||
const quotaSaturationRowTint = 'bg-warning/10'
|
||||
const quotaSaturationRowTint = 'bg-amber-50/60 dark:bg-amber-950/25'
|
||||
|
||||
function getColumnVisibilityStorageKey(
|
||||
logCategory: LogCategory,
|
||||
@@ -63,12 +64,7 @@ function getColumnVisibilityStorageKey(
|
||||
}
|
||||
|
||||
function deserializeLogTypeFilter(value: unknown): unknown[] {
|
||||
let values: unknown[] = []
|
||||
if (Array.isArray(value)) {
|
||||
values = value
|
||||
} else if (value) {
|
||||
values = [value]
|
||||
}
|
||||
const values = Array.isArray(value) ? value : value ? [value] : []
|
||||
return values.filter((item) => String(item) !== LOG_TYPE_ALL_VALUE)
|
||||
}
|
||||
|
||||
@@ -79,6 +75,7 @@ interface UsageLogsTableProps {
|
||||
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
const { t } = useTranslation()
|
||||
const isAdmin = useIsAdmin()
|
||||
const isMobile = useMediaQuery('(max-width: 640px)')
|
||||
const searchParams = route.useSearch()
|
||||
|
||||
const {
|
||||
@@ -90,11 +87,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
defaultPage: 1,
|
||||
defaultPageSize: 20,
|
||||
pageSizeStorageKey: `usage-logs:${logCategory}:${isAdmin ? 'admin' : 'user'}:page-size:v1`,
|
||||
},
|
||||
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
|
||||
globalFilter: { enabled: false },
|
||||
columnFilters: [
|
||||
{
|
||||
@@ -187,7 +180,6 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns as ColumnDef<Record<string, unknown>>[]}
|
||||
tableLabel={t('Usage Logs')}
|
||||
isLoading={isLoadingData}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Logs Found')}
|
||||
@@ -203,15 +195,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
<UsageLogsMobileList
|
||||
table={table}
|
||||
isLoading={isLoadingData}
|
||||
getRowClassName={(row) => {
|
||||
if (!isCommon || !isAdmin) return undefined
|
||||
const other = parseLogOther(
|
||||
((row.original as Record<string, unknown>).other as string) ?? ''
|
||||
)
|
||||
return other?.admin_info?.quota_saturation
|
||||
? quotaSaturationRowTint
|
||||
: undefined
|
||||
}}
|
||||
logCategory={logCategory}
|
||||
/>
|
||||
}
|
||||
toolbar={
|
||||
@@ -221,7 +205,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
<TaskLogsFilterBar table={table} logCategory={logCategory} />
|
||||
)
|
||||
}
|
||||
renderRow={(row, helpers) => {
|
||||
renderRow={(row) => {
|
||||
const logType = (row.original as Record<string, unknown>).type as
|
||||
| number
|
||||
| undefined
|
||||
@@ -241,9 +225,7 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
||||
key={row.id}
|
||||
row={row}
|
||||
className={cn('transition-colors', tintClass)}
|
||||
getColumnClassName={(columnId) =>
|
||||
helpers.getCellClassName(columnId, isCommon ? 'py-2' : 'py-3.5')
|
||||
}
|
||||
getColumnClassName={() => (isCommon ? 'py-2' : 'py-3.5')}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user