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:
Vendored
+112
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
Vendored
+406
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user