refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+112
View File
@@ -0,0 +1,112 @@
/*
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 { api } from '@/lib/api'
import { buildQueryParams } from './lib/utils'
import type {
GetLogsParams,
GetLogsResponse,
GetLogStatsParams,
GetLogStatsResponse,
GetMidjourneyLogsParams,
GetTaskLogsParams,
UserInfo,
} from './types'
// ============================================================================
// Generic API Helpers
// ============================================================================
function buildApiPath(endpoint: string, isAdmin: boolean): string {
return isAdmin ? endpoint : `${endpoint}/self`
}
async function fetchLogs<T>(
endpoint: string,
params: T,
isAdmin: boolean
): Promise<GetLogsResponse> {
const paramRecord = params as unknown as Record<string, unknown>
const queryParams = buildQueryParams({
p: paramRecord.p || 1,
page_size: paramRecord.page_size || 20,
...params,
})
const path = buildApiPath(endpoint, isAdmin)
const res = await api.get(`${path}?${queryParams}`)
return res.data
}
async function fetchLogStats<T>(
endpoint: string,
params: T,
isAdmin: boolean
): Promise<GetLogStatsResponse> {
const queryParams = buildQueryParams(
params as unknown as Record<string, unknown>
)
const path = buildApiPath(endpoint, isAdmin)
const res = await api.get(`${path}/stat?${queryParams}`)
return res.data
}
// ============================================================================
// Common Log APIs
// ============================================================================
export const getAllLogs = (params: GetLogsParams = {}) =>
fetchLogs('/api/log', params, true)
export const getUserLogs = (
params: Omit<GetLogsParams, 'username' | 'channel'> = {}
) => fetchLogs('/api/log', params, false)
export const getLogStats = (params: GetLogStatsParams = {}) =>
fetchLogStats('/api/log', params, true)
export const getUserLogStats = (
params: Omit<GetLogStatsParams, 'username' | 'channel'> = {}
) => fetchLogStats('/api/log', params, false)
export async function getUserInfo(
userId: number
): Promise<{ success: boolean; message?: string; data?: UserInfo }> {
const res = await api.get(`/api/user/${userId}`)
return res.data
}
// ============================================================================
// MjProxy (Drawing) Logs API
// ============================================================================
export const getAllMidjourneyLogs = (params: GetMidjourneyLogsParams) =>
fetchLogs('/api/mj', params, true)
export const getUserMidjourneyLogs = (params: GetMidjourneyLogsParams) =>
fetchLogs('/api/mj', params, false)
// ============================================================================
// Task Logs API
// ============================================================================
export const getAllTaskLogs = (params: GetTaskLogsParams) =>
fetchLogs('/api/task', params, true)
export const getUserTaskLogs = (params: GetTaskLogsParams) =>
fetchLogs('/api/task', params, false)
@@ -0,0 +1,271 @@
/*
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 type { ColumnDef } from '@tanstack/react-table'
import { Zap } from 'lucide-react'
/* eslint-disable react-refresh/only-export-components */
import { useState } from 'react'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
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'
/**
* Cache tooltip component for token display
*/
export function CacheTooltip({
tokens,
label,
color,
}: {
tokens: number
label: string
color: string
}) {
if (tokens <= 0) return null
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<Zap className={`size-3 flex-shrink-0 ${color}`} />}
></TooltipTrigger>
<TooltipContent side='top'>
<p className='text-xs'>
{label}: {formatTokens(tokens)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// ============================================================================
// Column Definition Factories
// ============================================================================
/**
* Create a timestamp column - compact mono style matching common logs
*/
export function createTimestampColumn<T>(config: {
accessorKey: string
title: string
unit?: 'seconds' | 'milliseconds'
}): ColumnDef<T> {
const { accessorKey, title, unit = 'milliseconds' } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={title} />
),
cell: ({ row }) => {
const timestamp = row.getValue(accessorKey) as number
if (!timestamp) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<span className='font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp, unit)}
</span>
)
},
meta: { label: title },
}
}
/**
* Create a duration column - pill style matching common logs timing
*/
export function createDurationColumn<T>(config: {
submitTimeKey: string
finishTimeKey: string
unit?: 'seconds' | 'milliseconds'
headerLabel: string
warningThresholdSec?: number
}): ColumnDef<T> {
const {
submitTimeKey,
finishTimeKey,
unit = 'milliseconds',
headerLabel,
warningThresholdSec = 60,
} = config
return {
id: 'duration',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const log = row.original as Record<string, unknown>
const duration = formatDuration(
log[submitTimeKey] as number | undefined,
log[finishTimeKey] as number | undefined,
unit
)
if (!duration) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
const variant =
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
label={`${duration.durationSec.toFixed(1)}s`}
variant={variant}
size='sm'
copyable={false}
className={cn('rounded-md font-mono', durationBgMap[variant])}
/>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a channel column (admin only) - #id badge matching common logs
*/
export function createChannelColumn<T>(config: {
accessorKey?: string
headerLabel: string
}): ColumnDef<T> {
const { accessorKey = 'channel_id', headerLabel } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const channelId = row.getValue(accessorKey) as number
if (!channelId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<StatusBadge
label={`#${channelId}`}
autoColor={String(channelId)}
copyText={String(channelId)}
size='sm'
showDot={false}
className='font-mono'
/>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a fail reason column - text-xs truncate, hover underline, dialog
*/
export function createFailReasonColumn<T>(config: {
accessorKey?: string
headerLabel: string
cellTitle: string
}): ColumnDef<T> {
const { accessorKey = 'fail_reason', headerLabel, cellTitle } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: function FailReasonCell({ row }) {
const failReason = row.getValue(accessorKey) as string
const [dialogOpen, setDialogOpen] = useState(false)
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={cellTitle}
>
<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}
/>
</>
)
},
meta: { label: headerLabel },
}
}
/**
* Create a progress column - compact mono pill
*/
export function createProgressColumn<T>(config: {
accessorKey?: string
headerLabel: string
}): ColumnDef<T> {
const { accessorKey = 'progress', headerLabel } = config
return {
accessorKey,
header: ({ column }) => (
<DataTableColumnHeader column={column} title={headerLabel} />
),
cell: ({ row }) => {
const progress = row.getValue(accessorKey) as string
if (!progress) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<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}
</span>
)
},
meta: { label: headerLabel },
}
}
@@ -0,0 +1,837 @@
/*
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 type { ColumnDef } from '@tanstack/react-table'
import { GitBranch, Sparkles, KeyRound } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatBillingCurrencyFromUSD } from '@/lib/currency'
import { formatLogQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { LOG_TYPE_ALL_VALUE } from '../../constants'
import type { UsageLog } from '../../data/schema'
import {
formatModelName,
getTieredBillingSummary,
hasAnyCacheTokens,
parseLogOther,
isViolationFeeLog,
renderAuditContent,
} from '../../lib/format'
import {
isDisplayableLogType,
isTimingLogType,
getLogTypeConfig,
isPerCallBilling,
} from '../../lib/utils'
import type { LogOtherData } from '../../types'
import { DetailsDialog } from '../dialogs/details-dialog'
import { ModelBadge } from '../model-badge'
import { TimingMetricsCell, StreamTpsCell } from '../timing-metrics-cell'
import { useUsageLogsContext } from '../usage-logs-provider'
interface DetailSegment {
text: string
muted?: boolean
danger?: boolean
}
function formatRatioCompact(ratio: number | undefined): string {
if (ratio == null || !Number.isFinite(ratio)) return '-'
return ratio % 1 === 0
? String(ratio)
: ratio.toFixed(4).replace(/\.?0+$/, '')
}
function getGroupRatio(other: LogOtherData | null): number | null {
const userGroupRatio = other?.user_group_ratio
if (
userGroupRatio != null &&
userGroupRatio !== -1 &&
Number.isFinite(userGroupRatio)
) {
return userGroupRatio
}
const groupRatio = other?.group_ratio
if (groupRatio != null && groupRatio !== 1 && Number.isFinite(groupRatio)) {
return groupRatio
}
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,
t: (key: string, opts?: Record<string, unknown>) => string,
isAdmin: boolean
): DetailSegment[] {
const segments = buildTypeDetailSegments(log, other, t)
// Quota saturation is a rare, admin-only anomaly marker; surface it first
// and in danger styling so it stands out on the related billing log. The
// backend already strips admin_info for non-admins; gate on isAdmin too as
// defense in depth so the marker never leaks if that changes.
if (isAdmin && other?.admin_info?.quota_saturation) {
return [{ text: t('Quota clamped'), danger: true }, ...segments]
}
return segments
}
function buildTypeDetailSegments(
log: UsageLog,
other: LogOtherData | null,
t: (key: string, opts?: Record<string, unknown>) => string
): DetailSegment[] {
// Audit (type=3) and login (type=7) logs: render localized content from the
// structured op descriptor instead of the raw (English-fallback) content.
if (log.type === 3 || log.type === 7) {
const text = renderAuditContent(other, t)
return text ? [{ text }] : []
}
if (log.type === 6) {
return [{ text: t('Async task refund') }]
}
if (log.type !== 2) return []
const isViolation = isViolationFeeLog(other)
if (isViolation) {
const segments: DetailSegment[] = []
segments.push({ text: t('Violation Fee'), danger: true })
if (other?.violation_fee_code) {
segments.push({
text: other.violation_fee_code,
muted: true,
})
}
segments.push({
text: `${t('Fee')}: ${formatLogQuota(other?.fee_quota ?? log.quota)}`,
muted: true,
})
return segments
}
if (!other) return []
const segments: DetailSegment[] = []
const priceOpts = { digitsLarge: 4, digitsSmall: 6, abbreviate: false }
const formatPrice = (price: number) =>
`${formatBillingCurrencyFromUSD(price, priceOpts)}/M`
const formatPriceCompact = (price: number) =>
formatBillingCurrencyFromUSD(price, priceOpts)
const formatPriceList = (prices: string[], showUnit: boolean) => {
const text = prices.join(' / ')
return showUnit ? `${text}/M` : text
}
const isTieredExpr = other.billing_mode === 'tiered_expr'
const tieredSummary = getTieredBillingSummary(other)
if (isTieredExpr) {
if (tieredSummary) {
const baseEntries = tieredSummary.priceEntries
.filter((entry) => ['inputPrice', 'outputPrice'].includes(entry.field))
.map((entry) => formatPriceCompact(entry.price))
if (baseEntries.length > 0) {
const tierLabel = tieredSummary.tier.label || t('Default')
segments.push({
text: `${tierLabel} · ${formatPriceList(baseEntries, true)}`,
})
}
const cacheEntries = tieredSummary.priceEntries
.filter((entry) =>
['cacheReadPrice', 'cacheCreatePrice', 'cacheCreate1hPrice'].includes(
entry.field
)
)
.map((entry) => {
return formatPriceCompact(entry.price)
})
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
const otherEntries = tieredSummary.priceEntries
.filter(
(entry) =>
![
'inputPrice',
'outputPrice',
'cacheReadPrice',
'cacheCreatePrice',
'cacheCreate1hPrice',
].includes(entry.field)
)
.map((entry) => `${t(entry.shortLabel)} ${formatPrice(entry.price)}`)
if (otherEntries.length > 0) {
segments.push({
text: otherEntries.join(' · '),
muted: true,
})
}
} else {
segments.push({
text: `${t('Dynamic Pricing')} · ${t('No matching results')}`,
muted: true,
})
}
} else {
const modelPrice = other.model_price
const isPerCall = isPerCallBilling(modelPrice)
if (isPerCall && modelPrice != null) {
segments.push({
text: `${t('Per-call')} · ${formatBillingCurrencyFromUSD(modelPrice, priceOpts)}`,
})
} else if (other.model_ratio != null) {
const inputPriceUSD = other.model_ratio * 2.0
const baseEntries = [formatPriceCompact(inputPriceUSD)]
if (other.completion_ratio != null) {
baseEntries.push(
formatPriceCompact(inputPriceUSD * other.completion_ratio)
)
}
segments.push({
text: `${t('Standard')} · ${formatPriceList(baseEntries, true)}`,
})
if (hasAnyCacheTokens(other)) {
const cacheEntries = [
other.cache_ratio != null && other.cache_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_ratio)
: null,
other.cache_creation_ratio != null && other.cache_creation_ratio !== 1
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio)
: null,
other.cache_creation_ratio_1h != null &&
other.cache_creation_ratio_1h !== 0
? formatPriceCompact(inputPriceUSD * other.cache_creation_ratio_1h)
: null,
].filter(Boolean) as string[]
if (cacheEntries.length > 0) {
segments.push({
text: `${t('Cache')} ${formatPriceList(cacheEntries, false)}`,
muted: true,
})
}
}
} else {
const userGroupRatio = other.user_group_ratio
const groupRatio = other.group_ratio
const isUserGroup =
userGroupRatio != null &&
Number.isFinite(userGroupRatio) &&
userGroupRatio !== -1
const effectiveRatio = isUserGroup ? userGroupRatio : groupRatio
const ratioLabel = isUserGroup
? t('User Exclusive Ratio')
: t('Group Ratio')
if (effectiveRatio != null && Number.isFinite(effectiveRatio)) {
segments.push({
text: `${ratioLabel} ${formatRatioCompact(effectiveRatio)}x`,
})
}
}
}
if (other.is_system_prompt_overwritten) {
segments.push({
text: t('System Prompt Override'),
danger: true,
})
}
return segments
}
export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<UsageLog>[] = [
{
accessorKey: 'created_at',
header: t('Time'),
cell: ({ row }) => {
const log = row.original
const timestamp = row.getValue('created_at') as number
const config = getLogTypeConfig(log.type)
return (
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(timestamp)}
</span>
<StatusBadge
label={t(config.label)}
variant={config.color as StatusBadgeProps['variant']}
size='sm'
copyable={false}
className='-ml-1.5 !text-xs [&_span]:!text-xs'
/>
</div>
)
},
filterFn: (row, _id, value) => {
if (!Array.isArray(value) || value.length === 0) return true
if (value.includes(LOG_TYPE_ALL_VALUE)) return true
return value.includes(String(row.original.type))
},
enableHiding: false,
size: 180,
},
]
if (isAdmin) {
columns.push(
{
id: 'channel',
header: t('Channel'),
accessorFn: (row) => row.channel,
cell: function ChannelCell({ row }) {
const { sensitiveVisible, setAffinityTarget, setAffinityDialogOpen } =
useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const affinity = other?.admin_info?.channel_affinity
const rawUseChannel = other?.admin_info?.use_channel ?? []
const useChannel = Array.isArray(rawUseChannel)
? rawUseChannel.map(String).filter(Boolean)
: []
const hasRetryChain = useChannel.length > 1
const channelChain = hasRetryChain
? useChannel.join(' → ')
: undefined
const channelDisplay = log.channel_name
? `${log.channel_name} #${log.channel}`
: `#${log.channel}`
const channelIdDisplay = `#${log.channel}`
const channelName = sensitiveVisible ? log.channel_name : '••••'
const multiKeyIndex = other?.admin_info?.multi_key_index
const showMultiKeyIndex =
other?.admin_info?.is_multi_key === true &&
typeof multiKeyIndex === 'number' &&
Number.isFinite(multiKeyIndex)
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<div className='flex max-w-[160px] flex-col gap-0.5' />
}
>
<div className='relative inline-flex w-fit items-center gap-1'>
<StatusBadge
label={channelIdDisplay}
autoColor={String(log.channel)}
copyText={String(log.channel)}
size='sm'
showDot={false}
className='font-mono'
/>
{showMultiKeyIndex && (
<StatusBadge
label={String(multiKeyIndex)}
size='sm'
showDot={false}
copyable={false}
variant='neutral'
className='h-5 min-w-5 justify-center rounded-full px-1 font-mono text-xs'
aria-label={`${t('Key')} ${multiKeyIndex}`}
/>
)}
{hasRetryChain && (
<Popover>
<PopoverTrigger
render={
<button
type='button'
className='text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex size-5 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:outline-none'
aria-label={t('Retry Chain')}
onClick={(e) => e.stopPropagation()}
/>
}
>
<GitBranch
className='size-3.5 text-amber-500'
aria-hidden='true'
/>
</PopoverTrigger>
<PopoverContent
side='top'
align='start'
className='w-64 text-xs'
>
<div className='flex flex-col gap-1'>
<p className='font-medium'>{t('Retry Chain')}</p>
<p className='text-muted-foreground font-mono break-all'>
{channelChain}
</p>
</div>
</PopoverContent>
</Popover>
)}
{affinity && (
<button
type='button'
className='absolute -top-1 -right-1 leading-none text-amber-500'
onClick={(e) => {
e.stopPropagation()
setAffinityTarget({
rule_name: affinity.rule_name || '',
using_group:
affinity.using_group ||
affinity.selected_group ||
'',
key_hint: affinity.key_hint || '',
key_fp: affinity.key_fp || '',
})
setAffinityDialogOpen(true)
}}
>
<Sparkles className='size-3 fill-current' />
</button>
)}
</div>
{log.channel_name && (
<span className='text-muted-foreground/70 truncate [font-family:var(--font-body)] !text-xs'>
{channelName}
</span>
)}
</TooltipTrigger>
<TooltipContent>
<div className='space-y-1'>
<p>
{sensitiveVisible ? channelDisplay : channelIdDisplay}
</p>
{channelChain && (
<p className='text-muted-foreground text-xs'>
{t('Chain')}: {channelChain}
</p>
)}
{showMultiKeyIndex && (
<p className='text-muted-foreground text-xs'>
{t('Key')}: {multiKeyIndex}
</p>
)}
{affinity && (
<div className='border-t pt-1 text-xs'>
<p className='font-medium'>{t('Channel Affinity')}</p>
<p>
{t('Rule')}: {affinity.rule_name || '-'}
</p>
<p>
{t('Group')}:{' '}
{sensitiveVisible
? affinity.using_group ||
affinity.selected_group ||
'-'
: '••••'}
</p>
</div>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
},
},
{
id: 'user',
header: t('User'),
accessorFn: (row) => row.username,
cell: function UserCell({ row }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext()
const log = row.original
if (!log.username) return null
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible
? getUserAvatarStyle(log.username)
: undefined
}
>
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
</Avatar>
<TooltipProvider delay={300}>
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground max-w-[100px] truncate text-sm hover:underline' />
}
>
{sensitiveVisible ? log.username : '••••'}
</TooltipTrigger>
{sensitiveVisible && log.username.length > 12 && (
<TooltipContent side='top'>{log.username}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</button>
)
},
}
)
}
columns.push({
accessorKey: 'token_name',
header: t('Token'),
cell: function TokenNameCell({ row }) {
const { sensitiveVisible } = useUsageLogsContext()
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const tokenName = log.token_name
if (!tokenName) return null
const other = parseLogOther(log.other)
const displayName = sensitiveVisible ? tokenName : '••••'
let group = log.group
if (!group) group = other?.group || ''
const groupRatio = getGroupRatio(other)
return (
<div className='flex max-w-[200px] flex-col gap-0.5'>
<TooltipProvider delay={300}>
<Tooltip>
<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'>
{tokenName}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
{(group || groupRatio != null) && (
<span className='block max-w-full truncate text-xs leading-none'>
{group ? (
<GroupBadge
group={group}
label={sensitiveVisible ? undefined : '••••'}
type='text'
size='sm'
className='inline align-baseline text-xs leading-none [&>span]:leading-none'
/>
) : null}
{group && groupRatio != null ? ' ' : null}
{groupRatio != null ? (
<span className='text-muted-foreground/60 relative top-px align-baseline tabular-nums'>
{formatRatioCompact(groupRatio)}x
</span>
) : null}
</span>
)}
</div>
)
},
size: 160,
})
columns.push(
{
accessorKey: 'model_name',
header: t('Model'),
cell: function ModelCell({ row }) {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const modelInfo = formatModelName(log)
return (
<div className='flex w-fit flex-col gap-0.5'>
<ModelBadge
modelName={modelInfo.name}
actualModel={modelInfo.actualModel}
/>
</div>
)
},
meta: { mobileTitle: true },
},
{
accessorKey: 'is_stream',
header: t('Stream'),
cell: ({ row }) => {
const log = row.original
if (!isTimingLogType(log.type)) return null
const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other)
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
return (
<StreamTpsCell
isStream={log.is_stream}
tokensPerSecond={tokensPerSecond}
streamStatus={other?.stream_status}
/>
)
},
meta: { label: t('Stream') },
},
{
accessorKey: 'prompt_tokens',
header: 'Tokens',
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const other = parseLogOther(log.other)
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return <span className='text-muted-foreground text-xs'>-</span>
}
const cacheReadTokens = other?.cache_tokens || 0
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
return (
<div className='flex flex-col gap-0.5'>
<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-[11px]'>
{cacheReadTokens > 0 && (
<span className='text-muted-foreground/60'>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span className='text-muted-foreground/60'>
{cacheWriteTokens.toLocaleString()}
</span>
)}
</div>
)}
</div>
)
},
},
{
accessorKey: 'quota',
header: t('Cost'),
cell: ({ row }) => {
const log = row.original
if (!isDisplayableLogType(log.type)) return null
const quota = row.getValue('quota') as number
const other = parseLogOther(log.other)
const isSubscription = other?.billing_source === 'subscription'
if (isSubscription) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={t('Subscription')}
variant='success'
size='sm'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<span>
{t('Deducted by subscription')}: {formatLogQuota(quota)}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
const quotaStr = formatLogQuota(quota)
const quotaDisplay = splitQuotaDisplay(quotaStr)
return (
<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>
)
},
},
{
accessorKey: 'use_time',
header: t('Timing'),
cell: ({ row }) => {
const log = row.original
if (!isTimingLogType(log.type)) return null
const useTime = row.getValue('use_time') as number
const other = parseLogOther(log.other)
return (
<TimingMetricsCell
useTimeSec={useTime}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
/>
)
},
},
{
accessorKey: 'content',
header: t('Details'),
cell: function DetailsCell({ row }) {
const [dialogOpen, setDialogOpen] = useState(false)
const log = row.original
const other = parseLogOther(log.other)
const segments = buildDetailSegments(log, other, t, isAdmin)
const primary = segments[0]
const hasMore = segments.length > 1
let primaryTextClass = 'text-foreground'
if (primary?.muted) {
primaryTextClass = 'text-muted-foreground/60'
} else if (primary?.danger) {
primaryTextClass = 'text-red-600 dark:text-red-400'
}
let detailPreview = <span className='text-muted-foreground/40'></span>
if (primary) {
detailPreview = (
<span
className={cn(
'truncate leading-snug group-hover:underline',
primaryTextClass
)}
>
{primary.text}
{hasMore && (
<span className='text-muted-foreground/40 ml-0.5'>
+{segments.length - 1}
</span>
)}
</span>
)
} else if (log.content) {
detailPreview = (
<span className='text-muted-foreground truncate group-hover:underline'>
{log.content}
</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 details')}
>
{detailPreview}
</button>
<DetailsDialog
log={log}
isAdmin={isAdmin}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
size: 180,
maxSize: 200,
}
)
return columns
}
@@ -0,0 +1,270 @@
/*
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 type { ColumnDef } from '@tanstack/react-table'
import {
Blend,
FileText,
HelpCircle,
ImageIcon,
Maximize2,
Move,
Paintbrush,
RefreshCw,
Scissors,
Shuffle,
Upload,
UserRound,
Video,
WandSparkles,
ZoomIn,
type LucideIcon,
} from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import { formatTimestampToDate } from '@/lib/format'
import { MJ_TASK_TYPES } from '../../constants'
import {
mjTaskTypeMapper,
mjStatusMapper,
mjSubmitResultMapper,
} from '../../lib/mappers'
import type { MidjourneyLog } from '../../types'
import { ImageDialog } from '../dialogs/image-dialog'
import { PromptDialog } from '../dialogs/prompt-dialog'
import {
createDurationColumn,
createChannelColumn,
createProgressColumn,
createFailReasonColumn,
} from './column-helpers'
const drawingTypeIconMap: Record<string, LucideIcon> = {
[MJ_TASK_TYPES.IMAGINE]: ImageIcon,
[MJ_TASK_TYPES.UPSCALE]: Maximize2,
[MJ_TASK_TYPES.VIDEO]: Video,
[MJ_TASK_TYPES.EDITS]: Paintbrush,
[MJ_TASK_TYPES.VARIATION]: Shuffle,
[MJ_TASK_TYPES.HIGH_VARIATION]: Shuffle,
[MJ_TASK_TYPES.LOW_VARIATION]: Shuffle,
[MJ_TASK_TYPES.PAN]: Move,
[MJ_TASK_TYPES.DESCRIBE]: FileText,
[MJ_TASK_TYPES.BLEND]: Blend,
[MJ_TASK_TYPES.UPLOAD]: Upload,
[MJ_TASK_TYPES.SHORTEN]: Scissors,
[MJ_TASK_TYPES.REROLL]: RefreshCw,
[MJ_TASK_TYPES.INPAINT]: WandSparkles,
[MJ_TASK_TYPES.SWAP_FACE]: UserRound,
[MJ_TASK_TYPES.ZOOM]: ZoomIn,
[MJ_TASK_TYPES.CUSTOM_ZOOM]: ZoomIn,
}
function getDrawingTypeIcon(action: string): LucideIcon {
return drawingTypeIconMap[action] ?? HelpCircle
}
export function useDrawingLogsColumns(
isAdmin: boolean
): ColumnDef<MidjourneyLog>[] {
const { t } = useTranslation()
const columns: ColumnDef<MidjourneyLog>[] = [
{
accessorKey: 'submit_time',
header: t('Submit Time'),
cell: ({ row }) => {
const log = row.original
const submitTime = row.getValue('submit_time') as number
return (
<div className='flex min-w-0 flex-col gap-0.5'>
<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'
copyable={false}
/>
</div>
)
},
size: 180,
},
]
if (isAdmin) {
columns.push(
createChannelColumn<MidjourneyLog>({ headerLabel: t('Channel') })
)
}
columns.push({
accessorKey: 'action',
header: t('Type'),
cell: ({ row }) => {
const action = row.getValue('action') as string
return (
<StatusBadge
label={t(mjTaskTypeMapper.getLabel(action))}
variant={mjTaskTypeMapper.getVariant(action)}
icon={getDrawingTypeIcon(action)}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
})
columns.push({
accessorKey: 'mj_id',
header: t('Task ID'),
cell: ({ row }) => {
const mjId = row.getValue('mj_id') as string
if (!mjId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<div className='flex max-w-[160px] flex-col gap-0.5'>
<StatusBadge
label={mjId}
copyText={mjId}
variant='neutral'
size='sm'
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: { mobileTitle: true },
})
columns.push(
createDurationColumn<MidjourneyLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
headerLabel: t('Duration'),
})
)
if (isAdmin) {
columns.push({
accessorKey: 'code',
header: t('Submit Result'),
cell: ({ row }) => {
const code = row.getValue('code') as number
return (
<StatusBadge
label={t(mjSubmitResultMapper.getLabel(String(code)))}
variant={mjSubmitResultMapper.getVariant(String(code))}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
})
}
columns.push(
createProgressColumn<MidjourneyLog>({ headerLabel: t('Progress') }),
{
accessorKey: 'image_url',
header: t('Image'),
cell: function ImageCell({ row }) {
const log = row.original
const imageUrl = row.getValue('image_url') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!imageUrl) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<button
type='button'
className='group text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view image')}
>
<span className='text-foreground truncate leading-snug group-hover:underline'>
{t('View')}
</span>
</button>
<ImageDialog
imageUrl={imageUrl}
taskId={log.mj_id}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
},
{
accessorKey: 'prompt',
header: t('Prompt'),
cell: function PromptCell({ row }) {
const log = row.original
const prompt = row.getValue('prompt') as string
const [dialogOpen, setDialogOpen] = useState(false)
if (!prompt) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<>
<button
type='button'
className='group flex max-w-[220px] items-center text-left text-xs'
onClick={() => setDialogOpen(true)}
title={t('Click to view full prompt')}
>
<span className='text-muted-foreground truncate leading-snug group-hover:underline'>
{prompt}
</span>
</button>
<PromptDialog
prompt={prompt}
promptEn={log.prompt_en}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</>
)
},
size: 200,
maxSize: 220,
},
createFailReasonColumn<MidjourneyLog>({
headerLabel: t('Fail Reason'),
cellTitle: t('Click to view full error message'),
})
)
return columns
}
@@ -0,0 +1,294 @@
/*
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 type { ColumnDef } from '@tanstack/react-table'
import { Music } from 'lucide-react'
/* eslint-disable react-refresh/only-export-components */
import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
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 { 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'
import { useUsageLogsContext } from '../usage-logs-provider'
import {
createDurationColumn,
createChannelColumn,
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>[] = [
{
accessorKey: 'submit_time',
header: t('Submit Time'),
cell: ({ row }) => {
const log = row.original
const submitTime = row.getValue('submit_time') as number
return (
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='truncate font-mono text-xs tabular-nums'>
{formatTimestampToDate(submitTime, 'seconds')}
</span>
{log.finish_time ? (
<span className='text-muted-foreground/60 truncate font-mono text-[11px] tabular-nums'>
{formatTimestampToDate(log.finish_time, 'seconds')}
</span>
) : (
<span className='text-muted-foreground/50 text-[11px]'>-</span>
)}
</div>
)
},
size: 180,
},
]
if (isAdmin) {
columns.push(createChannelColumn<TaskLog>({ headerLabel: t('Channel') }), {
id: 'user',
header: t('User'),
accessorFn: (row) => row.username || row.user_id,
cell: function UserCell({ row }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext()
const log = row.original
const displayName = log.username || String(log.user_id || '?')
return (
<button
type='button'
className='flex items-center gap-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 ring-1 max-sm:hidden'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getUserAvatarStyle(displayName) : undefined
}
>
{sensitiveVisible ? getUserAvatarFallback(displayName) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-muted-foreground truncate text-sm hover:underline'>
{sensitiveVisible ? displayName : '••••'}
</span>
</button>
)
},
})
}
columns.push(
{
accessorKey: 'task_id',
header: t('Task ID'),
cell: ({ row }) => {
const log = row.original
const taskId = row.getValue('task_id') as string
if (!taskId) {
return <span className='text-muted-foreground/60 text-xs'>-</span>
}
return (
<div className='flex max-w-[170px] flex-col gap-0.5'>
<StatusBadge
label={taskId}
copyText={taskId}
variant='neutral'
size='sm'
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: { mobileTitle: true },
},
createDurationColumn<TaskLog>({
submitTimeKey: 'submit_time',
finishTimeKey: 'finish_time',
unit: 'seconds',
headerLabel: t('Duration'),
warningThresholdSec: 300,
}),
{
accessorKey: 'status',
header: t('Status'),
cell: ({ row }) => {
const status = row.getValue('status') as string
return (
<StatusBadge
label={t(taskStatusMapper.getLabel(status, status || 'Submitting'))}
variant={taskStatusMapper.getVariant(status)}
size='sm'
copyable={false}
className='-ml-1.5'
/>
)
},
},
createProgressColumn<TaskLog>({ headerLabel: t('Progress') }),
{
accessorKey: 'fail_reason',
header: t('Details'),
cell: function DetailsCell({ row }) {
const log = row.original
const failReason = row.getValue('fail_reason') as string
const status = log.status
const [dialogOpen, setDialogOpen] = useState(false)
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
}
@@ -0,0 +1,447 @@
/*
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 { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import type { Table } from '@tanstack/react-table'
import { Eye, EyeOff } from 'lucide-react'
import { useState, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { CommonLogFilters } from '../types'
import { CommonLogsStats } from './common-logs-stats'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import {
LogsFilterField,
LogsFilterInput,
LogsFilterToolbar,
} from './logs-filter-toolbar'
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
type LogTypeValue = (typeof LOG_TYPE_FILTERS)[number]['value']
const logTypeValueSet = new Set<string>(
LOG_TYPE_FILTERS.map((type) => type.value)
)
type CommonLogDraft = {
sourceKey: string
filters: CommonLogFilters
logType: LogTypeValue
}
function isLogTypeValue(value: string): value is LogTypeValue {
return logTypeValueSet.has(value)
}
function getLogTypeValue(value: unknown): LogTypeValue {
return Array.isArray(value) &&
value.length === 1 &&
typeof value[0] === 'string' &&
isLogTypeValue(value[0])
? value[0]
: LOG_TYPE_ALL_VALUE
}
function buildSearchSourceKey(values: {
startTime?: unknown
endTime?: unknown
channel?: unknown
model?: unknown
token?: unknown
group?: unknown
username?: unknown
requestId?: unknown
upstreamRequestId?: unknown
type?: unknown
}) {
return [
values.startTime,
values.endTime,
values.channel,
values.model,
values.token,
values.group,
values.username,
values.requestId,
values.upstreamRequestId,
Array.isArray(values.type) ? values.type.join(',') : values.type,
]
.map((value) => String(value ?? ''))
.join('\u001f')
}
interface CommonLogsFilterBarProps<TData> {
table: Table<TData>
}
export function CommonLogsFilterBar<TData>(
props: CommonLogsFilterBarProps<TData>
) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const { isAdminView: isAdmin } = useLogsViewScope()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const searchState = useMemo<CommonLogDraft>(() => {
const { start, end } = getDefaultTimeRange()
const sourceValues = {
startTime: searchParams.startTime,
endTime: searchParams.endTime,
channel: searchParams.channel,
model: searchParams.model,
token: searchParams.token,
group: searchParams.group,
username: searchParams.username,
requestId: searchParams.requestId,
upstreamRequestId: searchParams.upstreamRequestId,
type: searchParams.type,
}
const filters: CommonLogFilters = {
startTime: searchParams.startTime
? new Date(searchParams.startTime)
: start,
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
channel: searchParams.channel || undefined,
model: searchParams.model || undefined,
token: searchParams.token || undefined,
group: searchParams.group || undefined,
username: searchParams.username || undefined,
requestId: searchParams.requestId || undefined,
upstreamRequestId: searchParams.upstreamRequestId || undefined,
}
return {
sourceKey: buildSearchSourceKey(sourceValues),
filters,
logType: getLogTypeValue(searchParams.type),
}
}, [
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.model,
searchParams.token,
searchParams.group,
searchParams.username,
searchParams.requestId,
searchParams.upstreamRequestId,
searchParams.type,
])
const [draft, setDraft] = useState<CommonLogDraft>(() => searchState)
const activeDraft =
draft.sourceKey === searchState.sourceKey ? draft : searchState
const filters = activeDraft.filters
const logType = activeDraft.logType
const handleChange = useCallback(
(field: keyof CommonLogFilters, value: Date | string | undefined) => {
setDraft((current) => {
const base =
current.sourceKey === searchState.sourceKey ? current : searchState
return {
sourceKey: searchState.sourceKey,
filters: { ...base.filters, [field]: value },
logType: base.logType,
}
})
},
[searchState]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, 'common')
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: {
...filterParams,
type: [logType],
page: 1,
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] })
}, [filters, logType, navigate, queryClient])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
const resetSearch = {
type: [LOG_TYPE_ALL_VALUE],
startTime: start.getTime(),
endTime: end.getTime(),
}
setDraft({
sourceKey: buildSearchSourceKey(resetSearch),
filters: resetFilters,
logType: LOG_TYPE_ALL_VALUE,
})
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: {
page: 1,
...resetSearch,
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
queryClient.invalidateQueries({ queryKey: ['usage-logs-stats'] })
}, [navigate, queryClient])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleApply()
},
[handleApply]
)
const hasExpandedFilters =
!!filters.token ||
!!filters.username ||
!!filters.channel ||
!!filters.requestId ||
!!filters.upstreamRequestId
const hasTypeFilter = logType !== LOG_TYPE_ALL_VALUE
const hasAdditionalFilters =
!!filters.model || !!filters.group || hasTypeFilter || hasExpandedFilters
const expandedFilterCount = [
filters.token,
isAdmin ? filters.username : undefined,
isAdmin ? filters.channel : undefined,
filters.requestId,
filters.upstreamRequestId,
].filter(Boolean).length
const sensitiveType = sensitiveVisible ? 'text' : 'password'
const logTypeItems = useMemo(
() =>
LOG_TYPE_FILTERS.map((type) => ({
value: type.value,
label: t(type.label),
})),
[t]
)
const logTypeLabel =
logTypeItems.find((type) => type.value === logType)?.label ?? t('All Types')
const statsBar = (
<div className='flex flex-wrap items-center gap-2'>
<CommonLogsStats />
</div>
)
const sensitiveToggle = (
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-7'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
)
const dateRangeFilter = (
<LogsFilterField wide>
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
/>
</LogsFilterField>
)
const modelFilter = (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Model Name')}
value={filters.model || ''}
onChange={(e) => handleChange('model', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
)
const groupFilter = (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Group')}
type={sensitiveType}
value={filters.group || ''}
onChange={(e) => handleChange('group', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
)
const typeFilter = (
<LogsFilterField>
<Select
items={logTypeItems}
value={logType}
onValueChange={(value) => {
const nextLogType =
value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE
setDraft((current) => {
const base =
current.sourceKey === searchState.sourceKey
? current
: searchState
return {
sourceKey: searchState.sourceKey,
filters: base.filters,
logType: nextLogType,
}
})
}}
>
<SelectTrigger>
<SelectValue>{logTypeLabel}</SelectValue>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{LOG_TYPE_FILTERS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{t(type.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</LogsFilterField>
)
const advancedFilters = (
<>
<LogsFilterField>
<LogsFilterInput
placeholder={t('Token Name')}
type={sensitiveType}
value={filters.token || ''}
onChange={(e) => handleChange('token', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
{isAdmin && (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Username')}
type={sensitiveType}
value={filters.username || ''}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
)}
{isAdmin && (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
)}
<LogsFilterField>
<LogsFilterInput
placeholder={t('Request ID')}
value={filters.requestId || ''}
onChange={(e) => handleChange('requestId', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
<LogsFilterField>
<LogsFilterInput
placeholder={t('Upstream Request ID')}
value={filters.upstreamRequestId || ''}
onChange={(e) => handleChange('upstreamRequestId', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
</>
)
return (
<LogsFilterToolbar
table={props.table}
stats={statsBar}
actionStart={sensitiveToggle}
primaryFilters={
<>
{dateRangeFilter}
{modelFilter}
{groupFilter}
{typeFilter}
</>
}
advancedFilters={advancedFilters}
mobilePinnedFilters={dateRangeFilter}
mobileFilters={
<>
{modelFilter}
{groupFilter}
{typeFilter}
{advancedFilters}
</>
}
mobileFilterCount={
[filters.model, filters.group, hasTypeFilter].filter(Boolean).length +
expandedFilterCount
}
hasAdvancedActiveFilters={hasExpandedFilters}
advancedFilterCount={expandedFilterCount}
hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply}
searchLoading={fetchingLogs > 0}
onReset={handleReset}
/>
)
}
@@ -0,0 +1,66 @@
/*
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 { Eye, EyeOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { CommonLogsStats } from './common-logs-stats'
import { useUsageLogsContext } from './usage-logs-provider'
/**
* Page-header actions for the Common Logs view: live usage stats plus a
* toggle for masking sensitive values (token names, usernames, group names,
* and the quota figure shown in stats). Both controls live in the page
* header so the toolbar below stays focused on filter inputs and form
* actions only.
*/
export function CommonLogsHeaderActions() {
const { t } = useTranslation()
const { sensitiveVisible, setSensitiveVisible } = useUsageLogsContext()
return (
<div className='flex flex-wrap items-center gap-2'>
<CommonLogsStats />
<Tooltip>
<TooltipTrigger
render={
<Button
variant='ghost'
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-7'
/>
}
>
{sensitiveVisible ? <Eye /> : <EyeOff />}
</TooltipTrigger>
<TooltipContent>
{sensitiveVisible ? t('Hide') : t('Show')}
</TooltipContent>
</Tooltip>
</div>
)
}
@@ -0,0 +1,107 @@
/*
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 { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import { formatLogQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getLogStats, getUserLogStats } from '../api'
import { DEFAULT_LOG_STATS } from '../constants'
import { buildApiParams } from '../lib/utils'
import { useLogsViewScope, useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
function StatBadge(props: {
label: string
value: string | number
accent: string
}) {
return (
<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>
</span>
)
}
export function CommonLogsStats() {
const { t } = useTranslation()
const { isAdminView: isAdmin } = useLogsViewScope()
const searchParams = route.useSearch()
const { sensitiveVisible } = useUsageLogsContext()
const { data: stats, isLoading } = useQuery({
queryKey: ['usage-logs-stats', isAdmin, searchParams],
queryFn: async () => {
const params = buildApiParams({
page: 1,
pageSize: 1,
searchParams,
columnFilters: [],
isAdmin,
})
const result = isAdmin
? await getLogStats(params)
: await getUserLogStats(params)
return result.success
? result.data || DEFAULT_LOG_STATS
: DEFAULT_LOG_STATS
},
placeholderData: (previousData) => previousData,
})
if (isLoading) {
return (
<div className='flex items-center gap-2'>
<Skeleton className='h-7 w-[150px] rounded-md' />
<Skeleton className='h-7 w-[100px] rounded-md' />
<Skeleton className='h-7 w-[120px] rounded-md' />
</div>
)
}
return (
<div className='flex flex-wrap items-center gap-2'>
<StatBadge
label={t('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'
/>
</div>
)
}
@@ -0,0 +1,227 @@
/*
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 { CalendarDays } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
interface CompactDateTimeRangePickerProps {
start?: Date
end?: Date
onChange: (range: { start?: Date; end?: Date }) => void
className?: string
}
function toInputValue(date?: Date): string {
return date ? dayjs(date).format('YYYY-MM-DDTHH:mm') : ''
}
function fromInputValue(value: string): Date | undefined {
if (!value) return undefined
const date = new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date
}
export function CompactDateTimeRangePicker({
start,
end,
onChange,
className,
}: CompactDateTimeRangePickerProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [draftStart, setDraftStart] = useState(toInputValue(start))
const [draftEnd, setDraftEnd] = useState(toInputValue(end))
const label = useMemo(() => {
if (!start && !end) return t('Date Range')
// The popover's <input type="datetime-local"> only supports minute
// precision, so seconds are always 00 (manual pick) or 59 (preset
// end-of-day). Hide them in the trigger label to keep the button
// width compact while still showing the meaningful timestamp.
const startText = start ? dayjs(start).format('YYYY-MM-DD HH:mm') : '-'
const endText = end ? dayjs(end).format('YYYY-MM-DD HH:mm') : '-'
return `${startText} ~ ${endText}`
}, [end, start, t])
const handleOpenChange = (nextOpen: boolean) => {
if (nextOpen) {
setDraftStart(toInputValue(start))
setDraftEnd(toInputValue(end))
}
setOpen(nextOpen)
}
const applyDraft = () => {
onChange({
start: fromInputValue(draftStart),
end: fromInputValue(draftEnd),
})
setOpen(false)
}
const applyPreset = (kind: 'today' | '7d' | 'week' | '30d' | 'month') => {
const now = dayjs()
const presets = {
today: {
start: now.startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
'7d': {
start: now.subtract(6, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
week: {
start: now.startOf('week').toDate(),
end: now.endOf('week').toDate(),
},
'30d': {
start: now.subtract(29, 'day').startOf('day').toDate(),
end: now.endOf('day').toDate(),
},
month: {
start: now.startOf('month').toDate(),
end: now.endOf('month').toDate(),
},
}
const range = presets[kind]
setDraftStart(toInputValue(range.start))
setDraftEnd(toInputValue(range.end))
onChange(range)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button
type='button'
variant='outline'
className={cn(
'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums',
!start && !end && 'text-muted-foreground',
className
)}
/>
}
>
<CalendarDays className='text-muted-foreground size-4 shrink-0' />
<span className='truncate'>{label}</span>
</PopoverTrigger>
<PopoverContent
align='start'
className='w-[min(520px,calc(100vw-2rem))] p-3'
>
<div className='space-y-3'>
<div className='grid gap-2 sm:grid-cols-[1fr_auto_1fr] sm:items-end'>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('Start Time')}
</div>
<Input
type='datetime-local'
value={draftStart}
onChange={(e) => setDraftStart(e.target.value)}
className='h-8 text-sm leading-5 tabular-nums'
/>
</div>
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
~
</span>
<div className='space-y-1.5'>
<div className='text-muted-foreground text-xs'>
{t('End Time')}
</div>
<Input
type='datetime-local'
value={draftEnd}
onChange={(e) => setDraftEnd(e.target.value)}
className='h-8 text-sm leading-5 tabular-nums'
/>
</div>
</div>
<div className='flex flex-wrap gap-1.5'>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('today')}
>
{t('Today')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('7d')}
>
{t('7 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('week')}
>
{t('This week')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('30d')}
>
{t('30 Days')}
</Button>
<Button
type='button'
variant='secondary'
size='sm'
className='h-7 flex-1 px-2 text-xs'
onClick={() => applyPreset('month')}
>
{t('This month')}
</Button>
</div>
<div className='flex justify-end'>
<Button size='sm' className='h-8' onClick={applyDraft}>
{t('Confirm')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,183 @@
/*
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 { ExternalLink, Copy, Music } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { IconBadge } from '@/components/ui/icon-badge'
import { ScrollArea } from '@/components/ui/scroll-area'
export interface AudioClip {
clip_id?: string
id?: string
title?: string
tags?: string
duration?: number
audio_url?: string
image_url?: string
image_large_url?: string
metadata?: {
tags?: string
duration?: number
}
}
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)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function AudioClipCard({ clip }: { clip: AudioClip }) {
const { t } = useTranslation()
const [hasError, setHasError] = useState(false)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setHasError(false)
}, [clip.audio_url])
const title = clip.title || t('Untitled')
const tags = clip.tags || clip.metadata?.tags || ''
const duration = clip.duration || clip.metadata?.duration
const imageUrl = clip.image_url || clip.image_large_url
const audioUrl = clip.audio_url
if (!audioUrl) return null
return (
<div className='bg-card flex gap-4 rounded-lg border p-4'>
{imageUrl && (
<img
src={imageUrl}
alt={title}
className='h-20 w-20 shrink-0 rounded-lg object-cover'
onError={(e) => {
;(e.target as HTMLElement).style.display = 'none'
}}
/>
)}
<div className='min-w-0 flex-1'>
<div className='mb-1 flex items-center gap-2'>
<span className='truncate text-sm font-medium'>{title}</span>
{duration != null && duration > 0 && (
<StatusBadge
label={formatDuration(duration)}
variant='neutral'
className='shrink-0'
copyable={false}
/>
)}
</div>
{tags && (
<p className='text-muted-foreground mb-2 truncate text-xs'>{tags}</p>
)}
{hasError ? (
<div className='flex flex-wrap items-center gap-2'>
<span className='text-destructive text-xs'>
{t('Audio playback failed')}
</span>
<Button
variant='outline'
size='sm'
className='h-7 gap-1 text-xs'
onClick={() => window.open(audioUrl, '_blank')}
>
<ExternalLink className='h-3 w-3' />
{t('Open in new tab')}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 gap-1 text-xs'
onClick={() => {
navigator.clipboard.writeText(audioUrl)
toast.success(t('Copied'))
}}
>
<Copy className='h-3 w-3' />
{t('Copy Link')}
</Button>
</div>
) : (
<audio
ref={audioRef}
src={audioUrl}
controls
preload='none'
onError={() => setHasError(true)}
className='h-9 w-full'
/>
)}
</div>
</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={
<>
<IconBadge tone='chart-4' size='sm'>
<Music />
</IconBadge>
{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>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
/*
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 { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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'
interface FailReasonDialogProps {
failReason: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function FailReasonDialog({
failReason,
open,
onOpenChange,
}: FailReasonDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Fail Reason Details')}
description={t('View the complete error message and details')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Error Message')}
</Label>
<div className='bg-muted/50 relative rounded-md border border-red-200 p-3'>
<Button
variant='ghost'
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='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='overflow-wrap-anywhere pr-10 text-sm leading-relaxed break-all whitespace-pre-wrap text-red-600'>
{failReason || '-'}
</p>
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -0,0 +1,114 @@
/*
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 { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Skeleton } from '@/components/ui/skeleton'
interface ImageDialogProps {
imageUrl: string
taskId?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function ImageDialog({
imageUrl,
taskId,
open,
onOpenChange,
}: ImageDialogProps) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
// Reset loading state when dialog opens or image URL changes
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setIsLoading(true)
setHasError(false)
}
onOpenChange(newOpen)
}
const handleImageLoad = () => {
setIsLoading(false)
setHasError(false)
}
const handleImageError = () => {
setIsLoading(false)
setHasError(true)
}
return (
<Dialog
open={open}
onOpenChange={handleOpenChange}
title={t('Image Preview')}
description={
taskId ? `${t('Task ID:')} ${taskId}` : t('View the generated image')
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[600px]'>
<div className='py-4'>
<div className='bg-muted/50 relative flex min-h-[300px] items-center justify-center rounded-lg border'>
{/* Skeleton - show when loading or error */}
{(isLoading || hasError) && (
<Skeleton className='absolute inset-0 h-full w-full rounded-lg' />
)}
{/* Actual Image */}
<img
src={imageUrl}
alt={t('Generated image')}
className={`max-h-[550px] w-full rounded-lg object-contain ${
isLoading || hasError ? 'opacity-0' : 'opacity-100'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
loading='lazy'
/>
{/* Error text overlay (shown on skeleton) */}
{hasError && (
<div className='absolute inset-0 flex items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Failed to load image')}
</p>
</div>
)}
</div>
{/* Image URL */}
<div className='bg-muted mt-4 rounded-md p-3'>
<p className='text-muted-foreground font-mono text-xs break-all'>
{imageUrl}
</p>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -0,0 +1,109 @@
/*
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 { Copy, Check } from 'lucide-react'
import { useTranslation } from 'react-i18next'
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'
interface PromptDialogProps {
prompt: string
promptEn?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function PromptDialog({
prompt,
promptEn,
open,
onOpenChange,
}: PromptDialogProps) {
const { t } = useTranslation()
const { copiedText, copyToClipboard } = useCopyToClipboard({ notify: false })
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Prompt Details')}
description={t('View the complete prompt and its English translation')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
<ScrollArea className='max-h-[500px] pr-4'>
<div className='space-y-4 py-4'>
{/* Original Prompt */}
<div className='space-y-2'>
<Label className='text-sm font-semibold'>{t('Prompt')}</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
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='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{prompt || '-'}
</p>
</div>
</div>
{/* English Prompt */}
{promptEn && (
<div className='space-y-2'>
<Label className='text-sm font-semibold'>
{t('Prompt (EN)')}
</Label>
<div className='bg-muted/50 relative rounded-md border p-3'>
<Button
variant='ghost'
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='size-4 text-green-600' />
) : (
<Copy className='size-4' />
)}
</Button>
<p className='pr-10 text-sm leading-relaxed break-words whitespace-pre-wrap'>
{promptEn}
</p>
</div>
</div>
)}
</div>
</ScrollArea>
</Dialog>
)
}
@@ -0,0 +1,186 @@
/*
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 { Loader2 } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Label } from '@/components/ui/label'
import { formatQuota, formatCompactNumber } from '@/lib/format'
import { getUserInfo } from '../../api'
import type { UserInfo } from '../../types'
interface UserInfoDialogProps {
userId: number | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function UserInfoDialog({
userId,
open,
onOpenChange,
}: UserInfoDialogProps) {
const { t } = useTranslation()
const [userInfo, setUserInfo] = useState<UserInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const fetchUserInfo = useCallback(
async (id: number) => {
setIsLoading(true)
try {
const result = await getUserInfo(id)
if (result.success) {
setUserInfo(result.data || null)
} else {
toast.error(result.message || t('Failed to fetch user information'))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user info:', error)
toast.error(t('Failed to fetch user information'))
} finally {
setIsLoading(false)
}
},
[t]
)
useEffect(() => {
if (open && userId) {
fetchUserInfo(userId)
}
}, [open, userId, fetchUserInfo])
const InfoItem = ({
label,
value,
}: {
label: string
value: string | number
}) => (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>{label}</Label>
<div className='text-sm font-semibold'>{value}</div>
</div>
)
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('User Information')}
description={t(
'View detailed information about this user including balance, usage statistics, and invitation details.'
)}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
>
{isLoading ? (
<div className='flex items-center justify-center py-8'>
<Loader2 className='text-muted-foreground size-6 animate-spin' />
</div>
) : userInfo ? (
<div className='space-y-4 py-4'>
{/* Basic Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem label={t('Username')} value={userInfo.username} />
{userInfo.display_name && (
<InfoItem
label={t('Display Name')}
value={userInfo.display_name}
/>
)}
</div>
{/* Balance Info */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Balance')}
value={formatQuota(userInfo.quota)}
/>
<InfoItem
label={t('Used Quota')}
value={formatQuota(userInfo.used_quota)}
/>
</div>
{/* Statistics */}
<div className='grid grid-cols-2 gap-4'>
<InfoItem
label={t('Request Count')}
value={formatCompactNumber(userInfo.request_count)}
/>
{userInfo.group && (
<InfoItem label={t('User Group')} value={userInfo.group} />
)}
</div>
{/* Invitation Info */}
{(userInfo.aff_code ||
userInfo.aff_count !== undefined ||
(userInfo.aff_quota !== undefined && userInfo.aff_quota > 0)) && (
<>
<div className='grid grid-cols-2 gap-4'>
{userInfo.aff_code && (
<InfoItem
label={t('Invitation Code')}
value={userInfo.aff_code}
/>
)}
{userInfo.aff_count !== undefined && (
<InfoItem
label={t('Invited Users')}
value={formatCompactNumber(userInfo.aff_count)}
/>
)}
</div>
{userInfo.aff_quota !== undefined && userInfo.aff_quota > 0 && (
<InfoItem
label={t('Invitation Quota')}
value={formatQuota(userInfo.aff_quota)}
/>
)}
</>
)}
{/* Remark */}
{userInfo.remark && (
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>
{t('Remark')}
</Label>
<div className='text-sm leading-relaxed font-semibold break-words'>
{userInfo.remark}
</div>
</div>
)}
</div>
) : (
<div className='text-muted-foreground py-8 text-center text-sm'>
{t('No user information available')}
</div>
)}
</Dialog>
)
}
@@ -0,0 +1,294 @@
/*
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 type { Table } from '@tanstack/react-table'
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 {
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>
primaryFilters: ReactNode
advancedFilters?: ReactNode
mobilePinnedFilters?: ReactNode
mobileFilters?: ReactNode
mobileFilterCount?: number
stats?: ReactNode
actionStart?: ReactNode
hasActiveFilters: boolean
hasAdvancedActiveFilters?: boolean
advancedFilterCount?: number
searchLoading?: boolean
onReset: () => void
onSearch: () => void
className?: string
}
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>) {
const { t } = useTranslation()
const [advancedOpen, setAdvancedOpen] = useState(false)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
const [mobilePanelCollapsed, setMobilePanelCollapsed] = 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)}
>
{!mobilePanelCollapsed && (
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
)}
<div
className={cn(
'flex flex-col gap-2',
!mobilePanelCollapsed && 'mt-2'
)}
>
{!mobilePanelCollapsed && props.stats}
<div className='flex items-center justify-end gap-1.5'>
<Button
type='button'
variant='ghost'
size='icon'
onClick={() =>
setMobilePanelCollapsed((collapsed) => !collapsed)
}
aria-expanded={!mobilePanelCollapsed}
aria-label={mobilePanelCollapsed ? t('Expand') : t('Collapse')}
className='text-muted-foreground hover:text-foreground mr-auto size-7'
>
<ChevronDown
className={cn(
'size-3.5 transition-transform duration-200',
!mobilePanelCollapsed && 'rotate-180'
)}
/>
</Button>
{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>
)
}
+195
View File
@@ -0,0 +1,195 @@
/*
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 { Route } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { StatusBadge } from '@/components/status-badge'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn } from '@/lib/utils'
interface ModelBadgeProps {
modelName: string
actualModel?: string
className?: string
}
interface ModelProvider {
icon: string
label: string
}
function resolveModelProvider(modelName: string): ModelProvider | null {
const model = modelName.toLowerCase()
const hasAny = (keywords: string[]) =>
keywords.some((keyword) => model.includes(keyword))
if (
hasAny([
'gpt-',
'chatgpt-',
'text-embedding-',
'omni-moderation',
'dall-e',
'whisper',
'tts-',
]) ||
/\bo[134](?:-|$)/.test(model)
) {
return { icon: 'OpenAI.Color', label: 'OpenAI' }
}
if (hasAny(['claude-', 'anthropic'])) {
return { icon: 'Claude.Color', label: 'Claude' }
}
if (hasAny(['gemini-', 'learnlm-'])) {
return { icon: 'Gemini.Color', label: 'Gemini' }
}
if (hasAny(['grok-', 'xai-'])) {
return { icon: 'Grok.Color', label: 'Grok' }
}
if (hasAny(['deepseek-'])) {
return { icon: 'DeepSeek.Color', label: 'DeepSeek' }
}
if (hasAny(['qwen', 'qwq-'])) {
return { icon: 'Qwen.Color', label: 'Qwen' }
}
if (hasAny(['doubao-', 'volcengine'])) {
return { icon: 'Doubao.Color', label: 'Doubao' }
}
if (hasAny(['moonshot-', 'kimi-'])) {
return { icon: 'Moonshot.Color', label: 'Moonshot' }
}
if (hasAny(['minimax', 'abab'])) {
return { icon: 'Minimax.Color', label: 'MiniMax' }
}
if (hasAny(['glm-', 'chatglm', 'cogview', 'cogvideo'])) {
return { icon: 'Zhipu.Color', label: 'Zhipu' }
}
if (hasAny(['mimo-'])) {
return { icon: 'XiaomiMiMo', label: 'MiMo' }
}
if (hasAny(['ernie'])) {
return { icon: 'Wenxin.Color', label: 'Baidu' }
}
if (hasAny(['spark'])) {
return { icon: 'Spark.Color', label: 'iFlyTek' }
}
if (hasAny(['hunyuan'])) {
return { icon: 'Hunyuan.Color', label: 'Tencent' }
}
if (hasAny(['baichuan'])) {
return { icon: 'Baichuan.Color', label: 'Baichuan' }
}
if (hasAny(['internlm'])) {
return { icon: 'InternLM.Color', label: 'InternLM' }
}
if (hasAny(['step-'])) {
return { icon: 'Stepfun.Color', label: 'StepFun' }
}
if (hasAny(['yi-'])) {
return { icon: 'Yi.Color', label: 'Yi' }
}
if (hasAny(['mistral-', 'mixtral-'])) {
return { icon: 'Mistral.Color', label: 'Mistral' }
}
if (hasAny(['llama-', 'meta-'])) {
return { icon: 'Meta.Color', label: 'Meta' }
}
if (hasAny(['command-', 'cohere-'])) {
return { icon: 'Cohere.Color', label: 'Cohere' }
}
return null
}
function ModelBadgeContent(props: ModelBadgeProps) {
const provider = resolveModelProvider(props.modelName)
return (
<StatusBadge
copyText={props.modelName}
size='sm'
showDot={!provider}
autoColor={provider ? undefined : props.modelName}
className={cn(
'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
)}
>
<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>
)
}
export function ModelBadge(props: ModelBadgeProps) {
const { t } = useTranslation()
if (!props.actualModel) {
return <ModelBadgeContent {...props} />
}
return (
<Popover>
<PopoverTrigger
render={
<button type='button' className='inline-flex items-center gap-1' />
}
>
<ModelBadgeContent {...props} />
<Route className='text-muted-foreground size-3 shrink-0' />
</PopoverTrigger>
<PopoverContent className='w-72'>
<div className='space-y-2'>
<div className='flex items-start justify-between gap-3'>
<span className='text-muted-foreground text-xs'>
{t('Request Model:')}
</span>
<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>
<span className='truncate font-mono text-xs font-medium'>
{props.actualModel}
</span>
</div>
</div>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,226 @@
/*
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 { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { buildSearchParams } from '../lib/filter'
import { getDefaultTimeRange } from '../lib/utils'
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
import {
LogsFilterField,
LogsFilterInput,
LogsFilterToolbar,
} from './logs-filter-toolbar'
import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
type TaskLikeLogCategory = Extract<LogCategory, 'drawing' | 'task'>
type TaskLogsFilters = DrawingLogFilters | TaskLogFilters
interface TaskLogsFilterBarProps<TData> {
table: Table<TData>
logCategory: TaskLikeLogCategory
}
function getFilterValue(
filters: TaskLogsFilters,
logCategory: TaskLikeLogCategory
): string {
if (logCategory === 'drawing') {
return (filters as DrawingLogFilters).mjId || ''
}
return (filters as TaskLogFilters).taskId || ''
}
function setFilterValue(
filters: TaskLogsFilters,
logCategory: TaskLikeLogCategory,
value: string
): TaskLogsFilters {
if (logCategory === 'drawing') {
return { ...filters, mjId: value }
}
return { ...filters, taskId: value }
}
export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const searchParams = route.useSearch()
const { isAdminView: isAdmin } = useLogsViewScope()
const fetchingLogs = useIsFetching({ queryKey: ['logs'] })
const [filters, setFilters] = useState<TaskLogsFilters>(() => {
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
useEffect(() => {
const { start, end } = getDefaultTimeRange()
const baseFilters = {
startTime: searchParams.startTime
? new Date(searchParams.startTime)
: start,
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
...(searchParams.channel
? { channel: String(searchParams.channel) }
: {}),
}
const next: TaskLogsFilters =
props.logCategory === 'drawing'
? {
...baseFilters,
...(searchParams.filter ? { mjId: searchParams.filter } : {}),
}
: {
...baseFilters,
...(searchParams.filter ? { taskId: searchParams.filter } : {}),
}
setFilters(next)
}, [
props.logCategory,
searchParams.startTime,
searchParams.endTime,
searchParams.channel,
searchParams.filter,
])
const handleChange = useCallback(
(field: keyof TaskLogsFilters, value: Date | string | undefined) => {
setFilters((prev) => ({ ...prev, [field]: value }))
},
[]
)
const handleApply = useCallback(() => {
const filterParams = buildSearchParams(filters, props.logCategory)
navigate({
to: '/usage-logs/$section',
params: { section: props.logCategory },
search: {
...filterParams,
page: 1,
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
}, [filters, navigate, props.logCategory, queryClient])
const handleReset = useCallback(() => {
const { start, end } = getDefaultTimeRange()
const resetFilters: TaskLogsFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
navigate({
to: '/usage-logs/$section',
params: { section: props.logCategory },
search: {
page: 1,
startTime: start.getTime(),
endTime: end.getTime(),
},
})
queryClient.invalidateQueries({ queryKey: ['logs'] })
}, [navigate, props.logCategory, queryClient])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleApply()
},
[handleApply]
)
const handleFilterChange = useCallback(
(value: string) => {
setFilters((prev) => setFilterValue(prev, props.logCategory, value))
},
[props.logCategory]
)
const filterValue = getFilterValue(filters, props.logCategory)
const placeholder =
props.logCategory === 'drawing'
? t('Filter by MjProxy task ID')
: t('Filter by task ID')
const hasAdditionalFilters = !!filterValue || !!filters.channel
const dateRangeFilter = (
<LogsFilterField wide>
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
/>
</LogsFilterField>
)
const taskIdFilter = (
<LogsFilterField>
<LogsFilterInput
aria-label={t('Task ID')}
placeholder={placeholder}
value={filterValue}
onChange={(e) => handleFilterChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
)
const channelFilter = isAdmin ? (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
/>
</LogsFilterField>
) : null
return (
<LogsFilterToolbar
table={props.table}
primaryFilters={
<>
{dateRangeFilter}
{taskIdFilter}
{channelFilter}
</>
}
mobilePinnedFilters={dateRangeFilter}
mobileFilters={
<>
{taskIdFilter}
{channelFilter}
</>
}
mobileFilterCount={[filterValue, filters.channel].filter(Boolean).length}
hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply}
searchLoading={fetchingLogs > 0}
onReset={handleReset}
/>
)
}
@@ -0,0 +1,211 @@
/*
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 { CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
dotColorMap,
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { formatUseTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getFirstResponseTimeColor, getResponseTimeColor } from '../lib/format'
import type { LogOtherData } from '../types'
/**
* Softened fills for the full-height timing bar. The bar sits directly beside
* dense numeric text, so the saturated `dotColorMap` tones (tuned for small
* dots and badges) read as too high-contrast at that size; a translucent fill
* keeps the status legible while matching the page's muted palette.
*/
const barColorMap: Record<StatusVariant, string> = {
...dotColorMap,
success: 'bg-success/90',
warning: 'bg-warning/80',
danger: 'bg-destructive/80',
neutral: 'bg-neutral/80',
}
interface TimingMetricsCellProps {
useTimeSec: number
completionTokens: number
frtMs?: number
isStream: boolean
className?: string
/**
* `bar` (default) draws a full-height color segment beside the labels,
* matching the dense desktop table. `dot` swaps that segment for small
* status dots inline with each label, matching the lighter-weight status
* indicator used elsewhere on the mobile card.
*/
indicator?: 'bar' | 'dot'
}
export function TimingMetricsCell(props: TimingMetricsCellProps) {
const { t } = useTranslation()
const indicator = props.indicator ?? 'bar'
const showFirstToken = props.isStream
const firstTokenSeconds =
props.frtMs != null && props.frtMs > 0 ? props.frtMs / 1000 : null
const firstTokenVariant: StatusVariant =
firstTokenSeconds == null
? 'neutral'
: getFirstResponseTimeColor(firstTokenSeconds)
const totalTimeVariant = getResponseTimeColor(
props.useTimeSec,
props.completionTokens
)
const firstTokenLabel =
firstTokenSeconds == null ? t('N/A') : formatUseTime(firstTokenSeconds)
const totalTimeLabel = formatUseTime(props.useTimeSec)
const labels = (
<div className='flex min-h-8 min-w-0 flex-col justify-center gap-0.5 text-xs leading-tight'>
{showFirstToken && (
<div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && (
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[firstTokenVariant]
)}
/>
)}
<span className='text-muted-foreground shrink-0'>
{t('First token')}
</span>
<span className={cn('tabular-nums', textColorMap[firstTokenVariant])}>
{firstTokenLabel}
</span>
</div>
)}
<div className='flex items-baseline gap-1.5'>
{indicator === 'dot' && (
<span
aria-hidden
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[totalTimeVariant]
)}
/>
)}
<span className='text-muted-foreground shrink-0'>{t('Duration')}</span>
<span className={cn('tabular-nums', textColorMap[totalTimeVariant])}>
{totalTimeLabel}
</span>
</div>
</div>
)
if (indicator === 'dot') {
return (
<div className={cn('flex items-stretch', props.className)}>{labels}</div>
)
}
return (
<div className={cn('flex items-stretch gap-2', props.className)}>
<span
aria-hidden
className={cn(
'flex w-1 shrink-0 flex-col overflow-hidden rounded-full',
!showFirstToken && barColorMap[totalTimeVariant]
)}
>
{showFirstToken && (
<>
<span className={cn('flex-1', barColorMap[firstTokenVariant])} />
<span className={cn('flex-1', barColorMap[totalTimeVariant])} />
</>
)}
</span>
{labels}
</div>
)
}
interface StreamTpsCellProps {
isStream: boolean
tokensPerSecond?: number | null
streamStatus?: LogOtherData['stream_status']
className?: string
}
export function StreamTpsCell(props: StreamTpsCellProps) {
const { t } = useTranslation()
const showStreamError =
props.isStream && props.streamStatus && props.streamStatus.status !== 'ok'
const tpsLabel =
props.tokensPerSecond != null
? `${Math.round(props.tokensPerSecond)} t/s`
: '—'
const streamLabel = props.isStream ? t('Stream') : t('Non-stream')
return (
<div
className={cn(
'flex shrink-0 flex-col items-start justify-center gap-0.5 text-xs leading-tight',
props.className
)}
>
<span
className={cn(
'inline-flex items-center gap-1 font-medium',
props.isStream ? 'text-info' : 'text-muted-foreground'
)}
>
{streamLabel}
{showStreamError && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={<CircleAlert className='text-destructive size-3' />}
/>
<TooltipContent>
<div className='space-y-0.5 text-xs'>
<p>
{t('Stream Status')}: {t('Error')}
</p>
<p>{props.streamStatus?.end_reason || 'unknown'}</p>
{(props.streamStatus?.error_count ?? 0) > 0 && (
<p>
{t('Soft Errors')}: {props.streamStatus?.error_count}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</span>
<span className='text-muted-foreground/60 px-0.5 tabular-nums'>
{tpsLabel}
</span>
</div>
)
}
@@ -0,0 +1,514 @@
/*
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 { flexRender, type Cell, type Table } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import {
dotColorMap,
textColorMap,
type StatusVariant,
} from '@/components/status-badge'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { getUserAvatarFallback, getUserAvatarStyle } from '@/lib/avatar'
import { formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { LOG_TYPE_ENUM } from '../constants'
import type { UsageLog } from '../data/schema'
import { parseLogOther } from '../lib/format'
import {
getLogTypeConfig,
isDisplayableLogType,
isTimingLogType,
} from '../lib/utils'
import type { LogCategory } from '../types'
import { StreamTpsCell, TimingMetricsCell } from './timing-metrics-cell'
import { useUsageLogsContext } from './usage-logs-provider'
const logTypeRowTint: Record<number, string> = {
[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> {
table: Table<TData>
isLoading?: boolean
emptyTitle?: string
emptyDescription?: string
logCategory: LogCategory
}
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>
)
}
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={cn('bg-muted/20 min-w-0 rounded-md px-2 py-1.5', className)}
>
{label != null && label !== '' && (
<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>
)
}
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
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>
)
}
/** Mobile-only Tokens block: always show cache ↓/↑ when present (no label). */
function MobileTokensField({ log }: { log: UsageLog }) {
const { t } = useTranslation()
if (!isDisplayableLogType(log.type)) return null
const promptTokens = log.prompt_tokens || 0
const completionTokens = log.completion_tokens || 0
if (promptTokens === 0 && completionTokens === 0) {
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<span className='text-muted-foreground text-xs'>-</span>
</div>
)
}
const other = parseLogOther(log.other)
const cacheReadTokens = other?.cache_tokens || 0
const cacheWrite5m = other?.cache_creation_tokens_5m || 0
const cacheWrite1h = other?.cache_creation_tokens_1h || 0
const hasSplitCache = cacheWrite5m > 0 || cacheWrite1h > 0
const cacheWriteTokens = hasSplitCache
? cacheWrite5m + cacheWrite1h
: other?.cache_creation_tokens || 0
const showCache = cacheReadTokens > 0 || cacheWriteTokens > 0
return (
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono text-xs font-medium tabular-nums'>
{promptTokens.toLocaleString()} / {completionTokens.toLocaleString()}
</span>
{showCache ? (
<div className='text-muted-foreground flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] leading-none'>
{cacheReadTokens > 0 && (
<span>
{t('Cache')} {cacheReadTokens.toLocaleString()}
</span>
)}
{cacheWriteTokens > 0 && (
<span> {cacheWriteTokens.toLocaleString()}</span>
)}
</div>
) : (
<span className='text-muted-foreground/50 text-[11px] leading-none'>
</span>
)}
</div>
</div>
)
}
/** Mobile-only User block: own layout so avatar/name always line up on the same baseline. */
function MobileUserField({ log }: { log: UsageLog }) {
const { sensitiveVisible, setSelectedUserId, setUserInfoDialogOpen } =
useUsageLogsContext()
if (!log.username) return null
return (
<button
type='button'
className='bg-muted/20 flex min-w-0 items-center gap-1.5 rounded-md px-2 py-1.5 text-left'
onClick={(e) => {
e.stopPropagation()
setSelectedUserId(log.user_id)
setUserInfoDialogOpen(true)
}}
>
<Avatar className='ring-border/60 size-6 shrink-0 ring-1'>
<AvatarFallback
className={cn(
'text-[11px] font-semibold',
!sensitiveVisible && 'bg-muted text-muted-foreground'
)}
style={
sensitiveVisible ? getUserAvatarStyle(log.username) : undefined
}
>
{sensitiveVisible ? getUserAvatarFallback(log.username) : '•'}
</AvatarFallback>
</Avatar>
<span className='text-foreground min-w-0 truncate text-sm'>
{sensitiveVisible ? log.username : '••••'}
</span>
</button>
)
}
/** Merge stream badge + TPS with first-token / duration on one row. */
function MobileStreamTimingField({ log }: { log: UsageLog }) {
if (!isTimingLogType(log.type)) return null
const other = parseLogOther(log.other)
const useTime = log.use_time || 0
const tokensPerSecond =
useTime > 0 && log.completion_tokens > 0
? log.completion_tokens / useTime
: null
return (
<div className='bg-muted/20 flex min-w-0 items-center gap-2.5 rounded-md px-2 py-1.5'>
<TimingMetricsCell
useTimeSec={useTime}
completionTokens={log.completion_tokens}
frtMs={other?.frt}
isStream={log.is_stream}
indicator='dot'
className='min-w-0 flex-1'
/>
<StreamTpsCell
isStream={log.is_stream}
tokensPerSecond={tokensPerSecond}
streamStatus={other?.stream_status}
className='shrink-0'
/>
</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 UsageLog | 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,1fr)_minmax(0,0.8fr)] gap-1.5'>
<div className='bg-muted/20 min-w-0 rounded-md px-2 py-1.5'>
<MobileLogTimeStatus
createdAt={rowData?.created_at}
type={rowData?.type}
/>
</div>
<SummaryField
cell={cells.get('channel')}
valueClassName='[&_.flex-col]:max-w-none'
/>
{rowData && cells.has('user') ? (
<MobileUserField log={rowData} />
) : (
<SummaryField cell={cells.get('user')} />
)}
<SummaryField
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'
/>
{rowData ? (
<MobileStreamTimingField log={rowData} />
) : (
<SummaryField cell={cells.get('use_time')} />
)}
{rowData ? (
<MobileTokensField log={rowData} />
) : (
<SummaryField cell={cells.get('prompt_tokens')} />
)}
<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>
)
}
export function UsageLogsMobileList<TData>({
table,
isLoading = false,
emptyTitle,
emptyDescription,
logCategory,
}: UsageLogsMobileListProps<TData>) {
const { t } = useTranslation()
const resolvedEmptyTitle = emptyTitle ?? t('No Logs Found')
const resolvedEmptyDescription =
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 (
<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 (
<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>
)
}
@@ -0,0 +1,104 @@
/*
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
*/
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, type ReactNode } from 'react'
import { useIsAdmin } from '@/hooks/use-admin'
import type { ChannelAffinityInfo } from '../types'
export type LogsViewScope = 'all' | 'self'
interface UsageLogsContextValue {
selectedUserId: number | null
setSelectedUserId: (userId: number | null) => void
userInfoDialogOpen: boolean
setUserInfoDialogOpen: (open: boolean) => void
affinityTarget: ChannelAffinityInfo | null
setAffinityTarget: (target: ChannelAffinityInfo | null) => void
affinityDialogOpen: boolean
setAffinityDialogOpen: (open: boolean) => void
sensitiveVisible: boolean
setSensitiveVisible: (visible: boolean) => void
viewScope: LogsViewScope
setViewScope: (scope: LogsViewScope) => void
}
const UsageLogsContext = createContext<UsageLogsContextValue | undefined>(
undefined
)
export function UsageLogsProvider({ children }: { children: ReactNode }) {
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
const [userInfoDialogOpen, setUserInfoDialogOpen] = useState(false)
const [affinityTarget, setAffinityTarget] =
useState<ChannelAffinityInfo | null>(null)
const [affinityDialogOpen, setAffinityDialogOpen] = useState(false)
const [sensitiveVisible, setSensitiveVisible] = useState(true)
const [viewScope, setViewScope] = useState<LogsViewScope>('all')
return (
<UsageLogsContext.Provider
value={{
selectedUserId,
setSelectedUserId,
userInfoDialogOpen,
setUserInfoDialogOpen,
affinityTarget,
setAffinityTarget,
affinityDialogOpen,
setAffinityDialogOpen,
sensitiveVisible,
setSensitiveVisible,
viewScope,
setViewScope,
}}
>
{children}
</UsageLogsContext.Provider>
)
}
export function useUsageLogsContext() {
const context = useContext(UsageLogsContext)
if (!context) {
throw new Error('useUsageLogsContext must be used within UsageLogsProvider')
}
return context
}
/**
* Resolves the effective admin scope for usage logs: whether the current
* user is allowed to view all users' logs (`canManageScope`), and whether
* their current view preference (`viewScope`) has that scope active
* (`isAdminView`). Data fetching and admin-only UI should key off
* `isAdminView` rather than raw role, so an admin who switches to "only
* mine" is treated exactly like a regular user for that view.
*/
export function useLogsViewScope() {
const canManageScope = useIsAdmin()
const { viewScope, setViewScope } = useUsageLogsContext()
return {
canManageScope,
viewScope,
setViewScope,
isAdminView: canManageScope && viewScope === 'all',
}
}
@@ -0,0 +1,234 @@
/*
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 { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { type ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
DataTablePage,
DataTableRow,
useDataTable,
} from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { cn } from '@/lib/utils'
import {
DEFAULT_LOGS_DATA,
LOG_TYPE_ALL_VALUE,
LOG_TYPE_ENUM,
} from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { parseLogOther } from '../lib/format'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
import { CommonLogsFilterBar } from './common-logs-filter-bar'
import { TaskLogsFilterBar } from './task-logs-filter-bar'
import { UsageLogsMobileList } from './usage-logs-mobile-card'
import { useLogsViewScope } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.ERROR]: 'bg-rose-50/40 dark:bg-rose-950/20',
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
}
// 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-amber-50/60 dark:bg-amber-950/25'
function getColumnVisibilityStorageKey(
logCategory: LogCategory,
isAdmin: boolean
): string {
return `usage-logs:${logCategory}:${isAdmin ? 'admin' : 'user'}:column-visibility`
}
function deserializeLogTypeFilter(value: unknown): unknown[] {
const values = Array.isArray(value) ? value : value ? [value] : []
return values.filter((item) => String(item) !== LOG_TYPE_ALL_VALUE)
}
interface UsageLogsTableProps {
logCategory: LogCategory
}
export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
const { t } = useTranslation()
const { isAdminView: isAdmin } = useLogsViewScope()
const isMobile = useMediaQuery('(max-width: 640px)')
const searchParams = route.useSearch()
const {
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
globalFilter: { enabled: false },
columnFilters: [
{
columnId: 'created_at',
searchKey: 'type',
type: 'array' as const,
deserialize: deserializeLogTypeFilter,
},
{ columnId: 'model_name', searchKey: 'model', type: 'string' as const },
{ columnId: 'token_name', searchKey: 'token', type: 'string' as const },
{ columnId: 'group', searchKey: 'group', type: 'string' as const },
...(isAdmin
? [
{
columnId: 'channel',
searchKey: 'channel',
type: 'string' as const,
},
{
columnId: 'username',
searchKey: 'username',
type: 'string' as const,
},
]
: []),
],
})
const { data, isLoading, isFetching } = useQuery({
queryKey: [
'logs',
logCategory,
isAdmin,
pagination.pageIndex + 1,
pagination.pageSize,
columnFilters,
searchParams,
t,
],
queryFn: async () => {
const result = await fetchLogsByCategory({
logCategory,
isAdmin,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
searchParams,
columnFilters,
})
if (!result?.success) {
toast.error(result?.message || t('Failed to load logs'))
return DEFAULT_LOGS_DATA
}
return result.data || DEFAULT_LOGS_DATA
},
placeholderData: (previousData, previousQuery) => {
if (previousQuery?.queryKey[1] === logCategory) {
return previousData
}
return undefined
},
})
const logs = data?.items || []
const columns = useColumnsByCategory(logCategory, isAdmin)
const isLoadingData = isLoading || (isFetching && !data)
const { table } = useDataTable({
data: logs as Record<string, unknown>[],
columns: columns as ColumnDef<Record<string, unknown>>[],
columnFilters,
columnVisibilityStorageKey: getColumnVisibilityStorageKey(
logCategory,
isAdmin
),
pagination,
enableRowSelection: false,
onPaginationChange,
onColumnFiltersChange,
manualPagination: true,
manualFiltering: true,
totalCount: data?.total || 0,
ensurePageInRange,
})
const isCommon = logCategory === 'common'
return (
<DataTablePage
table={table}
columns={columns as ColumnDef<Record<string, unknown>>[]}
isLoading={isLoadingData}
isFetching={isFetching}
emptyTitle={t('No Logs Found')}
emptyDescription={t(
'No usage logs available. Logs will appear here once API calls are made.'
)}
skeletonKeyPrefix='usage-log-skeleton'
applyHeaderSize
tableClassName={cn(
'[&_[data-slot=table]]:text-[13px] [&_[data-slot=table]_td]:text-[13px] [&_[data-slot=table]_td_*]:text-[13px] [&_[data-slot=table]_th]:text-[13px] [&_[data-slot=table]_th_*]:text-[13px]'
)}
mobile={
<UsageLogsMobileList
table={table}
isLoading={isLoadingData}
logCategory={logCategory}
/>
}
toolbar={
isCommon ? (
<CommonLogsFilterBar table={table} />
) : (
<TaskLogsFilterBar table={table} logCategory={logCategory} />
)
}
renderRow={(row) => {
const logType = (row.original as Record<string, unknown>).type as
| number
| undefined
let tintClass =
isCommon && logType != null ? (logTypeRowTint[logType] ?? '') : ''
if (isCommon && isAdmin) {
const other = parseLogOther(
((row.original as Record<string, unknown>).other as string) ?? ''
)
if (other?.admin_info?.quota_saturation) {
tintClass = quotaSaturationRowTint
}
}
return (
<DataTableRow
key={row.id}
row={row}
className={cn('transition-colors', tintClass)}
getColumnClassName={() => (isCommon ? 'py-2' : 'py-3.5')}
/>
)
}}
/>
)
}
+354
View File
@@ -0,0 +1,354 @@
/*
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
*/
/**
* Shared constants for usage logs feature
*/
import type { StatusBadgeProps } from '@/components/status-badge'
import type { LogStatistics, LogCategory } from './types'
// ============================================================================
// Default Values
// ============================================================================
/**
* Default log statistics when no data is available
*/
export const DEFAULT_LOG_STATS: LogStatistics = {
quota: 0,
rpm: 0,
tpm: 0,
}
/**
* Default empty logs data
*/
export const DEFAULT_LOGS_DATA = {
items: [],
total: 0,
}
// ============================================================================
// Log Type Enum
// ============================================================================
/**
* Log type enum values
*/
export const LOG_TYPE_ENUM = {
UNKNOWN: 0,
TOPUP: 1,
CONSUME: 2,
MANAGE: 3,
SYSTEM: 4,
ERROR: 5,
REFUND: 6,
LOGIN: 7,
} as const
/**
* The log list/stat backend uses type=0 as the "all types" sentinel.
* Row rendering still displays records with type=0 as "Unknown".
*/
export const LOG_TYPE_ALL_VALUE = '0' as const
// ============================================================================
// Time Range Presets
// ============================================================================
/**
* Quick time range presets for filter dialog
*/
export const TIME_RANGE_PRESETS = [
{ days: 1, label: '24 Hours' },
{ days: 7, label: '7 Days' },
{ days: 14, label: '14 Days' },
{ days: 30, label: '30 Days' },
] as const
// ============================================================================
// Common Logs Configuration
// ============================================================================
/**
* Log types configuration for filtering and display
*/
export const LOG_TYPES = [
{ value: 0, label: 'Unknown', color: 'default' },
{ value: 1, label: 'Top-up', color: 'cyan' },
{ value: 2, label: 'Consume', color: 'green' },
{ value: 3, label: 'Manage', color: 'orange' },
{ value: 4, label: 'System', color: 'purple' },
{ value: 5, label: 'Error', color: 'red' },
{ value: 6, label: 'Refund', color: 'blue' },
{ value: 7, label: 'Login', color: 'teal' },
] as const
/**
* Log types for DataTableToolbar filters (single select mode)
* Backend treats type=0 as "all logs" in list/stat endpoints, so the filter
* must not expose the display-only "Unknown" label for that value.
*/
export const LOG_TYPE_FILTERS = [
{ label: 'All Types', value: LOG_TYPE_ALL_VALUE },
...LOG_TYPES.filter((type) => type.value !== LOG_TYPE_ENUM.UNKNOWN).map(
(type) => ({
label: type.label,
value: String(type.value),
})
),
] as const
// ============================================================================
// Drawing Logs (MjProxy) Constants
// ============================================================================
/**
* MjProxy task types
* Must match backend constants in constant/midjourney.go
*/
export const MJ_TASK_TYPES = {
IMAGINE: 'IMAGINE', // 绘图
UPSCALE: 'UPSCALE', // 放大
VIDEO: 'VIDEO', // 视频
EDITS: 'EDITS', // 编辑
VARIATION: 'VARIATION', // 变换
HIGH_VARIATION: 'HIGH_VARIATION', // 强变换
LOW_VARIATION: 'LOW_VARIATION', // 弱变换
PAN: 'PAN', // 平移
DESCRIBE: 'DESCRIBE', // 图生文
BLEND: 'BLEND', // 图混合
UPLOAD: 'UPLOAD', // 上传文件
SHORTEN: 'SHORTEN', // 缩词
REROLL: 'REROLL', // 重绘
INPAINT: 'INPAINT', // 局部重绘
SWAP_FACE: 'SWAP_FACE', // 换脸
ZOOM: 'ZOOM', // 缩放
CUSTOM_ZOOM: 'CUSTOM_ZOOM', // 自定义缩放
MODAL: 'MODAL', // 窗口
} as const
/**
* MjProxy task status
*/
export const MJ_TASK_STATUS = {
NOT_START: 'NOT_START', // 未启动
SUBMITTED: 'SUBMITTED', // 队列中
IN_PROGRESS: 'IN_PROGRESS', // 执行中
SUCCESS: 'SUCCESS', // 成功
FAILURE: 'FAILURE', // 失败
MODAL: 'MODAL', // 窗口等待
} as const
/**
* MjProxy submit result codes
*/
export const MJ_SUBMIT_RESULT_CODES = {
NOT_SUBMITTED: 0, // 未提交
SUBMITTED: 1, // 已提交
WAITING: 21, // 等待中
DUPLICATE: 22, // 重复任务
} as const
// ============================================================================
// Task Logs Constants
// ============================================================================
/**
* Task action types
* Must match backend constants in constant/task.go
*/
export const TASK_ACTIONS = {
// Suno (uppercase)
MUSIC: 'MUSIC', // 生成音乐
LYRICS: 'LYRICS', // 生成歌词
// Video generation (camelCase)
GENERATE: 'generate', // 图生视频
TEXT_GENERATE: 'textGenerate', // 文生视频
FIRST_TAIL_GENERATE: 'firstTailGenerate', // 首尾生视频
REFERENCE_GENERATE: 'referenceGenerate', // 参照生视频
REMIX_GENERATE: 'remixGenerate', // 视频 Remix
} as const
/**
* Task status
*/
export const TASK_STATUS = {
NOT_START: 'NOT_START', // 未启动
SUBMITTED: 'SUBMITTED', // 队列中
IN_PROGRESS: 'IN_PROGRESS', // 执行中
SUCCESS: 'SUCCESS', // 成功
FAILURE: 'FAILURE', // 失败
QUEUED: 'QUEUED', // 排队中
UNKNOWN: 'UNKNOWN', // 未知
} as const
/**
* Task platforms
*/
export const TASK_PLATFORMS = {
SUNO: 'suno',
KLING: 'kling',
RUNWAY: 'runway',
LUMA: 'luma',
VIGGLE: 'viggle',
} as const
// ============================================================================
// Status Mappings
// ============================================================================
/**
* Status mapping configuration type
*/
export interface StatusMapping {
label: string
variant: StatusBadgeProps['variant']
}
/**
* MjProxy task type mappings
*/
export const MJ_TASK_TYPE_MAPPINGS: Record<string, StatusMapping> = {
[MJ_TASK_TYPES.IMAGINE]: { label: 'Draw', variant: 'blue' },
[MJ_TASK_TYPES.UPSCALE]: { label: 'Upscale', variant: 'orange' },
[MJ_TASK_TYPES.VIDEO]: { label: 'Video', variant: 'orange' },
[MJ_TASK_TYPES.EDITS]: { label: 'Edit', variant: 'orange' },
[MJ_TASK_TYPES.VARIATION]: { label: 'Vary', variant: 'violet' },
[MJ_TASK_TYPES.HIGH_VARIATION]: { label: 'Vary (Strong)', variant: 'violet' },
[MJ_TASK_TYPES.LOW_VARIATION]: { label: 'Vary (Subtle)', variant: 'violet' },
[MJ_TASK_TYPES.PAN]: { label: 'Pan', variant: 'cyan' },
[MJ_TASK_TYPES.DESCRIBE]: { label: 'Describe', variant: 'yellow' },
[MJ_TASK_TYPES.BLEND]: { label: 'Blend', variant: 'lime' },
[MJ_TASK_TYPES.UPLOAD]: { label: 'Upload', variant: 'blue' },
[MJ_TASK_TYPES.SHORTEN]: { label: 'Shorten', variant: 'pink' },
[MJ_TASK_TYPES.REROLL]: { label: 'Reroll', variant: 'indigo' },
[MJ_TASK_TYPES.INPAINT]: { label: 'Inpaint', variant: 'teal' },
[MJ_TASK_TYPES.SWAP_FACE]: { label: 'Swap Face', variant: 'purple' },
[MJ_TASK_TYPES.ZOOM]: { label: 'Zoom', variant: 'green' },
[MJ_TASK_TYPES.CUSTOM_ZOOM]: { label: 'Custom Zoom', variant: 'green' },
}
/**
* MjProxy task status mappings
*/
export const MJ_STATUS_MAPPINGS: Record<string, StatusMapping> = {
[MJ_TASK_STATUS.SUCCESS]: { label: 'Success', variant: 'green' },
[MJ_TASK_STATUS.NOT_START]: { label: 'Not Started', variant: 'neutral' },
[MJ_TASK_STATUS.SUBMITTED]: { label: 'Queued', variant: 'yellow' },
[MJ_TASK_STATUS.IN_PROGRESS]: { label: 'In Progress', variant: 'blue' },
[MJ_TASK_STATUS.FAILURE]: { label: 'Failed', variant: 'red' },
[MJ_TASK_STATUS.MODAL]: { label: 'Waiting', variant: 'amber' },
}
/**
* MjProxy submit result mappings
*/
export const MJ_SUBMIT_RESULT_MAPPINGS: Record<string, StatusMapping> = {
[String(MJ_SUBMIT_RESULT_CODES.SUBMITTED)]: {
label: 'Submitted',
variant: 'green',
},
[String(MJ_SUBMIT_RESULT_CODES.WAITING)]: {
label: 'Waiting',
variant: 'lime',
},
[String(MJ_SUBMIT_RESULT_CODES.DUPLICATE)]: {
label: 'Duplicate',
variant: 'orange',
},
[String(MJ_SUBMIT_RESULT_CODES.NOT_SUBMITTED)]: {
label: 'Not Submitted',
variant: 'yellow',
},
}
/**
* Task action type mappings
*/
export const TASK_ACTION_MAPPINGS: Record<string, StatusMapping> = {
[TASK_ACTIONS.MUSIC]: { label: 'Generate Music', variant: 'neutral' },
[TASK_ACTIONS.LYRICS]: { label: 'Generate Lyrics', variant: 'pink' },
[TASK_ACTIONS.GENERATE]: { label: 'Image to Video', variant: 'blue' },
[TASK_ACTIONS.TEXT_GENERATE]: { label: 'Text to Video', variant: 'blue' },
[TASK_ACTIONS.FIRST_TAIL_GENERATE]: {
label: 'First/Last Frame to Video',
variant: 'blue',
},
[TASK_ACTIONS.REFERENCE_GENERATE]: {
label: 'Reference Video',
variant: 'blue',
},
[TASK_ACTIONS.REMIX_GENERATE]: {
label: 'Video Remix',
variant: 'blue',
},
}
/**
* Task status mappings
*/
export const TASK_STATUS_MAPPINGS: Record<string, StatusMapping> = {
[TASK_STATUS.SUCCESS]: { label: 'Success', variant: 'green' },
[TASK_STATUS.NOT_START]: { label: 'Not Started', variant: 'neutral' },
[TASK_STATUS.SUBMITTED]: { label: 'Queued', variant: 'yellow' },
[TASK_STATUS.IN_PROGRESS]: { label: 'In Progress', variant: 'blue' },
[TASK_STATUS.FAILURE]: { label: 'Failed', variant: 'red' },
[TASK_STATUS.QUEUED]: { label: 'Queued', variant: 'orange' },
[TASK_STATUS.UNKNOWN]: { label: 'Unknown', variant: 'neutral' },
}
/**
* Task platform mappings
*/
export const TASK_PLATFORM_MAPPINGS: Record<string, StatusMapping> = {
[TASK_PLATFORMS.SUNO]: { label: 'suno', variant: 'green' },
[TASK_PLATFORMS.KLING]: { label: 'kling', variant: 'blue' },
[TASK_PLATFORMS.RUNWAY]: { label: 'runway', variant: 'violet' },
[TASK_PLATFORMS.LUMA]: { label: 'luma', variant: 'orange' },
[TASK_PLATFORMS.VIGGLE]: { label: 'viggle', variant: 'pink' },
}
// ============================================================================
// Log Category Labels
// ============================================================================
/**
* Log category display labels
*/
export const LOG_CATEGORY_LABELS: Record<LogCategory, string> = {
common: 'Common',
drawing: 'Drawing',
task: 'Task',
}
// ============================================================================
// Log Type Checkers (Constants)
// ============================================================================
/**
* Log types that are displayable (have detailed info)
*/
export const DISPLAYABLE_LOG_TYPES = [0, 2, 5, 6] as const
/**
* Log types that show timing info
*/
export const TIMING_LOG_TYPES = [2, 5] as const
+50
View File
@@ -0,0 +1,50 @@
/*
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
*/
/**
* Zod schemas for common logs
* This file should only contain Zod schemas and types inferred from them
*/
import { z } from 'zod'
// Usage log schema
export const usageLogSchema = z.object({
id: z.number(),
user_id: z.number(),
created_at: z.number(),
type: z.number(),
content: z.string(),
username: z.string().default(''),
token_name: z.string().default(''),
model_name: z.string().default(''),
quota: z.number().default(0),
prompt_tokens: z.number().default(0),
completion_tokens: z.number().default(0),
use_time: z.number().default(0),
is_stream: z.boolean().default(false),
channel: z.number().default(0),
channel_name: z.string().nullish().default(''),
token_id: z.number().default(0),
group: z.string().default(''),
ip: z.string().default(''),
other: z.string().default(''),
request_id: z.string().default(''),
upstream_request_id: z.string().default(''),
})
export type UsageLog = z.infer<typeof usageLogSchema>
+194
View File
@@ -0,0 +1,194 @@
/*
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 { getRouteApi, useNavigate } from '@tanstack/react-router'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import type { NavGroup } from '@/components/layout/types'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CacheStatsDialog } from '@/features/system-settings/general/channel-affinity/cache-stats-dialog'
import { useSidebarConfig } from '@/hooks/use-sidebar-config'
import { UserInfoDialog } from './components/dialogs/user-info-dialog'
import {
type LogsViewScope,
UsageLogsProvider,
useLogsViewScope,
useUsageLogsContext,
} from './components/usage-logs-provider'
import { UsageLogsTable } from './components/usage-logs-table'
import {
isUsageLogsSectionId,
USAGE_LOGS_DEFAULT_SECTION,
type UsageLogsSectionId,
} from './section-registry'
const route = getRouteApi('/_authenticated/usage-logs/$section')
const TASK_LOG_SECTIONS = ['drawing', 'task'] as const
const SECTION_META: Record<UsageLogsSectionId, { titleKey: string }> = {
common: {
titleKey: 'Common Logs',
},
drawing: {
titleKey: 'Drawing Logs',
},
task: {
titleKey: 'Task Logs',
},
}
function UsageLogsContent() {
const { t } = useTranslation()
const navigate = useNavigate()
const params = route.useParams()
const activeCategory: UsageLogsSectionId =
params.section && isUsageLogsSectionId(params.section)
? params.section
: USAGE_LOGS_DEFAULT_SECTION
const {
selectedUserId,
userInfoDialogOpen,
setUserInfoDialogOpen,
affinityTarget,
affinityDialogOpen,
setAffinityDialogOpen,
} = useUsageLogsContext()
const { canManageScope, viewScope, setViewScope } = useLogsViewScope()
const tabNavGroups = useMemo<NavGroup[]>(
() => [
{
title: 'Task Logs',
items: TASK_LOG_SECTIONS.map((section) => ({
title: SECTION_META[section].titleKey,
url: `/usage-logs/${section}`,
})),
},
],
[]
)
const filteredTabGroups = useSidebarConfig(tabNavGroups)
const visibleSections = useMemo(
() =>
(filteredTabGroups[0]?.items ?? [])
.map((item) => {
if (!('url' in item) || typeof item.url !== 'string') return null
return item.url.split('/').pop() ?? null
})
.filter((section): section is UsageLogsSectionId =>
Boolean(section && isUsageLogsSectionId(section))
),
[filteredTabGroups]
)
const handleSectionChange = useCallback(
(section: string) => {
void navigate({
to: '/usage-logs/$section',
params: { section: section as UsageLogsSectionId },
})
},
[navigate]
)
const handleViewScopeChange = useCallback(
(scope: string) => {
if (scope === 'all' || scope === 'self') {
setViewScope(scope as LogsViewScope)
}
},
[setViewScope]
)
const pageMeta =
activeCategory === 'common' ? SECTION_META.common : SECTION_META.task
const showTaskSwitcher =
activeCategory !== 'common' && visibleSections.length > 1
return (
<>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>
{t(pageMeta.titleKey)}
</SectionPageLayout.Title>
{canManageScope && (
<SectionPageLayout.Actions>
<Tabs value={viewScope} onValueChange={handleViewScopeChange}>
<TabsList>
<TabsTrigger value='all'>{t('All')}</TabsTrigger>
<TabsTrigger value='self'>{t('Only Mine')}</TabsTrigger>
</TabsList>
</Tabs>
</SectionPageLayout.Actions>
)}
<SectionPageLayout.Content>
<div className='flex h-full min-h-0 flex-col gap-4'>
{showTaskSwitcher && (
<Tabs value={activeCategory} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<div className='min-h-0 flex-1'>
<UsageLogsTable logCategory={activeCategory} />
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
<UserInfoDialog
userId={selectedUserId}
open={userInfoDialogOpen}
onOpenChange={setUserInfoDialogOpen}
/>
<CacheStatsDialog
open={affinityDialogOpen}
onOpenChange={setAffinityDialogOpen}
target={
affinityTarget
? {
rule_name: affinityTarget.rule_name || '',
using_group:
affinityTarget.using_group ||
affinityTarget.selected_group ||
'',
key_hint: affinityTarget.key_hint || '',
key_fp: affinityTarget.key_fp || '',
}
: null
}
/>
</>
)
}
export function UsageLogs() {
return (
<UsageLogsProvider>
<UsageLogsContent />
</UsageLogsProvider>
)
}
+52
View File
@@ -0,0 +1,52 @@
/*
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
*/
/**
* Column definitions factory
*/
import type { ColumnDef } from '@tanstack/react-table'
import { useCommonLogsColumns } from '../components/columns/common-logs-columns'
import { useDrawingLogsColumns } from '../components/columns/drawing-logs-columns'
import { useTaskLogsColumns } from '../components/columns/task-logs-columns'
import type { LogCategory } from '../types'
/**
* Get column definitions based on log category
* Returns any[] due to different log types (UsageLog, MjProxy log, TaskLog)
*/
export function useColumnsByCategory(
logCategory: LogCategory,
isAdmin: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ColumnDef<any>[] {
const commonColumns = useCommonLogsColumns(isAdmin)
const drawingColumns = useDrawingLogsColumns(isAdmin)
const taskColumns = useTaskLogsColumns(isAdmin)
switch (logCategory) {
case 'common':
return commonColumns
case 'drawing':
return drawingColumns
case 'task':
return taskColumns
default:
return commonColumns
}
}
+87
View File
@@ -0,0 +1,87 @@
/*
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
*/
/**
* Utility functions for usage logs filters
*/
import { LOG_CATEGORY_LABELS } from '../constants'
import type {
LogCategory,
LogFilters,
CommonLogFilters,
DrawingLogFilters,
TaskLogFilters,
} from '../types'
// ============================================================================
// Filter Building Functions
// ============================================================================
/**
* Build search params from filters based on log category
*/
export function buildSearchParams(
filters: LogFilters,
logCategory: LogCategory
): Record<string, unknown> {
const baseParams: Record<string, unknown> = {
...(filters.startTime && { startTime: filters.startTime.getTime() }),
...(filters.endTime && { endTime: filters.endTime.getTime() }),
...(filters.channel && { channel: filters.channel }),
}
switch (logCategory) {
case 'common': {
const commonFilters = filters as CommonLogFilters
return {
...baseParams,
...(commonFilters.model && { model: commonFilters.model }),
...(commonFilters.token && { token: commonFilters.token }),
...(commonFilters.group && { group: commonFilters.group }),
...(commonFilters.username && { username: commonFilters.username }),
...(commonFilters.requestId && { requestId: commonFilters.requestId }),
...(commonFilters.upstreamRequestId && {
upstreamRequestId: commonFilters.upstreamRequestId,
}),
}
}
case 'drawing': {
const drawingFilters = filters as DrawingLogFilters
return {
...baseParams,
...(drawingFilters.mjId && { filter: drawingFilters.mjId }),
}
}
case 'task': {
const taskFilters = filters as TaskLogFilters
return {
...baseParams,
...(taskFilters.taskId && { filter: taskFilters.taskId }),
}
}
default:
return baseParams
}
}
/**
* Get log category display name
*/
export function getLogCategoryLabel(category: LogCategory): string {
return LOG_CATEGORY_LABELS[category]
}
+408
View File
@@ -0,0 +1,408 @@
/*
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 type { StatusBadgeProps } from '@/components/status-badge'
import {
BILLING_PRICING_VARS,
normalizeTierLabel,
parseTiersFromExpr,
type ParsedTier,
} from '@/features/pricing/lib/billing-expr'
import type { UsageLog } from '../data/schema'
import type { LogOtherData } from '../types'
export { normalizeTierLabel }
const PARAM_OVERRIDE_ACTION_MAP: Record<string, string> = {
set: 'Set',
delete: 'Delete',
copy: 'Copy',
move: 'Move',
append: 'Append',
prepend: 'Prepend',
trim_prefix: 'Trim Prefix',
trim_suffix: 'Trim Suffix',
ensure_prefix: 'Ensure Prefix',
ensure_suffix: 'Ensure Suffix',
trim_space: 'Trim Space',
to_lower: 'To Lower',
to_upper: 'To Upper',
replace: 'Replace',
regex_replace: 'Regex Replace',
set_header: 'Set Header',
delete_header: 'Delete Header',
copy_header: 'Copy Header',
move_header: 'Move Header',
pass_headers: 'Pass Headers',
sync_fields: 'Sync Fields',
return_error: 'Return Error',
}
/**
* Get localized label for a param override action
*/
export function getParamOverrideActionLabel(
action: string,
t: (key: string) => string
): string {
const key = PARAM_OVERRIDE_ACTION_MAP[action.toLowerCase()]
return key ? t(key) : action
}
/**
* Parse a param override audit line into action and content
*/
export function parseAuditLine(
line: string
): { action: string; content: string } | null {
if (typeof line !== 'string') return null
const firstSpace = line.indexOf(' ')
if (firstSpace <= 0) return { action: line, content: line }
return {
action: line.slice(0, firstSpace),
content: line.slice(firstSpace + 1),
}
}
/**
* Check if the log is a violation fee log
*/
export function isViolationFeeLog(other: LogOtherData | null): boolean {
if (!other) return false
return (
other.violation_fee === true ||
Boolean(other.violation_fee_code) ||
Boolean(other.violation_fee_marker)
)
}
/**
* Parse the 'other' field from JSON string to object
*/
export function parseLogOther(other: string): LogOtherData | null {
if (!other) return null
try {
return JSON.parse(other) as LogOtherData
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse log other field:', error)
return null
}
}
/**
* Get time color based on duration (in seconds)
*/
export function getTimeColor(
seconds: number
): 'success' | 'warning' | 'danger' {
if (seconds < 10) return 'success'
if (seconds < 30) return 'warning'
return 'danger'
}
/**
* Get first-response-token color based on latency (in seconds)
*/
export function getFirstResponseTimeColor(
seconds: number
): 'success' | 'warning' | 'danger' {
if (seconds < 5) return 'success'
if (seconds < 10) return 'warning'
return 'danger'
}
/**
* Get throughput color based on generated tokens per second
*/
export function getThroughputColor(
tokensPerSecond: number
): 'success' | 'warning' | 'danger' {
if (tokensPerSecond >= 30) return 'success'
if (tokensPerSecond >= 15) return 'warning'
return 'danger'
}
/**
* Get response color using throughput only when enough output tokens exist.
*/
export function getResponseTimeColor(
seconds: number,
completionTokens: number
): 'success' | 'warning' | 'danger' {
if (completionTokens < 100 || seconds <= 0) return getTimeColor(seconds)
return getThroughputColor(completionTokens / seconds)
}
/**
* Format model name with mapping indicator
*/
export function formatModelName(log: UsageLog): {
name: string
isMapped: boolean
actualModel?: string
} {
const other = parseLogOther(log.other)
const isMapped = !!(
other?.is_model_mapped &&
other?.upstream_model_name &&
other.upstream_model_name !== ''
)
return {
name: log.model_name,
isMapped,
actualModel: isMapped ? other.upstream_model_name : undefined,
}
}
/**
* Decode a base64-encoded billing expression. Safely returns an empty string
* when the input is missing or malformed (e.g. legacy logs without expr_b64).
*/
export function decodeBillingExprB64(exprB64: string | undefined): string {
if (!exprB64) return ''
try {
const binaryString =
typeof window !== 'undefined'
? window.atob(exprB64)
: Buffer.from(exprB64, 'base64').toString('binary')
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
if (typeof TextDecoder !== 'undefined') {
return new TextDecoder().decode(bytes)
}
return decodeURIComponent(
Array.prototype.map
.call(bytes, (byte: number) => `%${byte.toString(16).padStart(2, '0')}`)
.join('')
)
} catch {
return ''
}
}
/**
* Resolve which parsed tier corresponds to the matched_tier label in a log
* entry. Missing or unknown labels do not fall back to another tier because
* that would display guessed unit prices.
*/
export function resolveMatchedTier(
tiers: ParsedTier[],
matchedLabel: string | undefined
): ParsedTier | null {
if (tiers.length === 0) return null
if (!matchedLabel) return null
const found = tiers.find((tier) => {
const l1 = normalizeTierLabel(tier.label)
const l2 = normalizeTierLabel(matchedLabel)
return l1 === l2 && l1 !== ''
})
return found || null
}
/**
* Tiered pricing summary derived from an `other` log payload using the
* billing-expression library. Returns null when the entry is not a tiered
* billing log or the expression failed to parse.
*/
export interface TieredBillingSummary {
tiers: ParsedTier[]
tier: ParsedTier
priceEntries: Array<{ field: string; shortLabel: string; price: number }>
}
/**
* Whether the request payload reports any cache-related token usage. Used to
* suppress cache pricing rows from the tiered breakdown when the request did
* not exercise the cache path.
*/
export function hasAnyCacheTokens(
other: LogOtherData | null | undefined
): boolean {
if (!other) return false
return (
(other.cache_tokens || 0) > 0 ||
(other.cache_creation_tokens || 0) > 0 ||
(other.cache_creation_tokens_5m || 0) > 0 ||
(other.cache_creation_tokens_1h || 0) > 0
)
}
export function getTieredBillingSummary(
other: LogOtherData | null
): TieredBillingSummary | null {
if (!other || other.billing_mode !== 'tiered_expr') return null
const exprStr = decodeBillingExprB64(other.expr_b64)
if (!exprStr) return null
const tiers = parseTiersFromExpr(exprStr)
const tier = resolveMatchedTier(tiers, other.matched_tier)
if (!tier) return null
const cacheTokensPresent = hasAnyCacheTokens(other)
const priceEntries: TieredBillingSummary['priceEntries'] = []
for (const v of BILLING_PRICING_VARS) {
if (!v.field) continue
if (v.group === 'cache' && !cacheTokensPresent) continue
const raw = tier[v.field as keyof ParsedTier]
const price = Number(raw)
if (Number.isFinite(price) && price > 0) {
priceEntries.push({
field: v.field,
shortLabel: v.shortLabel,
price,
})
}
}
return { tiers, tier, priceEntries }
}
/**
* Calculate duration and return formatted result with color variant
* @param submitTime - Submit timestamp
* @param finishTime - Finish timestamp
* @param unit - Unit of the timestamps ('seconds' or 'milliseconds')
*/
export function formatDuration(
submitTime?: number,
finishTime?: number,
unit: 'seconds' | 'milliseconds' = 'milliseconds'
): { durationSec: number; variant: StatusBadgeProps['variant'] } | null {
if (!submitTime || !finishTime) return null
const durationSec =
unit === 'milliseconds'
? (finishTime - submitTime) / 1000
: finishTime - submitTime
return { durationSec, variant: durationSec > 60 ? 'red' : 'green' }
}
/**
* Maps a language-independent audit/login operation `action` to an i18n
* template string (the template itself is the i18n key, with {{placeholders}}).
*
* The backend stores only `action` + structured `params` in `other.op`; the UI
* renders localized content at display time so audit/login logs are fully
* translatable instead of being frozen to whatever language was written to DB.
*/
const AUDIT_TEMPLATES: Record<string, string> = {
login: 'Logged in successfully via {{method}}',
// User management
'user.create': 'Created user {{username}} (role {{role}})',
'user.update': 'Updated user {{username}} (ID: {{id}})',
'user.delete': 'Deleted user {{username}} (ID: {{id}})',
'user.manage': 'Performed {{action}} on user {{username}} (ID: {{id}})',
'user.quota_add': 'Increased user quota by {{quota}}',
'user.quota_subtract': 'Decreased user quota by {{quota}}',
'user.quota_override': 'Overrode user quota from {{from}} to {{to}}',
'user.binding_clear': 'Cleared {{bindingType}} binding for user {{username}}',
'user.2fa_disable': 'Force-disabled two-factor authentication for the user',
'user.passkey_register': 'Registered a passkey',
'user.passkey_delete': 'Deleted a passkey',
'user.topup_complete': 'Completed top-up order for the user',
'user.reset_passkey': 'Reset the user passkey',
'user.oauth_unbind': 'Removed an OAuth binding for the user',
// System settings
'option.update': 'Updated system setting {{key}}',
'option.payment_compliance': 'Confirmed payment compliance',
'option.reset_ratio': 'Reset model ratios',
'option.clear_affinity_cache': 'Cleared channel affinity cache',
// Custom OAuth
'custom_oauth.create': 'Created a custom OAuth provider',
'custom_oauth.update': 'Updated a custom OAuth provider',
'custom_oauth.delete': 'Deleted a custom OAuth provider',
// Performance / cache
'performance.clear_disk_cache': 'Cleared disk cache',
'performance.gc': 'Triggered garbage collection',
'performance.clear_logs': 'Cleared log files',
// Channel
'channel.create': 'Created channel {{name}} (type {{type}}, count {{count}})',
'channel.update': 'Updated channel {{name}} (ID: {{id}})',
'channel.delete': 'Deleted channel {{name}} (ID: {{id}})',
'channel.delete_batch': 'Batch deleted {{count}} channels',
'channel.delete_disabled': 'Deleted all disabled channels ({{count}})',
'channel.key_view': 'Viewed channel key {{name}} (ID: {{id}})',
'channel.tag_disable': 'Disabled channels with tag {{tag}}',
'channel.tag_enable': 'Enabled channels with tag {{tag}}',
'channel.tag_edit': 'Edited channels with tag {{tag}}',
'channel.tag_batch_set': 'Batch set tag for {{count}} channels',
'channel.copy':
'Copied channel (source ID: {{sourceId}}) to {{name}} (new ID: {{id}})',
'channel.multi_key_manage':
'Multi-key management {{action}} on channel (ID: {{id}})',
'channel.upstream_apply':
'Applied upstream model changes to channel (ID: {{id}})',
'channel.upstream_apply_all':
'Applied upstream model changes to {{count}} channels',
// Redemption codes
'redemption.create':
'Created {{count}} redemption codes named {{name}} ({{quota}} each)',
'redemption.update': 'Updated a redemption code',
'redemption.delete': 'Deleted a redemption code',
'redemption.delete_invalid': 'Deleted invalid redemption codes',
// Prefill groups
'prefill_group.create': 'Created a prefill group',
'prefill_group.update': 'Updated a prefill group',
'prefill_group.delete': 'Deleted a prefill group',
// Vendors
'vendor.create': 'Created a vendor',
'vendor.update': 'Updated a vendor',
'vendor.delete': 'Deleted a vendor',
// Model metadata
'model.create': 'Created a model',
'model.update': 'Updated a model',
'model.delete': 'Deleted a model',
'model.sync_upstream': 'Synced upstream models',
// Deployments
'deployment.create': 'Created a deployment',
'deployment.update': 'Updated a deployment',
'deployment.delete': 'Deleted a deployment',
// Subscriptions
'subscription.plan_create': 'Created a subscription plan',
'subscription.plan_update': 'Updated a subscription plan',
'subscription.bind': 'Bound a subscription',
// Logs
'log.clear': 'Cleared historical logs',
'log.cleanup_start': 'Log cleanup task started.',
// Generic middleware fallback
generic: '{{method}} {{route}}',
}
/**
* Render the localized content of an audit/login log from its structured
* `other.op` descriptor. Returns null when the log has no recognized action,
* letting callers fall back to the raw `content` field.
*/
export function renderAuditContent(
other: LogOtherData | null | undefined,
t: (key: string, opts?: Record<string, unknown>) => string
): string | null {
const op = other?.op
if (!op?.action) return null
const template = AUDIT_TEMPLATES[op.action]
if (!template) return null
return t(template, (op.params ?? {}) as Record<string, unknown>)
}
+63
View File
@@ -0,0 +1,63 @@
/*
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
*/
/**
* Central export point for all lib utilities
*/
// Format utilities (usage-logs specific)
export {
parseLogOther,
getTimeColor,
formatModelName,
formatDuration,
getParamOverrideActionLabel,
parseAuditLine,
isViolationFeeLog,
} from './format'
// Filter utilities
export { buildSearchParams, getLogCategoryLabel } from './filter'
// General utilities
export {
isDisplayableLogType,
isTimingLogType,
getLogTypeConfig,
isPerCallBilling,
getDefaultTimeRange,
buildQueryParams,
buildBaseParams,
buildApiParams,
fetchLogsByCategory,
} from './utils'
// Status mapper utilities
export { createStatusMapper } from './status'
// Mappers
export {
mjTaskTypeMapper,
mjStatusMapper,
taskActionMapper,
taskStatusMapper,
taskPlatformMapper,
} from './mappers'
// Column utilities
export { useColumnsByCategory } from './columns'
+71
View File
@@ -0,0 +1,71 @@
/*
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
*/
/**
* Status mappers for different log types
* Centralized mapper instances for consistent usage across components
*/
import {
MJ_TASK_TYPE_MAPPINGS,
MJ_STATUS_MAPPINGS,
MJ_SUBMIT_RESULT_MAPPINGS,
TASK_ACTION_MAPPINGS,
TASK_STATUS_MAPPINGS,
TASK_PLATFORM_MAPPINGS,
} from '../constants'
import { createStatusMapper } from './status'
// ============================================================================
// MjProxy (Drawing) Logs Mappers
// ============================================================================
/**
* MjProxy task type mapper
*/
export const mjTaskTypeMapper = createStatusMapper(MJ_TASK_TYPE_MAPPINGS)
/**
* MjProxy task status mapper
*/
export const mjStatusMapper = createStatusMapper(MJ_STATUS_MAPPINGS)
/**
* MjProxy submit result mapper
*/
export const mjSubmitResultMapper = createStatusMapper(
MJ_SUBMIT_RESULT_MAPPINGS
)
// ============================================================================
// Task Logs Mappers
// ============================================================================
/**
* Task action type mapper
*/
export const taskActionMapper = createStatusMapper(TASK_ACTION_MAPPINGS)
/**
* Task status mapper
*/
export const taskStatusMapper = createStatusMapper(TASK_STATUS_MAPPINGS)
/**
* Task platform mapper
*/
export const taskPlatformMapper = createStatusMapper(TASK_PLATFORM_MAPPINGS)
+39
View File
@@ -0,0 +1,39 @@
/*
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 type { StatusBadgeProps } from '@/components/status-badge'
/**
* Generic status mapping utility
* Creates a function to map status values to labels and variants
*/
export function createStatusMapper<T extends string>(mapping: {
[key in T]?: { label: string; variant: StatusBadgeProps['variant'] }
}) {
return {
getLabel: (status: string, defaultLabel = 'Unknown'): string => {
return mapping[status as T]?.label ?? defaultLabel
},
getVariant: (
status: string,
defaultVariant: StatusBadgeProps['variant'] = 'neutral'
): StatusBadgeProps['variant'] => {
return mapping[status as T]?.variant ?? defaultVariant
},
}
}
+304
View File
@@ -0,0 +1,304 @@
/*
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
*/
/**
* Utility functions for usage logs feature
*/
import {
getAllLogs,
getUserLogs,
getAllMidjourneyLogs,
getUserMidjourneyLogs,
getAllTaskLogs,
getUserTaskLogs,
} from '../api'
import {
LOG_TYPES,
DISPLAYABLE_LOG_TYPES,
TIMING_LOG_TYPES,
} from '../constants'
import type {
GetLogsParams,
GetLogsResponse,
FetchLogsConfig,
GetMidjourneyLogsParams,
GetTaskLogsParams,
} from '../types'
// ============================================================================
// Type Checkers & Utilities
// ============================================================================
/**
* Check if log type is displayable (has detailed info)
*/
export function isDisplayableLogType(type: number): boolean {
return (DISPLAYABLE_LOG_TYPES as readonly number[]).includes(type)
}
/**
* Check if log type shows timing info
*/
export function isTimingLogType(type: number): boolean {
return (TIMING_LOG_TYPES as readonly number[]).includes(type)
}
/**
* Get log type configuration by type number
*/
export function getLogTypeConfig(type: number) {
return LOG_TYPES.find((t) => t.value === type) || LOG_TYPES[0]
}
/**
* Check if log uses per-call billing
*/
export function isPerCallBilling(modelPrice?: number): boolean {
return (modelPrice ?? 0) > 0
}
/**
* Get default time range (today 00:00:00 to now + 1 hour)
*/
export function getDefaultTimeRange(): { start: Date; end: Date } {
const now = new Date()
const start = new Date(now)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getTime() + 3600 * 1000) // +1 hour
return { start, end }
}
/**
* Convert milliseconds timestamp to seconds for API
*/
function timestampToSeconds(ms: number): number {
return Math.floor(ms / 1000)
}
/**
* Build query parameters from filters
*/
export function buildQueryParams(
params: Record<string, unknown>
): URLSearchParams {
const queryParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
// Keep 0 as a valid value, only filter out undefined, null, and empty string
if (value !== undefined && value !== null && value !== '') {
queryParams.append(key, String(value))
}
})
return queryParams
}
/**
* Build time range parameters with default values
* Shared logic for all log types
*/
function buildTimeRangeParams(
searchParams: Record<string, unknown>,
useMilliseconds: boolean
): { start_timestamp?: number; end_timestamp?: number } {
const hasTimeParams = searchParams.startTime ?? searchParams.endTime
const defaultTimeRange = !hasTimeParams ? getDefaultTimeRange() : null
const convertTimestamp = (timestamp: number) =>
useMilliseconds ? timestamp : timestampToSeconds(timestamp)
const getTimestamp = (paramTime?: unknown, defaultTime?: Date) => {
const time = (paramTime as number) || defaultTime?.getTime()
return time ? convertTimestamp(time) : undefined
}
return {
start_timestamp: getTimestamp(
searchParams.startTime,
defaultTimeRange?.start
),
end_timestamp: getTimestamp(searchParams.endTime, defaultTimeRange?.end),
}
}
/**
* Build base parameters with time range (for drawing and task logs)
* @param useMilliseconds - Whether to use millisecond timestamps (true for drawing logs, false for task logs)
*/
export function buildBaseParams(config: {
page: number
pageSize: number
searchParams: Record<string, unknown>
useMilliseconds?: boolean
}): {
p: number
page_size: number
channel_id?: string
start_timestamp?: number
end_timestamp?: number
} {
const { page, pageSize, searchParams, useMilliseconds = false } = config
return {
p: page,
page_size: pageSize,
...(searchParams.channel
? {
channel_id: String(searchParams.channel),
}
: {}),
...buildTimeRangeParams(searchParams, useMilliseconds),
}
}
/**
* Build API params from search params and column filters (for common logs)
*/
export function buildApiParams(config: {
page: number
pageSize: number
searchParams: Record<string, unknown>
columnFilters?: Array<{ id: string; value: unknown }>
isAdmin: boolean
}): GetLogsParams {
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
// Helper to process type parameter (single value from array)
const processType = (value: unknown): number | undefined => {
const parseType = (raw: unknown): number | undefined => {
const type = Number(raw)
return Number.isFinite(type) ? type : undefined
}
if (Array.isArray(value) && value.length === 1) {
return parseType(value[0])
}
if (typeof value === 'string' && value !== '') {
return parseType(value)
}
return undefined
}
// Build base params from search params
const params: GetLogsParams = {
p: page,
page_size: pageSize,
...(searchParams.type ? { type: processType(searchParams.type) } : {}),
...(searchParams.model ? { model_name: String(searchParams.model) } : {}),
...(searchParams.token ? { token_name: String(searchParams.token) } : {}),
...(searchParams.group ? { group: String(searchParams.group) } : {}),
...(isAdmin && searchParams.channel
? { channel: Number(searchParams.channel) || 0 }
: {}),
...(isAdmin && searchParams.username
? { username: String(searchParams.username) }
: {}),
...(searchParams.requestId
? { request_id: String(searchParams.requestId) }
: {}),
...(searchParams.upstreamRequestId
? { upstream_request_id: String(searchParams.upstreamRequestId) }
: {}),
...buildTimeRangeParams(searchParams, false),
}
// Override with column filters if present
if (columnFilters.length > 0) {
columnFilters.forEach(({ id, value }) => {
if (value === undefined || value === null || value === '') return
switch (id) {
case 'type':
params.type = processType(value)
break
case 'model_name':
params.model_name = String(value)
break
case 'token_name':
params.token_name = String(value)
break
case 'group':
params.group = String(value)
break
case 'channel':
if (isAdmin) params.channel = Number(value) || 0
break
case 'username':
if (isAdmin) params.username = String(value)
break
}
})
}
return params
}
// ============================================================================
// Data Fetching
// ============================================================================
/**
* Fetch logs based on category type
*/
export async function fetchLogsByCategory(
config: FetchLogsConfig
): Promise<GetLogsResponse> {
const { logCategory, isAdmin, page, pageSize, searchParams, columnFilters } =
config
if (logCategory === 'common') {
const params = buildApiParams({
page,
pageSize,
searchParams,
columnFilters,
isAdmin,
})
return isAdmin ? await getAllLogs(params) : await getUserLogs(params)
}
// For drawing and task logs
const baseParams = buildBaseParams({
page,
pageSize,
searchParams,
useMilliseconds: logCategory === 'drawing',
})
const paramsWithFilter = {
...baseParams,
...(logCategory === 'drawing'
? { mj_id: searchParams.filter as string | undefined }
: {}),
...(logCategory === 'task'
? { task_id: searchParams.filter as string | undefined }
: {}),
}
if (logCategory === 'drawing') {
return isAdmin
? await getAllMidjourneyLogs(paramsWithFilter as GetMidjourneyLogsParams)
: await getUserMidjourneyLogs(paramsWithFilter as GetMidjourneyLogsParams)
}
// task logs
return isAdmin
? await getAllTaskLogs(paramsWithFilter as GetTaskLogsParams)
: await getUserTaskLogs(paramsWithFilter as GetTaskLogsParams)
}
+62
View File
@@ -0,0 +1,62 @@
/*
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 { createSectionRegistry } from '@/features/system-settings/utils/section-registry'
/**
* Usage logs page section definitions
*/
const USAGE_LOGS_SECTIONS = [
{
id: 'common',
titleKey: 'Common Logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'drawing',
titleKey: 'Drawing Logs',
build: () => null, // Content is rendered directly in the page component
},
{
id: 'task',
titleKey: 'Task Logs',
build: () => null, // Content is rendered directly in the page component
},
] as const
export type UsageLogsSectionId = (typeof USAGE_LOGS_SECTIONS)[number]['id']
const usageLogsRegistry = createSectionRegistry<
UsageLogsSectionId,
Record<string, never>,
[]
>({
sections: USAGE_LOGS_SECTIONS,
defaultSection: 'common',
basePath: '/usage-logs',
urlStyle: 'path',
})
export const USAGE_LOGS_SECTION_IDS = usageLogsRegistry.sectionIds
export const USAGE_LOGS_DEFAULT_SECTION = usageLogsRegistry.defaultSection
/** Type guard for validating section IDs without casting. Use with z.string().refine() or params checks. */
export function isUsageLogsSectionId(s: string): s is UsageLogsSectionId {
return (USAGE_LOGS_SECTION_IDS as readonly string[]).includes(s)
}
export const getUsageLogsSectionNavItems = usageLogsRegistry.getSectionNavItems
+406
View File
@@ -0,0 +1,406 @@
/*
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
*/
/**
* Type definitions for usage logs
*/
import type { UsageLog } from './data/schema'
// ============================================================================
// Log Category Types
// ============================================================================
/**
* Log category for different log types
*/
export type LogCategory = 'common' | 'drawing' | 'task'
// ============================================================================
// Filter Types
// ============================================================================
/**
* Common filters (shared across all log types)
*/
export interface CommonFilters {
startTime?: Date
endTime?: Date
channel?: string
}
/**
* Common logs specific filters
*/
export interface CommonLogFilters extends CommonFilters {
model?: string
token?: string
group?: string
username?: string
requestId?: string
upstreamRequestId?: string
}
/**
* Drawing logs specific filters
*/
export interface DrawingLogFilters extends CommonFilters {
mjId?: string
}
/**
* Task logs specific filters
*/
export interface TaskLogFilters extends CommonFilters {
taskId?: string
}
/**
* Union type for all log filters
*/
export type LogFilters = CommonLogFilters | DrawingLogFilters | TaskLogFilters
// ============================================================================
// Common Logs Additional Types
// ============================================================================
/**
* Parsed data from the 'other' field in usage logs
*/
export interface ChannelAffinityInfo {
rule_name?: string
selected_group?: string
key_source?: string
key_path?: string
key_key?: string
key_hint?: string
key_fp?: string
using_group?: string
}
export const USAGE_BILLING_PATH = {
LOCAL: 'local',
UPSTREAM: 'upstream',
OPENAI: 'billing-usage-openai',
OPENAI_ESTIMATED: 'billing-usage-openai-estimated',
ANTHROPIC: 'billing-usage-anthropic',
ANTHROPIC_ESTIMATED: 'billing-usage-anthropic-estimated',
GEMINI: 'billing-usage-gemini',
GEMINI_ESTIMATED: 'billing-usage-gemini-estimated',
} as const
export type UsageBillingPath =
(typeof USAGE_BILLING_PATH)[keyof typeof USAGE_BILLING_PATH]
export interface LogOtherData {
admin_info?: {
is_multi_key?: boolean
multi_key_index?: number
use_channel?: number[]
local_count_tokens?: boolean
usage_billing_path?: UsageBillingPath | string
channel_affinity?: ChannelAffinityInfo
// Top-up audit fields (type=1, admin only)
payment_method?: string
callback_payment_method?: string
caller_ip?: string
server_ip?: string
version?: string
node_name?: string
// Operator identity for audit logs (type=3, admin only)
admin_username?: string
admin_id?: number | string
admin_role?: number
auth_method?: 'session' | 'access_token' | string
// Quota saturation marker: set when a quota conversion clamped at the
// int32 bound (overflow/underflow) or hit a NaN fallback while computing
// this request's charge. Admin-only (nested under admin_info).
quota_saturation?: {
op: string
kind: 'overflow' | 'underflow' | 'nan'
original: number
clamped: number
}
}
// Language-independent operation descriptor (audit/login logs).
// Frontend renders localized content from action + params via i18n templates.
op?: {
action?: string
params?: Record<string, string | number | boolean | string[]>
}
// Operation audit details written by the admin-audit fallback in authHelper (type=3, admin only)
audit_info?: {
method?: string
route?: string
path?: string
status?: number
success?: boolean
params?: Record<string, string>
}
// Login audit fields (type=7); visible to the log owner
login_method?: string
user_agent?: string
request_path?: string
request_conversion?: string[]
ws?: boolean
audio?: boolean
audio_input?: number
audio_output?: number
text_input?: number
text_output?: number
cache_tokens?: number
cache_creation_tokens?: number
cache_creation_tokens_5m?: number
cache_creation_tokens_1h?: number
claude?: boolean
model_ratio?: number
completion_ratio?: number
model_price?: number
group_ratio?: number
user_group_ratio?: number
cache_ratio?: number
cache_creation_ratio?: number
cache_creation_ratio_5m?: number
cache_creation_ratio_1h?: number
is_model_mapped?: boolean
upstream_model_name?: string
audio_ratio?: number
audio_completion_ratio?: number
frt?: number
// Tiered (expression-based) billing fields, set by backend when
// billing_mode === 'tiered_expr'. expr_b64 is the base64-encoded billing
// expression and matched_tier is the label of the tier that fired.
billing_mode?: string
expr_b64?: string
matched_tier?: string
reasoning_effort?: string
image?: boolean
image_ratio?: number
image_output?: number
web_search?: boolean
web_search_call_count?: number
web_search_price?: number
file_search?: boolean
file_search_call_count?: number
file_search_price?: number
audio_input_seperate_price?: boolean
audio_input_token_count?: number
audio_input_price?: number
image_generation_call?: boolean
image_generation_call_price?: number
is_system_prompt_overwritten?: boolean
po?: string[]
billing_source?: string
group?: string
stream_status?: {
status?: string
end_reason?: string
error_count?: number
end_error?: string
errors?: string[]
}
// Violation fee fields
violation_fee?: boolean
violation_fee_code?: string
violation_fee_marker?: string
fee_quota?: number
// Reject / intercept reason (admin)
reject_reason?: string
// Task-related fields (for refund logs, type=6)
is_task?: boolean
task_id?: string
reason?: string
// Subscription billing fields
subscription_plan_id?: string
subscription_plan_title?: string
subscription_id?: string
subscription_pre_consumed?: number
subscription_post_delta?: number
subscription_consumed?: number
subscription_remain?: number
subscription_total?: number
}
/**
* Log statistics data
*/
export interface LogStatistics {
quota: number
rpm: number
tpm: number
}
// ============================================================================
// Drawing Logs (MjProxy) Types
// ============================================================================
export interface MidjourneyLog {
id: number
user_id: number
channel_id: number
code: number
mj_id: string
action: string // IMAGINE, UPSCALE, VARIATION, etc. (backend field name)
submit_time: number // milliseconds
finish_time?: number // milliseconds
start_time?: number // milliseconds
fail_reason?: string
progress: string
prompt: string
prompt_en?: string
description?: string
buttons?: string
properties?: string
image_url?: string
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, MODAL
other?: string
created_at?: number
updated_at?: number
}
// ============================================================================
// Task Logs Types
// ============================================================================
export interface TaskLog {
id: number
user_id: number
username?: string
platform: string // suno, kling, runway, etc.
task_id: string
action: string // MUSIC, LYRICS, GENERATE, TEXT_GENERATE, etc.
channel_id: number
submit_time: number // seconds
finish_time?: number // seconds
progress?: string
progress_message_en?: string
data?: string // JSON string
fail_reason?: string
status: string // NOT_START, SUBMITTED, IN_PROGRESS, SUCCESS, FAILURE, QUEUED, UNKNOWN
other?: string
created_at?: number
updated_at?: number
}
// ============================================================================
// Common Log Types
// ============================================================================
export interface GetLogsParams {
p?: number
page_size?: number
type?: number
username?: string
token_name?: string
model_name?: string
start_timestamp?: number
end_timestamp?: number
channel?: number
group?: string
request_id?: string
upstream_request_id?: string
}
export interface GetLogsResponse {
success: boolean
message?: string
data?: {
items: UsageLog[] | MidjourneyLog[] | TaskLog[]
total: number
page: number
page_size: number
}
}
export interface GetLogStatsParams {
type?: number
username?: string
token_name?: string
model_name?: string
start_timestamp?: number
end_timestamp?: number
channel?: number
group?: string
request_id?: string
upstream_request_id?: string
}
export interface GetLogStatsResponse {
success: boolean
message?: string
data?: LogStatistics
}
// ============================================================================
// Drawing Log Types
// ============================================================================
export interface GetMidjourneyLogsParams {
p?: number
page_size?: number
channel_id?: string
mj_id?: string
start_timestamp?: number
end_timestamp?: number
}
// ============================================================================
// Task Log Types
// ============================================================================
export interface GetTaskLogsParams {
p?: number
page_size?: number
channel_id?: string
task_id?: string
start_timestamp?: number
end_timestamp?: number
}
// ============================================================================
// Fetch Logs Configuration
// ============================================================================
/**
* Configuration for fetching logs by category
*/
export interface FetchLogsConfig {
logCategory: LogCategory
isAdmin: boolean
page: number
pageSize: number
searchParams: Record<string, unknown>
columnFilters: Array<{ id: string; value: unknown }>
}
// ============================================================================
// User Info Types
// ============================================================================
export interface UserInfo {
id: number
username: string
display_name?: string
quota: number
used_quota: number
request_count: number
group?: string
aff_code?: string
aff_count?: number
aff_quota?: number
remark?: string
}