feat(web): polish themed data views and add task log details

This commit is contained in:
t0ng7u
2026-07-11 14:30:06 +08:00
parent b2a890e755
commit 308e3e347a
79 changed files with 1376 additions and 727 deletions
+95 -115
View File
@@ -17,22 +17,24 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
BadgeListCellDisplayContext,
DataTableCardField,
DataTableCardRow,
} from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import { isTagAggregateRow } from '../lib'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
const SENSITIVE_MASK = '••••'
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
* renderer via `flexRender`, so the table's information and interactions are
* preserved. All fields are always visible — no "More" disclosure.
* renderer via `flexRender`, preserving the original compact two-column
* hierarchy and all row interactions.
*/
function ChannelCardComponent({
row,
@@ -42,9 +44,9 @@ function ChannelCardComponent({
isSelected: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original)
const cells = row.getVisibleCells()
const visibleColumnIds = new Set(cells.map((cell) => cell.column.id))
const cells = row.getAllCells()
const renderCell = (id: string) => {
const cell = cells.find((candidate) => candidate.column.id === id)
@@ -54,129 +56,107 @@ function ChannelCardComponent({
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
balance: t('Used / Remaining'),
response_time: t('Response'),
test_time: t('Last Tested'),
}
const groups = parseGroupsList(row.original.group ?? '')
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const idCell = renderCell('id')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
const actionsCell = renderCell('actions')
const priorityCell = renderCell('priority')
const weightCell = renderCell('weight')
const balanceCell = renderCell('balance')
const modelsCell = renderCell('models')
const groupsCell = renderCell('group')
const tagCell = renderCell('tag')
const responseCell = renderCell('response_time')
const testCell = renderCell('test_time')
const showId = !isTagRow && visibleColumnIds.has('id')
const showTag = !isTagRow && visibleColumnIds.has('tag')
const showModels = !isTagRow && visibleColumnIds.has('models')
const showTestTime = !isTagRow && visibleColumnIds.has('test_time')
const hasStatRows =
showId ||
showTag ||
showTestTime ||
visibleColumnIds.has('balance') ||
visibleColumnIds.has('response_time') ||
visibleColumnIds.has('priority') ||
visibleColumnIds.has('weight')
const hasBadgeSections = showModels || visibleColumnIds.has('group')
const labelClass = 'text-muted-foreground text-xs font-medium select-none'
const showStatusBadge =
isTagRow ||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
return (
<ChannelRowActionsLayoutContext.Provider value='card'>
<BadgeListCellDisplayContext.Provider value='full'>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex h-full min-w-0 flex-col'
>
<div className='flex min-w-0 items-start gap-2.5'>
{!isTagRow && selectCell && (
<span className='mt-0.5 shrink-0'>{selectCell}</span>
<div
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
<div className='flex items-center justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0'>{typeCell}</div>
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
<ChannelRowActionsLayoutContext.Provider value='card'>
{actionsCell}
</ChannelRowActionsLayoutContext.Provider>
</div>
</div>
<div className='flex items-start justify-between gap-3'>
<div className='flex min-w-0 flex-1 flex-col gap-3'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
<div className='min-w-0 flex-1'>
{visibleColumnIds.has('name') && (
<div className='min-w-0 text-[15px] leading-tight font-semibold break-words'>
{nameCell}
</div>
)}
{visibleColumnIds.has('type') && (
<div className='mt-1.5 min-w-0'>{typeCell}</div>
)}
</div>
<div className='flex shrink-0 items-center gap-1'>
{visibleColumnIds.has('status') && statusCell}
{actionsCell}
</div>
{nameCell}
</div>
{hasStatRows && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{showId && (
<DataTableCardRow label={t('ID')} contentMode='full'>
{idCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('balance') && (
<DataTableCardRow
label={t('Used / Remaining')}
contentMode='full'
>
{balanceCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardRow label={t('Response')} contentMode='full'>
{responseCell}
</DataTableCardRow>
)}
{showTestTime && (
<DataTableCardRow label={t('Last Tested')} contentMode='full'>
{testCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardRow label={t('Priority')} contentMode='full'>
{priorityCell}
</DataTableCardRow>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardRow label={t('Weight')} contentMode='full'>
{weightCell}
</DataTableCardRow>
)}
{showTag && (
<DataTableCardRow label={t('Tag')} contentMode='wrap'>
{tagCell}
</DataTableCardRow>
)}
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
<div className='min-w-0 text-sm'>
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
</div>
)}
{hasBadgeSections && (
<div className='mt-3 space-y-3 border-t pt-3'>
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Groups')} contentMode='full'>
{groupsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
{showModels && (
<DataTableCardField label={t('Models')} contentMode='full'>
{modelsCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</DataTableCardField>
)}
</div>
)}
</div>
</div>
</BadgeListCellDisplayContext.Provider>
</ChannelRowActionsLayoutContext.Provider>
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='-ml-1.5 flex flex-wrap gap-1'>
{groups.map((group) => (
<GroupBadge
key={group}
group={group}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
)}
</div>
</div>
)
}
export const ChannelCard = ChannelCardComponent
export const ChannelCard = memo(ChannelCardComponent)
@@ -456,7 +456,7 @@ function BalanceCell({ channel }: { channel: Channel }) {
variant={remainingBadgeVariant}
size='sm'
render={<button type='button' />}
className='cursor-pointer'
className='cursor-pointer underline-offset-2 hover:underline'
onClick={handleClickUpdate}
>
{remainingBadgeLabel}
@@ -732,7 +732,7 @@ export function useChannelsColumns(
: undefined
return (
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
<div className='flex w-max items-center gap-2'>
{isMultiKey && (
<TooltipProvider delay={100}>
<Tooltip>
@@ -751,15 +751,10 @@ export function useChannelsColumns(
)}
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<div className='max-w-full min-w-0 overflow-hidden' />
}
>
<TooltipTrigger render={<div className='shrink-0' />}>
<ProviderBadge
iconKey={`${iconName}.Color`}
label={typeName}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
<TooltipContent side='top'>{typeName}</TooltipContent>
@@ -163,13 +163,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
}
return (
<div
className={
layout === 'card'
? 'flex items-center'
: '-ml-1.5 flex items-center gap-1'
}
>
<div className='-ml-1.5 flex items-center gap-1'>
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
@@ -191,54 +185,71 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
</Tooltip>
)}
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
</Tooltip>
{layout === 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleDirectTest}
disabled={isTesting}
aria-label={t('Test Connection')}
onClick={(event) => {
event.stopPropagation()
handleTest()
}}
aria-label={t('Test Channel Connection')}
/>
}
>
{isTesting ? (
<Loader2 className='size-4 animate-spin' />
) : (
<Gauge className='size-4' />
)}
<PlugZap className='size-4' />
</TooltipTrigger>
<TooltipContent>{t('Test Connection')}</TooltipContent>
<TooltipContent>{t('Test Channel Connection')}</TooltipContent>
</Tooltip>
)}
{layout !== 'card' && (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon-sm'
onClick={handleToggleStatus}
disabled={isTogglingStatus}
aria-label={isEnabled ? t('Disable') : t('Enable')}
className={
isEnabled
? 'text-destructive hover:text-destructive'
: 'text-success hover:text-success'
}
/>
}
>
{statusIcon}
</TooltipTrigger>
<TooltipContent>
{isEnabled ? t('Disable') : t('Enable')}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger
@@ -270,21 +281,6 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<PlugZap size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
{layout === 'card' && (
<DropdownMenuItem
disabled={isTogglingStatus}
onClick={() => void handleToggleStatus()}
className={
isEnabled
? 'text-destructive focus:text-destructive'
: 'text-success focus:text-success'
}
>
{isEnabled ? t('Disable') : t('Enable')}
<DropdownMenuShortcut>{statusIcon}</DropdownMenuShortcut>
</DropdownMenuItem>
)}
{/* Query Balance */}
<DropdownMenuItem onClick={handleQueryBalance}>
{t('Query Balance')}
@@ -1190,7 +1190,7 @@ function TestStatusCell({ result }: { result?: TestResult }) {
return (
<StatusBadge variant='info'>
<Loader2 className='size-3.5 shrink-0 animate-spin' />
<span className='min-w-0 truncate leading-normal'>
<span className='leading-normal whitespace-nowrap'>
{t('Testing...')}
</span>
</StatusBadge>
@@ -1363,7 +1363,7 @@ function FailureDetailsSheet({
onClick={() => copyToClipboard(details.details)}
>
{copiedText === details.details ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
<Check className='text-success mr-2 h-4 w-4' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
@@ -1057,7 +1057,7 @@ export function CodexUsageDialog({
>
<div className='flex flex-col gap-4'>
{errorMessage && (
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
<div className='border-destructive/25 bg-destructive/8 text-status-destructive rounded-lg border px-4 py-3 text-sm'>
{errorMessage}
</div>
)}
@@ -76,7 +76,7 @@ export function MissingModelsConfirmationDialog({
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
)}
</div>
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
<div className='bg-destructive/8 text-status-destructive rounded-md p-2 font-mono text-xs break-all'>
{missingModels.join(', ')}
</div>
<div>
@@ -964,7 +964,6 @@ const validateOperations = (
if (headers.length === 0)
return t('Rule {{line}} pass_headers format is invalid', { line })
}
}
return ''
}
@@ -96,7 +96,7 @@ export function NumericSpinnerInput({
const commitValue = () => {
setEditing(false)
const num = Number(localValue)
if (isNaN(num) || localValue === '' || localValue === '-') {
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
setLocalValue(String(value ?? 0))
return
}
@@ -219,7 +219,7 @@ function ModelBadge(props: { model: PerfModelSummary }) {
return (
<StatusBadge variant={variant}>
<span className='mr-1 max-w-[10rem] truncate'>{model.model_name}</span>
<span className='mr-1 whitespace-nowrap'>{model.model_name}</span>
<span className='tabular-nums'>
{formatUptimePct(model.success_rate)}
</span>
+4 -4
View File
@@ -23,12 +23,12 @@ import type { PingStatus } from '@/features/dashboard/types'
*/
export function getLatencyColorClass(latency: number): string {
if (latency < 200) {
return 'text-green-600 dark:text-green-400'
return 'text-status-success'
}
if (latency < 500) {
return 'text-yellow-600 dark:text-yellow-400'
return 'text-status-warning'
}
return 'text-red-600 dark:text-red-400'
return 'text-status-destructive'
}
/**
@@ -46,7 +46,7 @@ export async function testUrlLatency(url: string): Promise<PingStatus> {
const latency = Math.round(endTime - startTime)
return { latency, testing: false, error: false }
} catch (_error) {
} catch {
return { latency: null, testing: false, error: true }
}
}
@@ -58,7 +58,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
if (isLoading) {
copyIcon = <Loader2 className='size-3.5 animate-spin' />
} else if (isCopied) {
copyIcon = <Check className='size-3.5 text-green-600' />
copyIcon = <Check className='text-success size-3.5' />
}
let copyTooltip = t('Copy API key')
@@ -74,7 +74,7 @@ function LoadingStep({
{status === 'loading' && (
<Loader2 className='text-primary h-5 w-5 animate-spin' />
)}
{status === 'done' && <CheckCircle2 className='h-5 w-5 text-green-500' />}
{status === 'done' && <CheckCircle2 className='text-success h-5 w-5' />}
{status === 'pending' && (
<Circle className='text-muted-foreground/40 h-5 w-5' />
)}
@@ -172,8 +172,8 @@ export function DeploymentAccessGuard({
return (
<div className='mx-auto mt-8 max-w-md'>
<div className='text-center'>
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/20'>
<WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
<div className='bg-destructive/10 text-destructive mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
<WifiOff className='size-8' />
</div>
<h3 className='mb-6 text-xl font-semibold'>
{t('Connection failed')}
@@ -199,5 +199,5 @@ export function DeploymentAccessGuard({
)
}
return <>{children}</>
return children
}
@@ -78,12 +78,7 @@ export function useDeploymentsColumns(opts: {
cell: ({ getValue }) => {
const name = String(getValue() || '-') || '-'
return (
<CopyableStatusBadge
value={name}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
{name}
</CopyableStatusBadge>
)
@@ -131,11 +126,7 @@ export function useDeploymentsColumns(opts: {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<StatusBadge
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<StatusBadge variant='neutral' size='sm'>
{String(provider)}
</StatusBadge>
)
@@ -232,7 +223,6 @@ export function useDeploymentsColumns(opts: {
value={String(hardware)}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{String(hardware)}
</CopyableStatusBadge>
@@ -155,28 +155,32 @@ export function ViewLogsDialog({
if (isLoadingContainers || isLoadingLogs) {
logsContent = (
<div className='flex items-center justify-center py-8'>
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
)
} else if (containers.length === 0) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No containers')}</div>
<div className='text-muted-foreground py-8 text-center'>
{t('No containers')}
</div>
)
} else if (!containerId) {
logsContent = (
<div className='py-8 text-center text-gray-400'>
<div className='text-muted-foreground py-8 text-center'>
{t('Please select a container')}
</div>
)
} else if (!logsText.trim()) {
logsContent = (
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
<div className='text-muted-foreground py-8 text-center'>
{t('No logs')}
</div>
)
} else {
logsContent = (
<div className='font-mono text-sm'>
{keyedLogLines.map(({ key, line }) => (
<div key={key} className='whitespace-pre-wrap text-gray-200'>
<div key={key} className='text-foreground whitespace-pre-wrap'>
{line}
</div>
))}
@@ -143,12 +143,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
cell: ({ row }) => {
const name = row.getValue('model_name') as string
return (
<CopyableStatusBadge
value={name}
variant='neutral'
size='sm'
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
{name}
</CopyableStatusBadge>
)
@@ -255,11 +250,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return (
<BadgeCell className='overflow-visible'>
<ProviderBadge
iconKey={vendor.icon}
label={vendor.name}
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
/>
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
</BadgeCell>
)
},
@@ -123,10 +123,7 @@ function PlaygroundParameterContent({
>
{t(control.labelKey)}
</label>
<StatusBadge
appearance='outline'
className='max-w-24 shrink-0'
>
<StatusBadge appearance='outline'>
{t(getParameterControlValueText(control.key, value))}
</StatusBadge>
</div>
@@ -110,7 +110,7 @@ export function MessageActions({
if (hasContent) {
actions.push({
className: isCopied ? 'text-green-600' : '',
className: isCopied ? 'text-success' : '',
icon: isCopied ? Check : Copy,
label: isCopied
? MESSAGE_ACTION_LABELS.COPIED
@@ -63,7 +63,7 @@ export function MessageError({
return (
<Alert variant='default' className={className}>
<AlertTriangle className='text-orange-500' />
<AlertTriangle className='text-warning' />
<AlertTitle>{t('Model Price Not Configured')}</AlertTitle>
<AlertDescription className='space-y-2'>
<p>{content}</p>
@@ -260,7 +260,7 @@ export function DynamicPricingBreakdown({
{t('Tiered price table')}
</div>
<div className='space-y-1.5 sm:hidden'>
{tiers.map((tier, i) => {
{tiers.map((tier) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
matchedTierLabel != null &&
@@ -268,7 +268,7 @@ export function DynamicPricingBreakdown({
tier.label === matchedTierLabel
return (
<div
key={`tier-mobile-${i}`}
key={`tier-mobile-${tier.label || condSummary || 'default'}`}
className={cn(
'rounded-md border p-2',
isMatched && 'border-success/40 bg-success/10'
@@ -416,27 +416,30 @@ export function DynamicPricingBreakdown({
{t('Conditional multipliers')}
</div>
<ul className='space-y-1.5'>
{ruleGroups.map((group, gi) => (
<li
key={`group-${gi}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
<span
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
)}
{ruleGroups.map((group) => {
const description = describeGroup(group, t)
return (
<li
key={`${description}-${group.multiplier}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
{describeGroup(group, t)}
</span>
<Badge
variant='secondary'
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
>
{group.multiplier}x
</Badge>
</li>
))}
<span
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
)}
>
{description}
</span>
<Badge
variant='secondary'
className='border-warning/30 bg-warning/10 text-status-warning shrink-0'
>
{group.multiplier}x
</Badge>
</li>
)
})}
</ul>
</div>
)}
@@ -60,8 +60,8 @@ export function TelegramBindDialog({
</Alert>
<div className='flex flex-col items-center justify-center gap-4 rounded-lg border p-6'>
<div className='flex h-12 w-12 items-center justify-center rounded-xl bg-blue-100 dark:bg-blue-900'>
<Send className='h-6 w-6 text-blue-600 dark:text-blue-400' />
<div className='bg-info/10 text-info flex h-12 w-12 items-center justify-center rounded-xl'>
<Send className='size-6' />
</div>
<div className='text-center'>
+2 -2
View File
@@ -42,9 +42,9 @@ export function AdminStep({ form, rootInitialized }: AdminStepProps) {
const { t } = useTranslation()
if (rootInitialized) {
return (
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
<Alert className='border-info/25 bg-info/8'>
<AlertDescription className='flex items-start gap-2'>
<ShieldCheck className='mt-0.5 size-4 text-sky-500' />
<ShieldCheck className='text-info mt-0.5 size-4' />
{t(
'The administrator account is already initialized. You can keep your existing credentials and continue to the next step.'
)}
@@ -52,7 +52,7 @@ export function CompleteStep({ status, values }: CompleteStepProps) {
return (
<div className='flex flex-col items-center gap-6 text-center'>
<div className='rounded-2xl bg-emerald-500/10 p-4 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-300'>
<div className='bg-success/10 text-success rounded-xl p-4'>
<CheckCircle2 className='size-8' />
</div>
<div className='space-y-2'>
@@ -107,9 +107,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
</div>
{status?.database_type === 'sqlite' && (
<Alert className='border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/40'>
<Alert className='border-warning/30 bg-warning/8'>
<AlertTitle className='flex items-center gap-2'>
<HardDrive className='size-4 text-amber-500' />
<HardDrive className='text-warning size-4' />
{t('Persist your data file')}
</AlertTitle>
<AlertDescription>
@@ -119,7 +119,7 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
</p>
{isElectron && electronDataDir && (
<p className='mt-3 rounded-md bg-amber-100/70 px-3 py-2 font-mono text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200'>
<p className='bg-warning/10 text-status-warning mt-3 rounded-md px-3 py-2 font-mono text-xs'>
{t('Data directory:')} {electronDataDir}
</p>
)}
@@ -135,9 +135,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
{status?.database_type === 'mysql' && (
<Alert className='border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/40'>
<Alert className='border-success/25 bg-success/8'>
<AlertTitle className='flex items-center gap-2'>
<Server className='size-4 text-emerald-500' />
<Server className='text-success size-4' />
{t('MySQL detected')}
</AlertTitle>
<AlertDescription>
@@ -149,9 +149,9 @@ export function DatabaseStep({ status }: DatabaseStepProps) {
)}
{status?.database_type === 'postgres' && (
<Alert className='border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/40'>
<Alert className='border-info/25 bg-info/8'>
<AlertTitle className='flex items-center gap-2'>
<Server className='size-4 text-sky-500' />
<Server className='text-info size-4' />
{t('PostgreSQL detected')}
</AlertTitle>
<AlertDescription>
@@ -100,31 +100,31 @@ const typeOptions = [
{
value: 'default',
label: 'Default',
color: 'bg-gray-500',
color: 'bg-neutral',
badgeVariant: 'neutral' as const,
},
{
value: 'ongoing',
label: 'Ongoing',
color: 'bg-blue-500',
color: 'bg-info',
badgeVariant: 'info' as const,
},
{
value: 'success',
label: 'Success',
color: 'bg-green-500',
color: 'bg-success',
badgeVariant: 'success' as const,
},
{
value: 'warning',
label: 'Warning',
color: 'bg-orange-500',
color: 'bg-warning',
badgeVariant: 'warning' as const,
},
{
value: 'error',
label: 'Error',
color: 'bg-red-500',
color: 'bg-destructive',
badgeVariant: 'destructive' as const,
},
]
@@ -156,7 +156,7 @@ export function AmountDiscountDialog({
placeholder={t('e.g., 100')}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 0)
field.onChange(Number.parseInt(e.target.value) || 0)
}
disabled={isEditMode}
/>
@@ -188,14 +188,14 @@ export function AmountDiscountDialog({
placeholder={t('e.g., 0.95')}
{...field}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
field.onChange(Number.parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{t('Final price multiplier (0.95 = 5% discount')}
{discountPercentage > 0 && (
<span className='ml-1 font-medium text-green-600 dark:text-green-400'>
<span className='text-status-success ml-1 font-medium'>
= {discountPercentage}
{t('% off')}
</span>
@@ -234,7 +234,7 @@ export function IoNetDeploymentSettingsSection({
{testState.ok === true ? (
<Alert variant='default' className='flex items-center gap-2'>
<CheckCircle2 className='size-4 text-green-600' />
<CheckCircle2 className='text-success size-4' />
<div>
<AlertTitle>{t('Connection successful')}</AlertTitle>
<AlertDescription>
@@ -1251,24 +1251,24 @@ export function PaymentSettingsSection({
</p>
</div>
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
<p className='mb-2 font-medium'>
{t('Webhook Configuration:')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/stripe/webhook'}
</code>
</li>
<li>
{t('Required events:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{t('checkout.session.completed')}
</code>{' '}
{t('and')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{t('checkout.session.expired')}
</code>
</li>
@@ -1443,14 +1443,14 @@ export function PaymentSettingsSection({
</p>
</div>
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 dark:bg-blue-950 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm'>
<p className='mb-2 font-medium'>
{t('Webhook Configuration:')}
</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL:')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/creem/webhook'}
</code>
</li>
@@ -384,19 +384,19 @@ export function WaffoPancakeSettingsSection({
</p>
</div>
<div className='grid min-w-0 gap-x-5 gap-y-4 lg:grid-cols-2'>
{/* Blue box — webhook configuration only. */}
<div className='rounded-md bg-blue-50 p-4 text-sm text-blue-900 lg:col-span-2 dark:bg-blue-950 dark:text-blue-100'>
{/* Webhook configuration notice. */}
<div className='border-info/25 bg-info/8 rounded-md border p-4 text-sm lg:col-span-2'>
<p className='mb-2 font-medium'>{t('Webhook Configuration:')}</p>
<ul className='list-inside list-disc space-y-1'>
<li>
{t('Webhook URL (Test):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/waffo-pancake/webhook/test'}
</code>
</li>
<li>
{t('Webhook URL (Production):')}{' '}
<code className='rounded bg-blue-100 px-1 py-0.5 text-xs dark:bg-blue-900'>
<code className='bg-info/10 rounded px-1 py-0.5 text-xs'>
{'<ServerAddress>/api/waffo-pancake/webhook/prod'}
</code>
</li>
@@ -474,7 +474,7 @@ export function WaffoPancakeSettingsSection({
for. Subscriptions reuse the same Store but get their own
per-plan product, configured in the Subscriptions admin.
*/}
<div className='rounded-md border border-blue-200 bg-blue-50 p-3 text-xs text-blue-900 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-100'>
<div className='border-info/25 bg-info/8 rounded-md border p-3 text-xs'>
<p className='mb-1 font-medium'>
{t('Why only one store + product?')}
</p>
@@ -86,7 +86,7 @@ export function createTimestampColumn<T>(config: {
cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number
if (!timestamp) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<span className='text-xs tabular-nums'>
@@ -135,7 +135,7 @@ export function createDurationColumn<T>(config: {
)
if (!duration) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
const variant =
@@ -173,7 +173,7 @@ export function createChannelColumn<T>(config: {
cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number
if (!channelId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<CopyableStatusBadge
@@ -215,7 +215,7 @@ export function createFailReasonColumn<T>(config: {
const [dialogOpen, setDialogOpen] = useState(false)
if (!failReason) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -265,7 +265,7 @@ export function createProgressColumn<T>(config: {
cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string
if (!progress) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<StatusBadge variant='neutral' className='font-mono'>
@@ -442,7 +442,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)}
</div>
{log.channel_name && (
<span className='text-muted-foreground/70 truncate text-xs'>
<span className='text-subtle-foreground truncate text-xs'>
{channelName}
</span>
)}
@@ -579,25 +579,20 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (groupRatioText) metaParts.push(groupRatioText)
return (
<div className='flex max-w-[200px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger render={<div className='max-w-full' />}>
<TooltipTrigger render={<div className='shrink-0' />}>
{sensitiveVisible ? (
<CopyableStatusBadge
value={tokenName}
variant='neutral'
size='sm'
className='max-w-full'
>
{displayName}
</CopyableStatusBadge>
) : (
<StatusBadge
variant='neutral'
size='sm'
className='max-w-full'
>
<StatusBadge variant='neutral' size='sm'>
{displayName}
</StatusBadge>
)}
@@ -610,7 +605,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
</Tooltip>
</TooltipProvider>
{metaParts.length > 0 && (
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
{metaParts.join(' · ')}
</span>
)}
@@ -700,7 +695,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
))}
</div>
<div className='flex items-center gap-1 text-xs leading-none'>
<span className='text-muted-foreground/60 text-xs leading-none'>
<span className='text-subtle-foreground text-xs leading-none'>
{log.is_stream ? t('Stream') : t('Non-stream')}
{tokensPerSecond != null && (
<>
@@ -782,12 +777,12 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
{(cacheReadTokens > 0 || cacheWriteTokens > 0) && (
<div className='flex items-center gap-1 text-xs'>
{cacheReadTokens > 0 && (
<span className='text-muted-foreground/60'>
<span className='text-subtle-foreground'>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span className='text-muted-foreground/60'>
<span className='text-subtle-foreground'>
{cacheWriteTokens.toLocaleString()}
</span>
)}
@@ -860,7 +855,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
let detailsContent = <span className='text-muted-foreground/40'></span>
let detailsContent = <span className='text-faint-foreground'></span>
if (log.content) {
detailsContent = (
@@ -873,7 +868,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
if (primary) {
let primaryClassName = 'text-foreground'
if (primary.muted) {
primaryClassName = 'text-muted-foreground/60'
primaryClassName = 'text-subtle-foreground'
} else if (primary.danger) {
primaryClassName = 'text-destructive'
}
@@ -887,7 +882,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
<span className='text-faint-foreground ml-0.5'>
+{segments.length - 1}
</span>
)}
@@ -905,7 +900,7 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
>
{detailsContent}
{ip && (
<span className='text-muted-foreground/60 max-w-full truncate tabular-nums'>
<span className='text-subtle-foreground max-w-full truncate tabular-nums'>
{ip}
</span>
)}
@@ -149,16 +149,16 @@ export function useDrawingLogsColumns(
const mjId = row.getValue('mj_id') as string
if (!mjId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-[160px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<CopyableStatusBadge
value={mjId}
variant='neutral'
size='sm'
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
className='font-mono'
>
{mjId}
</CopyableStatusBadge>
@@ -215,7 +215,7 @@ export function useDrawingLogsColumns(
const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -254,7 +254,7 @@ export function useDrawingLogsColumns(
const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
@@ -17,9 +17,8 @@ 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, useMemo } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
@@ -28,14 +27,13 @@ import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { TASK_ACTIONS, TASK_STATUS } from '../../constants'
import { taskActionMapper, taskStatusMapper } from '../../lib/mappers'
import type { TaskLog } from '../../types'
import {
AudioPreviewDialog,
type AudioClip,
} from '../dialogs/audio-preview-dialog'
import { FailReasonDialog } from '../dialogs/fail-reason-dialog'
getTaskPlatformName,
taskActionMapper,
taskStatusMapper,
} from '../../lib/mappers'
import type { TaskLog } from '../../types'
import { TaskDetailsDialog } from '../dialogs/task-details-dialog'
import { useUsageLogsContext } from '../usage-logs-provider'
import {
createDurationColumn,
@@ -43,53 +41,6 @@ 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>[] = [
@@ -106,11 +57,11 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
{formatTimestampToDate(submitTime, 'seconds')}
</span>
{log.finish_time ? (
<span className='text-muted-foreground/60 text-xs tabular-nums'>
<span className='text-subtle-foreground text-xs tabular-nums'>
{formatTimestampToDate(log.finish_time, 'seconds')}
</span>
) : (
<span className='text-muted-foreground/50 text-xs'>-</span>
<span className='text-subtle-foreground text-xs'>-</span>
)}
</div>
)
@@ -180,20 +131,21 @@ 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-muted-foreground/60 text-xs'>-</span>
return <span className='text-subtle-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-[170px] flex-col gap-0.5'>
<div className='flex w-max flex-col gap-0.5'>
<CopyableStatusBadge
value={taskId}
variant='neutral'
size='sm'
className='h-auto max-w-full overflow-visible font-mono [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
className='font-mono'
>
{taskId}
</CopyableStatusBadge>
<span className='text-muted-foreground/60 text-xs [overflow-wrap:anywhere] break-words'>
{t(log.platform)} · {t(taskActionMapper.getLabel(log.action))}
<span className='text-subtle-foreground text-xs [overflow-wrap:anywhere] break-words'>
{getTaskPlatformName(log.platform)} ·{' '}
{t(taskActionMapper.getLabel(log.action))}
</span>
</div>
)
@@ -229,79 +181,14 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
},
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
{
accessorKey: 'fail_reason',
id: 'details',
header: t('Details'),
enableSorting: false,
cell: function DetailsCell({ row }) {
const log = row.original
const failReason = row.getValue('fail_reason') as string
const status = log.status
const [dialogOpen, setDialogOpen] = useState(false)
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='text-destructive truncate leading-snug group-hover:underline'>
{failReason}
</span>
</button>
<FailReasonDialog
failReason={failReason}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
return <TaskDetailsCell log={row.original} isAdmin={isAdmin} />
},
size: 200,
maxSize: 220,
size: 120,
maxSize: 140,
meta: {
cardRole: 'secondary',
cardOrder: 20,
@@ -313,3 +200,34 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
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}
/>
</>
)
}
@@ -16,15 +16,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { ExternalLink, Copy, Music } from 'lucide-react'
import { ExternalLink, Copy } 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 { ScrollArea } from '@/components/ui/scroll-area'
export interface AudioClip {
clip_id?: string
@@ -41,12 +39,6 @@ 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)
@@ -54,7 +46,7 @@ function formatDuration(seconds?: number): string {
return `${m}:${s.toString().padStart(2, '0')}`
}
function AudioClipCard({ clip }: { clip: AudioClip }) {
export function AudioClipCard({ clip }: { clip: AudioClip }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
@@ -135,39 +127,3 @@ 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>
)
}
@@ -55,7 +55,7 @@ export function FailReasonDialog({
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<div className='border-destructive/25 bg-destructive/8 relative rounded-md border p-3'>
<Button
variant='ghost'
size='icon'
@@ -64,12 +64,12 @@ export function FailReasonDialog({
title={t('Copy to clipboard')}
>
{copiedText === failReason ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
<p className='text-status-destructive overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap'>
{failReason || '-'}
</p>
</div>
@@ -65,7 +65,7 @@ export function PromptDialog({
title={t('Copy to clipboard')}
>
{copiedText === prompt ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
@@ -91,7 +91,7 @@ export function PromptDialog({
title={t('Copy to clipboard')}
>
{copiedText === promptEn ? (
<Check className='size-4 text-green-600' />
<Check className='text-success size-4' />
) : (
<Copy className='size-4' />
)}
@@ -0,0 +1,633 @@
/*
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>
)
}
@@ -150,9 +150,10 @@ function ModelBadgeContent(
return (
<StatusBadge
variant='neutral'
appearance='soft'
size='sm'
className={cn(
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
colorClassName,
props.className
)}
@@ -166,9 +167,10 @@ function ModelBadgeContent(
<CopyableStatusBadge
value={props.modelName}
variant='neutral'
appearance='soft'
size='sm'
className={cn(
'h-5! max-w-none shrink-0 whitespace-nowrap! border-current/20 [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
'h-5! max-w-none shrink-0 whitespace-nowrap! [&_[data-slot=status-badge-label]]:whitespace-nowrap!',
colorClassName,
props.className
)}
@@ -97,7 +97,7 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
{(titleCell || badgeCell) && (
<div className='flex min-w-0 items-start justify-between gap-3'>
{titleCell && (
<div className='min-w-0 flex-1 text-[15px] leading-tight font-semibold break-words'>
<div className='min-w-0 flex-1 text-base leading-tight font-semibold break-words'>
{flexRender(
titleCell.column.columnDef.cell,
titleCell.getContext()
@@ -115,31 +115,42 @@ function UsageLogCard<TData>(props: { cells: Cell<TData, unknown>[] }) {
</div>
)}
{rowCells.length > 0 && (
<div className='mt-3 space-y-0.5 border-t pt-3'>
{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>
)}
{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>
)}
{wideCells.length > 0 && (
<div className='mt-3 space-y-3 border-t pt-3'>
{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>
))}
{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>
)}
</div>
)}
</div>
+19
View File
@@ -20,6 +20,8 @@ For commercial licensing, please contact support@quantumnous.com
* Status mappers for different log types
* Centralized mapper instances for consistent usage across components
*/
import { CHANNEL_TYPES } from '@/features/channels/constants'
import {
MJ_TASK_TYPE_MAPPINGS,
MJ_STATUS_MAPPINGS,
@@ -69,3 +71,20 @@ export const taskStatusMapper = createStatusMapper(TASK_STATUS_MAPPINGS)
* Task platform mapper
*/
export const taskPlatformMapper = createStatusMapper(TASK_PLATFORM_MAPPINGS)
/**
* Resolve a task platform value to a human-readable name.
*
* Video tasks store the platform as a numeric channel type (e.g. "54"), which
* maps to a channel name like "DoubaoVideo". Suno-style tasks store a slug
* (e.g. "suno"). Falls back to the raw value when nothing matches.
*/
export function getTaskPlatformName(platform: string): string {
if (!platform) return ''
const num = Number(platform)
if (Number.isInteger(num) && num > 0) {
const name = CHANNEL_TYPES[num as keyof typeof CHANNEL_TYPES]
if (name) return name
}
return platform
}
+7
View File
@@ -269,12 +269,19 @@ export interface TaskLog {
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
channel_id: number
group?: string
quota?: number
submit_time: number // seconds
start_time?: number // seconds
finish_time?: number // seconds
progress?: string
progress_message_en?: string
data?: string // JSON string
fail_reason?: string
// Task result URL (video address, etc.). Backend falls back to fail_reason
// when no dedicated result URL is stored, so always validate before use.
result_url?: string
properties?: unknown
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
other?: string
created_at?: number
@@ -115,9 +115,7 @@ export function useUsersColumns(): ColumnDef<User>[] {
{remark && (
<Tooltip>
<TooltipTrigger render={<StatusBadge variant='success' />}>
<LongText className='max-w-full sm:max-w-[80px]'>
{remark}
</LongText>
{remark}
</TooltipTrigger>
<TooltipContent>
<p className='text-xs'>{remark}</p>
@@ -116,7 +116,7 @@ export function PaymentConfirmDialog({
<div className='bg-muted/50 rounded-lg p-3'>
<div className='flex items-center justify-between text-sm'>
<span className='text-muted-foreground'>{t('You save')}</span>
<span className='font-semibold text-green-600'>
<span className='text-status-success font-semibold'>
{formatCurrency(discountAmount)}
</span>
</div>