refactor(ui): Improve usage log filter responsiveness and mobile UX

Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
This commit is contained in:
t0ng7u
2026-05-25 05:35:44 +08:00
parent b302be30e3
commit 583da45296
79 changed files with 1879 additions and 1262 deletions
@@ -35,7 +35,7 @@ import {
formatQuota as formatQuotaValue,
} from '@/lib/format'
import { getLobeIcon } from '@/lib/lobe-icon'
import { cn, truncateText } from '@/lib/utils'
import { truncateText } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -47,11 +47,8 @@ import {
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { GroupBadge } from '@/components/group-badge'
import {
StatusBadge,
dotColorMap,
textColorMap,
} from '@/components/status-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { getCodexUsage } from '../api'
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
@@ -107,25 +104,12 @@ function renderLimitedItems(
items: React.ReactNode[],
maxDisplay: number = 2
): React.ReactNode {
if (items.length === 0)
return <span className='text-muted-foreground text-xs'>-</span>
const displayed = items.slice(0, maxDisplay)
const remaining = items.length - maxDisplay
return (
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
{displayed}
{remaining > 0 && (
<StatusBadge
label={`+${remaining}`}
variant='neutral'
size='sm'
copyable={false}
className='flex-shrink-0'
/>
)}
</div>
<StatusBadgeList
items={items}
max={maxDisplay}
renderItem={(item) => item}
/>
)
}
@@ -361,47 +345,50 @@ function BalanceCell({ channel }: { channel: Channel }) {
return (
<TooltipProvider>
<div className='flex items-center gap-1.5 text-xs font-medium'>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap[isUpdating ? 'neutral' : variant]
)}
aria-hidden='true'
/>
<div className='flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={<span className='text-muted-foreground cursor-help' />}
>
{usedDisplay}
</TooltipTrigger>
render={
<StatusBadge
label={usedDisplay}
variant='neutral'
size='sm'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p>
{t('Used:')} {usedDisplay}
</p>
</TooltipContent>
</Tooltip>
<span className='text-muted-foreground/30'>·</span>
<Tooltip>
<TooltipTrigger
render={
<span
className={cn(
'cursor-pointer transition-opacity hover:opacity-70',
<StatusBadge
label={
isUpdating
? t('Updating...')
: channel.type === 57
? t('Account Info')
: remainingDisplay
}
variant={
channel.type === 57
? 'text-primary'
: textColorMap[isUpdating ? 'neutral' : variant]
)}
? 'info'
: isUpdating
? 'neutral'
: variant
}
size='sm'
copyable={false}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
}
>
{isUpdating
? 'Updating...'
: channel.type === 57
? t('Account Info')
: remainingDisplay}
</TooltipTrigger>
/>
<TooltipContent>
<p>
{channel.type === 57
@@ -491,15 +478,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
),
cell: ({ row }) => {
const id = row.getValue('id') as number
return (
<StatusBadge
label={String(id)}
variant='neutral'
copyText={String(id)}
size='sm'
className='font-mono'
/>
)
return <TableId value={id} />
},
size: 80,
},
@@ -695,8 +674,13 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
/>
}
>
<span className='text-muted-foreground/30'>·</span>
<span className={cn(textColorMap.purple)}>IO.NET</span>
<StatusBadge
label='IO.NET'
variant='purple'
size='sm'
copyable={false}
className='cursor-pointer'
/>
</TooltipTrigger>
<TooltipContent side='top'>
<div className='max-w-xs space-y-1'>
@@ -747,7 +731,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<StatusBadge
label={`Active (${childrenCount})`}
variant='success'
showDot
size='sm'
copyable={false}
/>
@@ -806,7 +789,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<StatusBadge
label={label}
variant={config.variant}
showDot={config.showDot}
size='sm'
copyable={false}
/>
@@ -835,7 +817,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
<StatusBadge
label={label}
variant={config.variant}
showDot={config.showDot}
size='sm'
copyable={false}
/>
@@ -75,6 +75,12 @@ import {
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge'
import { formatResponseTime, handleTestChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -833,19 +839,19 @@ function FailureDetailsSheet({
side={isMobile ? 'bottom' : 'right'}
className={
isMobile
? 'max-h-[85dvh] gap-0 overflow-hidden rounded-t-xl p-0'
: 'h-dvh w-full gap-0 overflow-hidden p-0 sm:max-w-lg'
? sideDrawerContentClassName('h-auto max-h-[85dvh] rounded-t-xl')
: sideDrawerContentClassName('sm:max-w-lg')
}
>
{details && (
<>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
<SheetHeader className={sideDrawerHeaderClassName('sm:px-5')}>
<SheetTitle className='pr-10'>{t('Details')}</SheetTitle>
<SheetDescription className='pr-10 wrap-break-word'>
{details.model}
</SheetDescription>
</SheetHeader>
<div className='min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-3 sm:px-5 sm:py-4'>
<div className={sideDrawerFormClassName('gap-4 sm:px-5')}>
<section className='space-y-1'>
<div className='text-muted-foreground text-xs font-medium'>
{t('Model')}
@@ -869,7 +875,7 @@ function FailureDetailsSheet({
</pre>
</section>
</div>
<SheetFooter className='border-t px-4 py-3 sm:flex-row sm:justify-end sm:px-5'>
<SheetFooter className={sideDrawerFooterClassName('sm:px-5')}>
<Button
variant='outline'
className='w-full sm:w-auto'
@@ -99,6 +99,14 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSectionClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { MultiSelect } from '@/components/multi-select'
import {
@@ -269,9 +277,9 @@ function formatUnixTime(timestamp: unknown): string {
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
return (
<div className='flex items-center gap-2.5'>
<div className='flex items-center gap-3'>
{icon && (
<span className='bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-lg'>
<span className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md'>
{icon}
</span>
)}
@@ -1087,10 +1095,10 @@ export function ChannelMutateDrawer({
return (
<>
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-3xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle className='flex items-center gap-3'>
<span className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border'>
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-md'>
{getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
</span>
<span>
@@ -1115,10 +1123,10 @@ export function ChannelMutateDrawer({
<form
id='channel-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-5 sm:px-4'
className={sideDrawerFormClassName('gap-5')}
>
{/* ── Basic Information ── */}
<div className='bg-card space-y-4 rounded-xl border p-3 sm:p-5'>
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('Basic Information')}
icon={<Server className='h-4 w-4' />}
@@ -1173,8 +1181,8 @@ export function ChannelMutateDrawer({
control={form.control}
name='status'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border px-4 py-3'>
<div className='space-y-0.5'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel>{t('Enabled')}</FormLabel>
<FormDescription className='text-xs'>
{t('Enable or disable this channel')}
@@ -1213,7 +1221,7 @@ export function ChannelMutateDrawer({
</div>
{/* ── API Access ── */}
<div className='bg-card space-y-4 rounded-xl border p-5'>
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('API Access')}
icon={<Link2 className='h-4 w-4' />}
@@ -1945,7 +1953,7 @@ export function ChannelMutateDrawer({
</div>
</FormDescription>
{isEditing && (
<div className='mt-4 space-y-3 rounded-lg border border-dashed p-4'>
<div className='border-border/60 mt-4 flex flex-col gap-3 border-y border-dashed py-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>
@@ -2007,9 +2015,9 @@ export function ChannelMutateDrawer({
/>
{currentType === 57 && (
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-0.5'>
<div className='flex flex-col gap-0.5'>
<div className='text-sm font-semibold'>
{t('Codex Authorization')}
</div>
@@ -2171,7 +2179,7 @@ export function ChannelMutateDrawer({
</div>
{/* ── Models & Groups ── */}
<div className='bg-card space-y-4 rounded-xl border p-5'>
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('Models & Groups')}
icon={<Boxes className='h-4 w-4' />}
@@ -2429,11 +2437,11 @@ export function ChannelMutateDrawer({
render={
<button
type='button'
className='bg-card hover:bg-accent/50 flex w-full items-center justify-between rounded-xl border px-5 py-4 text-left transition-colors'
className='hover:bg-muted/40 flex w-full items-center justify-between rounded-md py-2 text-left transition-colors'
/>
}
>
<div className='space-y-0.5'>
<div className='flex flex-col gap-0.5'>
<div className='text-[13px] font-semibold'>
{t('Advanced Settings')}
</div>
@@ -2451,14 +2459,14 @@ export function ChannelMutateDrawer({
/>
</CollapsibleTrigger>
<CollapsibleContent className='mt-5 space-y-5'>
<CollapsibleContent className='mt-5 flex flex-col gap-5'>
{/* ── Routing & Overrides ── */}
<div className='bg-card space-y-4 rounded-xl border p-5'>
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('Routing & Overrides')}
icon={<Route className='h-4 w-4' />}
/>
<div className='space-y-4'>
<div className='flex flex-col gap-4'>
<SubHeading
title={t('Routing Strategy')}
icon={<Route className='h-3.5 w-3.5' />}
@@ -2557,7 +2565,7 @@ export function ChannelMutateDrawer({
/>
</div>
<div className='space-y-4 border-t pt-4'>
<div className='flex flex-col gap-4 border-t pt-4'>
<SubHeading
title={t('Internal Notes')}
icon={<FileText className='h-3.5 w-3.5' />}
@@ -2606,7 +2614,7 @@ export function ChannelMutateDrawer({
</div>
</div>
<div className='space-y-4 border-t pt-4'>
<div className='flex flex-col gap-4 border-t pt-4'>
<SubHeading
title={t('Override Rules')}
icon={<Code className='h-3.5 w-3.5' />}
@@ -2848,13 +2856,13 @@ export function ChannelMutateDrawer({
</div>
{/* ── Extra Settings ── */}
<div className='bg-card space-y-4 rounded-xl border p-5'>
<div className={sideDrawerSectionClassName()}>
<CardHeading
title={t('Channel Extra Settings')}
icon={<Settings className='h-4 w-4' />}
/>
{(currentType === 1 || currentType === 14) && (
<div className='space-y-3 rounded-lg border p-4'>
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<SubHeading
title={t('Field passthrough controls')}
icon={<SlidersHorizontal className='h-3.5 w-3.5' />}
@@ -3220,7 +3228,7 @@ export function ChannelMutateDrawer({
/>
{MODEL_FETCHABLE_TYPES.has(currentType) && (
<div className='space-y-3 rounded-lg border p-4'>
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<SubHeading
title={t('Upstream Model Detection Settings')}
icon={<RefreshCw className='h-3.5 w-3.5' />}
@@ -3341,7 +3349,7 @@ export function ChannelMutateDrawer({
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose
render={<Button variant='outline' disabled={isSubmitting} />}
>
-4
View File
@@ -131,22 +131,18 @@ export const CHANNEL_STATUS_CONFIG = {
[CHANNEL_STATUS.UNKNOWN]: {
variant: 'neutral' as const,
label: 'Unknown',
showDot: true,
},
[CHANNEL_STATUS.ENABLED]: {
variant: 'success' as const,
label: 'Enabled',
showDot: true,
},
[CHANNEL_STATUS.MANUAL_DISABLED]: {
variant: 'neutral' as const,
label: 'Disabled',
showDot: true,
},
[CHANNEL_STATUS.AUTO_DISABLED]: {
variant: 'danger' as const,
label: 'Auto Disabled',
showDot: true,
},
}
@@ -30,7 +30,6 @@ import {
FileText,
KeyRound,
ListChecks,
Play,
RadioTower,
ShieldCheck,
TerminalSquare,
@@ -505,10 +504,10 @@ export function OverviewDashboard() {
const quickActions = useMemo<QuickAction[]>(
() => [
{
title: t('Playground'),
description: t('Test models and prompts from the browser'),
to: '/playground',
icon: Play,
title: t('API Keys'),
description: t('Create a key for your app or service'),
to: '/keys',
icon: KeyRound,
},
{
title: t('Channels'),
@@ -26,6 +26,7 @@ import { VCHART_OPTION } from '@/lib/vchart'
import { useThemeCustomization } from '@/context/theme-customization-provider'
import { useTheme } from '@/context/theme-provider'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
import {
TIME_GRANULARITY_OPTIONS,
@@ -154,61 +155,64 @@ export function UserCharts() {
return (
<div className='space-y-3'>
<div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
{TIME_RANGE_PRESETS.map((preset) => (
<button
key={preset.days}
type='button'
onClick={() => handleRangeChange(preset.days)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
selectedRange === preset.days
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t(preset.label)}
</button>
))}
</div>
<Tabs
value={String(selectedRange)}
onValueChange={(value) => handleRangeChange(Number(value))}
className='shrink-0'
>
<TabsList>
{TIME_RANGE_PRESETS.map((preset) => (
<TabsTrigger
key={preset.days}
value={String(preset.days)}
className='px-2.5 text-xs'
>
{t(preset.label)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
{TIME_GRANULARITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type='button'
onClick={() =>
handleGranularityChange(opt.value as TimeGranularity)
}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
timeGranularity === opt.value
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t(opt.label)}
</button>
))}
</div>
<Tabs
value={timeGranularity}
onValueChange={(value) =>
handleGranularityChange(value as TimeGranularity)
}
className='shrink-0'
>
<TabsList>
{TIME_GRANULARITY_OPTIONS.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className='px-2.5 text-xs'
>
{t(opt.label)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
<span className='text-muted-foreground px-2 text-xs font-medium'>
{t('Top Users')}
</span>
{TOP_USER_LIMIT_OPTIONS.map((limit) => (
<button
key={limit}
type='button'
onClick={() => setTopUserLimit(limit)}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
topUserLimit === limit
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
{t('Top {{count}}', { count: limit })}
</button>
))}
</div>
<Tabs
value={String(topUserLimit)}
onValueChange={(value) => setTopUserLimit(Number(value))}
className='shrink-0'
>
<TabsList>
<span className='text-muted-foreground px-2 text-xs font-medium whitespace-nowrap'>
{t('Top Users')}
</span>
{TOP_USER_LIMIT_OPTIONS.map((limit) => (
<TabsTrigger
key={limit}
value={String(limit)}
className='px-2.5 text-xs'
>
{t('Top {{count}}', { count: limit })}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{isLoading && (
<Loader2 className='text-muted-foreground size-4 animate-spin' />
@@ -118,7 +118,6 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
showDot={statusConfig.showDot}
copyable={false}
/>
)
@@ -212,12 +211,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground/60'>
{t('Cross-group')}
</span>
</>
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
)}
</TooltipTrigger>
<TooltipContent>
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
import { useApiKeys } from './api-keys-provider'
@@ -24,19 +23,6 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
export function ApiKeysDialogs() {
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
const [lastMutateSide, setLastMutateSide] = useState<'left' | 'right'>(
'right'
)
const mutateSide =
open === 'create' ? 'left' : open === 'update' ? 'right' : lastMutateSide
useEffect(() => {
if (open === 'create') {
setLastMutateSide('left')
} else if (open === 'update') {
setLastMutateSide('right')
}
}, [open])
return (
<>
@@ -44,7 +30,6 @@ export function ApiKeysDialogs() {
open={open === 'create' || open === 'update'}
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
currentRow={open === 'update' ? currentRow || undefined : undefined}
side={mutateSide}
/>
<ApiKeysDeleteDialog />
<CCSwitchDialog
@@ -16,17 +16,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useState } from 'react'
import { useForm, type SubmitErrorHandler } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import {
ChevronDown,
KeyRound,
Settings2,
WalletCards,
type LucideIcon,
} from 'lucide-react'
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { getUserModels, getUserGroups } from '@/lib/api'
@@ -61,6 +55,15 @@ import {
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { DateTimePicker } from '@/components/datetime-picker'
import {
SideDrawerSection,
SideDrawerSectionHeader,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { MultiSelect } from '@/components/multi-select'
import { createApiKey, updateApiKey, getApiKey } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -82,42 +85,12 @@ type ApiKeyMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: ApiKey
side?: 'left' | 'right'
}
type ApiKeyFormSectionProps = {
title: string
description: string
icon: LucideIcon
children: ReactNode
}
function ApiKeyFormSection(props: ApiKeyFormSectionProps) {
const Icon = props.icon
return (
<section className='bg-card rounded-lg border'>
<div className='flex items-center gap-2.5 border-b px-3 py-2.5 sm:gap-3 sm:px-4 sm:py-3'>
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
<Icon className='size-4 sm:size-5' />
</div>
<div className='min-w-0'>
<h3 className='text-sm leading-none font-medium'>{props.title}</h3>
<p className='text-muted-foreground mt-0.5 text-xs sm:mt-1'>
{props.description}
</p>
</div>
</div>
<div className='space-y-3 p-3 sm:space-y-4 sm:p-4'>{props.children}</div>
</section>
)
}
export function ApiKeysMutateDrawer({
open,
onOpenChange,
currentRow,
side = 'right',
}: ApiKeyMutateDrawerProps) {
const { t } = useTranslation()
const isUpdate = !!currentRow
@@ -284,31 +257,30 @@ export function ApiKeysMutateDrawer({
}}
>
<SheetContent
side={side}
className='bg-background flex !h-dvh !w-screen max-w-none gap-0 overflow-hidden p-0 sm:!w-full sm:!max-w-[620px]'
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
>
<SheetHeader className='bg-background border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
<SheetTitle className='text-base sm:text-lg'>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate ? t('Update API Key') : t('Create API Key')}
</SheetTitle>
<SheetDescription className='pr-6 text-xs sm:text-sm'>
<SheetDescription>
{isUpdate
? t('Update the API key by providing necessary info.')
: t('Add a new API key by providing necessary info.')}{' '}
{t("Click save when you're done.")}
: t('Add a new API key by providing necessary info.')}
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='api-key-form'
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
className='min-h-0 flex-1 space-y-3 overflow-y-auto overscroll-contain px-3 py-3 sm:space-y-4 sm:px-4 sm:py-4'
className={sideDrawerFormClassName('gap-5')}
>
<ApiKeyFormSection
title={t('Basic Information')}
description={t('Set API key basic information')}
icon={KeyRound}
>
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Basic Information')}
description={t('Set API key basic information')}
icon={<KeyRound className='size-4' />}
/>
<FormField
control={form.control}
name='name'
@@ -347,8 +319,8 @@ export function ApiKeysMutateDrawer({
control={form.control}
name='cross_group_retry'
render={({ field }) => (
<FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
<div className='space-y-0.5'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel className='text-sm'>
{t('Cross-group retry')}
</FormLabel>
@@ -456,13 +428,14 @@ export function ApiKeysMutateDrawer({
)}
/>
)}
</ApiKeyFormSection>
</SideDrawerSection>
<ApiKeyFormSection
title={t('Quota Settings')}
description={t('Set quota amount and limits')}
icon={WalletCards}
>
<SideDrawerSection>
<SideDrawerSectionHeader
title={t('Quota Settings')}
description={t('Set quota amount and limits')}
icon={<WalletCards className='size-4' />}
/>
{!unlimitedQuota && (
<FormField
control={form.control}
@@ -498,8 +471,8 @@ export function ApiKeysMutateDrawer({
control={form.control}
name='unlimited_quota'
render={({ field }) => (
<FormItem className='flex min-h-16 flex-row items-center justify-between gap-3 rounded-lg border px-3 py-2.5 sm:min-h-20 sm:gap-4 sm:px-4 sm:py-3'>
<div className='space-y-0.5'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel className='text-sm'>
{t('Unlimited Quota')}
</FormLabel>
@@ -516,29 +489,24 @@ export function ApiKeysMutateDrawer({
</FormItem>
)}
/>
</ApiKeyFormSection>
</SideDrawerSection>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<section className='bg-card rounded-lg border'>
<SideDrawerSection>
<CollapsibleTrigger
render={
<button
type='button'
className='hover:bg-muted/50 flex w-full items-center gap-2.5 px-3 py-2.5 text-left transition-colors sm:gap-3 sm:px-4 sm:py-3'
className='hover:bg-muted/40 flex w-full items-center gap-3 rounded-md py-1.5 text-left transition-colors'
/>
}
>
<div className='bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-lg border sm:size-10'>
<Settings2 className='size-4 sm:size-5' />
</div>
<div className='min-w-0 flex-1'>
<h3 className='text-sm leading-none font-medium'>
{t('Advanced Settings')}
</h3>
<p className='text-muted-foreground mt-1 text-xs'>
{t('Set API key access restrictions')}
</p>
</div>
<SideDrawerSectionHeader
className='flex-1'
title={t('Advanced Settings')}
description={t('Set API key access restrictions')}
icon={<Settings2 className='size-4' />}
/>
<ChevronDown
className={cn(
'text-muted-foreground size-4 shrink-0 transition-transform',
@@ -547,7 +515,7 @@ export function ApiKeysMutateDrawer({
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className='space-y-3 border-t p-3 sm:space-y-4 sm:p-4'>
<div className='flex flex-col gap-4 pt-2'>
<FormField
control={form.control}
name='model_limits'
@@ -604,11 +572,11 @@ export function ApiKeysMutateDrawer({
/>
</div>
</CollapsibleContent>
</section>
</SideDrawerSection>
</Collapsible>
</form>
</Form>
<SheetFooter className='bg-background grid grid-cols-2 gap-2 border-t px-3 py-3 sm:flex sm:flex-row sm:justify-end sm:px-5 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose
render={<Button variant='outline' className='w-full sm:w-auto' />}
>
@@ -153,7 +153,6 @@ function ApiKeysMobileList({
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
showDot={statusConfig.showDot}
copyable={false}
/>
)}
+1 -5
View File
@@ -32,7 +32,7 @@ export const API_KEY_STATUS = {
export const API_KEY_STATUSES: Record<
number,
Pick<StatusBadgeProps, 'variant' | 'showDot'> & {
Pick<StatusBadgeProps, 'variant'> & {
label: string
value: number
}
@@ -41,25 +41,21 @@ export const API_KEY_STATUSES: Record<
label: 'Enabled',
variant: 'success',
value: API_KEY_STATUS.ENABLED,
showDot: true,
},
[API_KEY_STATUS.DISABLED]: {
label: 'Disabled',
variant: 'neutral',
value: API_KEY_STATUS.DISABLED,
showDot: true,
},
[API_KEY_STATUS.EXPIRED]: {
label: 'Expired',
variant: 'warning',
value: API_KEY_STATUS.EXPIRED,
showDot: true,
},
[API_KEY_STATUS.EXHAUSTED]: {
label: 'Exhausted',
variant: 'danger',
value: API_KEY_STATUS.EXHAUSTED,
showDot: true,
},
} as const
@@ -23,6 +23,7 @@ import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { getDeploymentStatusConfig } from '../constants'
import {
formatRemainingMinutes,
@@ -50,15 +51,7 @@ export function useDeploymentsColumns(opts: {
),
cell: ({ row }) => {
const id = row.original.id
return (
<StatusBadge
label={String(id)}
variant='neutral'
copyText={String(id)}
size='sm'
className='font-mono'
/>
)
return <TableId value={id} />
},
size: 120,
},
@@ -100,7 +93,6 @@ export function useDeploymentsColumns(opts: {
<StatusBadge
label={config.label}
variant={config.variant}
showDot={config.showDot}
size='sm'
copyable={false}
/>
@@ -51,6 +51,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { MultiSelect } from '@/components/multi-select'
import {
checkClusterNameAvailability,
@@ -375,8 +382,8 @@ export function CreateDeploymentDrawer({
}
}}
>
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
<SheetHeader className='text-start'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>{t('Create deployment')}</SheetTitle>
<SheetDescription>
{t('Configure and deploy a new container instance.')}
@@ -389,10 +396,10 @@ export function CreateDeploymentDrawer({
onSubmit={form.handleSubmit((values) =>
createMutation.mutate(values)
)}
className='flex-1 space-y-6 overflow-y-auto px-4'
className={sideDrawerFormClassName()}
>
{/* Basic Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Basic Configuration')}
</h3>
@@ -435,10 +442,10 @@ export function CreateDeploymentDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
{/* Resource Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Resource Configuration')}
</h3>
@@ -604,10 +611,10 @@ export function CreateDeploymentDrawer({
)}
/>
</div>
</div>
</SideDrawerSection>
{/* Price Estimation */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>{t('Price estimation')}</h3>
<p className='text-muted-foreground text-xs'>
{t('Price estimation description')}
@@ -642,10 +649,10 @@ export function CreateDeploymentDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
{/* Advanced Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Advanced Configuration')}
</h3>
@@ -653,7 +660,7 @@ export function CreateDeploymentDrawer({
{t('Optional settings for advanced container configuration.')}
</p>
<div className='space-y-4'>
<div className='flex flex-col gap-4'>
<div className='grid gap-4 sm:grid-cols-2'>
<FormField
control={form.control}
@@ -758,11 +765,11 @@ export function CreateDeploymentDrawer({
/>
</div>
</div>
</div>
</SideDrawerSection>
</form>
</Form>
<SheetFooter className='gap-2'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Cancel')}
</SheetClose>
@@ -65,6 +65,7 @@ import {
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { deletePrefillGroup, getPrefillGroups } from '../../api'
import { prefillGroupsQueryKeys } from '../../lib'
import type { PrefillGroup } from '../../types'
@@ -405,13 +406,7 @@ export function PrefillGroupManagementDialog({
<span className='font-medium'>
{group.name}
</span>
<StatusBadge
label={`#${group.id}`}
variant='neutral'
size='sm'
copyable={false}
className='font-mono'
/>
<TableId value={group.id} />
</div>
{group.description ? (
<p className='text-muted-foreground text-xs'>
@@ -50,7 +50,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetClose,
@@ -62,6 +61,14 @@ import {
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { TagInput } from '@/components/tag-input'
import {
@@ -627,8 +634,8 @@ export function ModelMutateDrawer({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isEditing ? t('Edit Model') : t('Create Model')}
</SheetTitle>
@@ -647,10 +654,10 @@ export function ModelMutateDrawer({
onSubmit={form.handleSubmit(
onSubmit as Parameters<typeof form.handleSubmit>[0]
)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
className={sideDrawerFormClassName()}
>
{/* Basic Information */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-semibold'>
{t('Basic Information')}
</h3>
@@ -774,12 +781,10 @@ export function ModelMutateDrawer({
</FormItem>
)}
/>
</div>
<Separator />
</SideDrawerSection>
{/* Matching Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-semibold'>{t('Matching Rules')}</h3>
<FormField
@@ -822,12 +827,10 @@ export function ModelMutateDrawer({
</FormItem>
)}
/>
</div>
<Separator />
</SideDrawerSection>
{/* Endpoints Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<div className='flex items-center justify-between'>
<h3 className='text-sm font-semibold'>{t('Endpoints')}</h3>
<Select<string>
@@ -883,12 +886,10 @@ export function ModelMutateDrawer({
</FormItem>
)}
/>
</div>
<Separator />
</SideDrawerSection>
{/* Pricing Configuration */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-semibold'>
{t('Pricing Configuration')}
</h3>
@@ -1114,7 +1115,7 @@ export function ModelMutateDrawer({
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<CollapsibleContent className='flex flex-col gap-4 pt-4'>
<FormField
control={form.control}
name='cacheRatio'
@@ -1226,20 +1227,18 @@ export function ModelMutateDrawer({
</Collapsible>
</>
)}
</div>
<Separator />
</SideDrawerSection>
{/* Status & Sync */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-semibold'>{t('Status & Sync')}</h3>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel className='text-base'>
{t('Enabled')}
</FormLabel>
@@ -1261,8 +1260,8 @@ export function ModelMutateDrawer({
control={form.control}
name='sync_official'
render={({ field }) => (
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<div className='flex flex-col gap-0.5'>
<FormLabel className='text-base'>
{t('Official Sync')}
</FormLabel>
@@ -1279,11 +1278,11 @@ export function ModelMutateDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose
render={<Button variant='outline' disabled={isSubmitting} />}
>
@@ -42,7 +42,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
SheetClose,
@@ -53,6 +52,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { StatusBadge } from '@/components/status-badge'
import { TagInput } from '@/components/tag-input'
@@ -180,8 +186,8 @@ export function PrefillGroupFormDrawer({
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
</SheetTitle>
@@ -196,10 +202,10 @@ export function PrefillGroupFormDrawer({
<form
id='prefill-group-form'
onSubmit={form.handleSubmit(handleSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
className={sideDrawerFormClassName()}
>
<div className='space-y-4'>
<div className='space-y-1'>
<SideDrawerSection>
<div className='flex flex-col gap-1'>
<h3 className='text-sm font-semibold'>{t('Group details')}</h3>
<p className='text-muted-foreground text-sm'>
{t(
@@ -252,12 +258,10 @@ export function PrefillGroupFormDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
<Separator />
<div className='space-y-4'>
<div className='space-y-1'>
<SideDrawerSection>
<div className='flex flex-col gap-1'>
<h3 className='text-sm font-semibold'>{t('Configuration')}</h3>
<p className='text-muted-foreground text-sm'>
{t('Choose the bundle type and define the items inside it.')}
@@ -326,7 +330,7 @@ export function PrefillGroupFormDrawer({
)}
/>
<div className='space-y-2 rounded-lg border p-3 sm:p-4'>
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<div className='flex items-center gap-2'>
<h4 className='text-sm font-medium'>{t('Project')}</h4>
<StatusBadge
@@ -379,11 +383,11 @@ export function PrefillGroupFormDrawer({
)}
/>
</div>
</div>
</SideDrawerSection>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose
render={
<Button type='button' variant='outline' disabled={isSaving} />
@@ -29,7 +29,8 @@ import {
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import {
getModelStatusConfig,
getNameRuleConfig,
@@ -47,25 +48,12 @@ function renderLimitedItems(
items: React.ReactNode[],
maxDisplay: number = 2
): React.ReactNode {
if (items.length === 0)
return <span className='text-muted-foreground text-xs'>-</span>
const displayed = items.slice(0, maxDisplay)
const remaining = items.length - maxDisplay
return (
<div className='flex max-w-full items-center gap-1 overflow-x-auto'>
{displayed}
{remaining > 0 && (
<StatusBadge
label={`+${remaining}`}
variant='neutral'
size='sm'
copyable={false}
className='flex-shrink-0'
/>
)}
</div>
<StatusBadgeList
items={items}
max={maxDisplay}
renderItem={(item) => item}
/>
)
}
@@ -118,15 +106,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
),
cell: ({ row }) => {
const id = row.getValue('id') as number
return (
<StatusBadge
label={String(id)}
variant='neutral'
copyText={String(id)}
size='sm'
className='font-mono'
/>
)
return <TableId value={id} />
},
size: 80,
},
@@ -250,7 +230,6 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
<StatusBadge
label={config.label}
variant={config.variant}
showDot={config.showDot}
size='sm'
copyable={false}
/>
+3 -9
View File
@@ -79,12 +79,9 @@ export function getModelStatusOptions(t: TFunction) {
export function getModelStatusConfig(
t: TFunction
): Record<
ModelStatus,
{ label: string; variant: 'success' | 'neutral'; showDot?: boolean }
> {
): Record<ModelStatus, { label: string; variant: 'success' | 'neutral' }> {
return {
1: { label: t('Enabled'), variant: 'success', showDot: true },
1: { label: t('Enabled'), variant: 'success' },
0: { label: t('Disabled'), variant: 'neutral' },
}
}
@@ -122,11 +119,10 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
{
label: string
variant: 'success' | 'neutral' | 'warning' | 'danger'
showDot?: boolean
}
> {
return {
running: { label: t('Running'), variant: 'success', showDot: true },
running: { label: t('Running'), variant: 'success' },
completed: { label: t('Completed'), variant: 'success' },
failed: { label: t('Failed'), variant: 'danger' },
error: { label: t('Failed'), variant: 'danger' },
@@ -134,12 +130,10 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
'deployment requested': {
label: t('Deployment requested'),
variant: 'warning',
showDot: true,
},
'termination requested': {
label: t('Termination requested'),
variant: 'warning',
showDot: true,
},
}
}
@@ -311,13 +311,13 @@ export function DynamicPricingBreakdown({
<Table className='text-sm'>
<TableHeader>
<TableRow className='hover:bg-transparent'>
<TableHead className='text-muted-foreground py-2 text-[10px] font-medium tracking-wider uppercase'>
<TableHead className='text-muted-foreground py-2 font-medium'>
{t('Tier')}
</TableHead>
{visiblePriceFields.map((v) => (
<TableHead
key={v.field}
className='text-muted-foreground py-2 text-right text-[10px] font-medium tracking-wider uppercase'
className='text-muted-foreground py-2 text-right font-medium'
>
{t(v.shortLabel)}
</TableHead>
@@ -574,14 +574,10 @@ function SupportedParametersSection(props: { model: PricingModel }) {
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9 w-44 text-xs'>
{t('Parameter')}
</TableHead>
<TableHead className='h-9 w-24 text-xs'>{t('Type')}</TableHead>
<TableHead className='h-9 w-32 text-xs'>
{t('Default / range')}
</TableHead>
<TableHead className='h-9 text-xs'>{t('Description')}</TableHead>
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
<TableHead className='h-9'>{t('Description')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -589,13 +585,13 @@ function SupportedParametersSection(props: { model: PricingModel }) {
<TableRow key={p.name} className='hover:bg-muted/20'>
<TableCell className='py-2 align-top'>
<div className='flex items-center gap-1.5'>
<code className='font-mono text-xs font-medium'>
<code className='font-mono text-sm font-medium'>
{p.name}
</code>
{p.required && (
<Badge
variant='outline'
className='h-4 border-rose-500/40 px-1 text-[9px] text-rose-600 dark:text-rose-400'
className='h-6 border-rose-500/40 px-2 text-sm text-rose-600 dark:text-rose-400'
>
{t('required')}
</Badge>
@@ -605,7 +601,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
<TableCell className='py-2 align-top'>
<Badge
variant='secondary'
className='h-5 rounded-sm px-1.5 font-mono text-[10px] font-normal'
className='h-7 rounded-full px-2.5 font-mono text-sm font-normal'
>
{p.type}
</Badge>
@@ -613,7 +609,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
<TableCell className='py-2 align-top'>
<ParamRangeCell param={p} />
</TableCell>
<TableCell className='text-muted-foreground py-2 align-top text-xs'>
<TableCell className='text-muted-foreground py-2 align-top'>
{t(p.descriptionKey)}
</TableCell>
</TableRow>
@@ -630,21 +626,19 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
if (defaultValue !== undefined) {
return (
<div className='flex flex-wrap items-center gap-1'>
<span className='text-muted-foreground text-[11px]'>=</span>
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
<span className='text-muted-foreground text-sm'>=</span>
<code className='bg-muted rounded px-1.5 py-0.5 font-mono text-sm'>
{String(defaultValue)}
</code>
{range && (
<span className='text-muted-foreground text-[11px]'>{range}</span>
<span className='text-muted-foreground text-sm'>{range}</span>
)}
</div>
)
}
if (range) {
return (
<span className='text-muted-foreground font-mono text-[11px]'>
{range}
</span>
<span className='text-muted-foreground font-mono text-sm'>{range}</span>
)
}
if (enumValues && enumValues.length > 0) {
@@ -653,7 +647,7 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
{enumValues.map((v) => (
<code
key={v}
className='bg-muted text-muted-foreground rounded px-1 py-0.5 font-mono text-[10px]'
className='bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-sm'
>
{v}
</code>
@@ -661,7 +655,7 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
</div>
)
}
return <span className='text-muted-foreground/60 text-[11px]'></span>
return <span className='text-muted-foreground/60 text-sm'></span>
}
// ---------------------------------------------------------------------------
@@ -681,25 +675,23 @@ function RateLimitsSection(props: { model: PricingModel }) {
<Table>
<TableHeader>
<TableRow className='bg-muted/30 hover:bg-muted/30'>
<TableHead className='h-9 text-xs'>{t('Group')}</TableHead>
<TableHead className='h-9 text-right text-xs'>RPM</TableHead>
<TableHead className='h-9 text-right text-xs'>TPM</TableHead>
<TableHead className='h-9 text-right text-xs'>RPD</TableHead>
<TableHead className='h-9'>{t('Group')}</TableHead>
<TableHead className='h-9 text-right'>RPM</TableHead>
<TableHead className='h-9 text-right'>TPM</TableHead>
<TableHead className='h-9 text-right'>RPD</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{limits.map((l) => (
<TableRow key={l.group} className='hover:bg-muted/20'>
<TableCell className='py-2 font-mono text-xs'>
{l.group}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.rpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.tpm)}
</TableCell>
<TableCell className='py-2 text-right font-mono text-xs'>
<TableCell className='py-2 text-right font-mono'>
{formatRateLimit(l.rpd)}
</TableCell>
</TableRow>
@@ -199,13 +199,13 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
<div className='text-sm font-medium'>
<AppLink app={app} />
</div>
<p className='text-muted-foreground line-clamp-1 text-xs'>
<p className='text-muted-foreground line-clamp-1 text-sm'>
{app.description}
</p>
</div>
</div>
</TableCell>
<TableCell className='text-muted-foreground hidden py-2.5 text-xs md:table-cell'>
<TableCell className='text-muted-foreground hidden py-2.5 md:table-cell'>
{app.category}
</TableCell>
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
@@ -42,6 +42,7 @@ import {
} from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CopyButton } from '@/components/copy-button'
import { sideDrawerContentClassName } from '@/components/drawer-layout'
import { GroupBadge } from '@/components/group-badge'
import { PublicLayout } from '@/components/layout'
import { getPerfMetrics } from '@/features/performance-metrics/api'
@@ -735,7 +736,7 @@ function GroupPricingSection(props: {
return (
<TableRow key={`${group}-${tier.label || tierIndex}`}>
<TableCell className='text-muted-foreground py-2.5 text-xs'>
<TableCell className='text-muted-foreground py-2.5'>
{tier.label || t('Default')}
</TableCell>
{priceFields.map((fieldEntry) => {
@@ -808,7 +809,7 @@ function GroupPricingSection(props: {
<TableCell className='py-2.5'>
<GroupBadge group={group} size='sm' />
</TableCell>
<TableCell className='text-muted-foreground py-2.5 font-mono text-xs'>
<TableCell className='text-muted-foreground py-2.5 font-mono'>
{ratio}x
</TableCell>
{isTokenBased ? (
@@ -1006,7 +1007,9 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side='right'
className='flex h-dvh w-full overflow-hidden p-0 sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
className={sideDrawerContentClassName(
'sm:max-w-2xl lg:max-w-3xl xl:max-w-4xl 2xl:max-w-5xl'
)}
>
<SheetHeader className='sr-only'>
<SheetTitle>{props.model.model_name}</SheetTitle>
@@ -27,6 +27,7 @@ import {
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table/column-header'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
import {
getDynamicDisplayGroupRatio,
@@ -56,19 +57,15 @@ function renderLimitedTags(
items: string[],
maxDisplay: number = 3
): React.ReactNode {
if (items.length === 0)
return <span className='text-muted-foreground/50 text-xs'></span>
const displayed = items.slice(0, maxDisplay)
const remaining = items.length - maxDisplay
return (
<span className='text-muted-foreground text-xs'>
{displayed.join(', ')}
{remaining > 0 && (
<span className='text-muted-foreground/50'> +{remaining}</span>
<StatusBadgeList
items={items}
max={maxDisplay}
getKey={(item) => item}
renderItem={(item) => (
<StatusBadge label={item} autoColor={item} size='sm' copyable={false} />
)}
</span>
/>
)
}
@@ -76,21 +73,13 @@ function renderLimitedGroupBadges(
groups: string[],
maxDisplay: number = 2
): React.ReactNode {
if (groups.length === 0)
return <span className='text-muted-foreground/50 text-xs'></span>
const displayed = groups.slice(0, maxDisplay)
const remaining = groups.length - maxDisplay
return (
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
{displayed.map((group) => (
<GroupBadge key={group} group={group} size='sm' />
))}
{remaining > 0 && (
<span className='text-muted-foreground/50 text-xs'>+{remaining}</span>
)}
</div>
<StatusBadgeList
items={groups}
max={maxDisplay}
getKey={(group) => group}
renderItem={(group) => <GroupBadge group={group} size='sm' />}
/>
)
}
@@ -141,9 +130,11 @@ export function usePricingColumns(
cell: ({ row }) => {
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
return (
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{isTokenBased ? t('Token') : t('Request')}
</span>
<StatusBadge
label={isTokenBased ? t('Token') : t('Request')}
variant={isTokenBased ? 'info' : 'neutral'}
copyable={false}
/>
)
},
size: 80,
@@ -365,9 +356,14 @@ export function usePricingColumns(
? getLobeIcon(model.vendor_icon, 12)
: null
return (
<span className='text-muted-foreground flex items-center gap-1.5 text-xs'>
<span className='flex items-center gap-1.5'>
{vendorIcon}
{model.vendor_name}
<StatusBadge
label={model.vendor_name}
autoColor={model.vendor_name}
size='sm'
copyable={false}
/>
</span>
)
},
@@ -102,7 +102,7 @@ export function PricingTable(props: PricingTableProps) {
<TableHead
key={header.id}
style={{ width: header.getSize() }}
className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'
className='text-muted-foreground font-medium'
>
{header.isPlaceholder
? null
@@ -40,6 +40,11 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import {
VIEW_MODES,
getSortLabels,
@@ -269,15 +274,15 @@ export function PricingToolbar(props: PricingToolbarProps) {
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<SheetContent
side='right'
className='flex h-dvh w-full flex-col overflow-hidden p-0 sm:max-w-md'
className={sideDrawerContentClassName('sm:max-w-md')}
>
<SheetHeader className='border-b px-4 py-3 sm:px-6 sm:py-4'>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>{t('Filter')}</SheetTitle>
<SheetDescription>
{t('Filter models by provider, group, type, endpoint, and tags.')}
</SheetDescription>
</SheetHeader>
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
<div className={sideDrawerFormClassName('gap-0')}>
<PricingSidebar
quotaTypeFilter={props.quotaTypeFilter}
endpointTypeFilter={props.endpointTypeFilter}
@@ -28,6 +28,7 @@ import {
import { DataTableColumnHeader } from '@/components/data-table'
import { MaskedValueDisplay } from '@/components/masked-value-display'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
import { isRedemptionExpired, isTimestampExpired } from '../lib'
import { type Redemption } from '../types'
@@ -66,7 +67,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
<DataTableColumnHeader column={column} title={t('ID')} />
),
cell: ({ row }) => {
return <div className='w-[60px]'>{row.getValue('id')}</div>
return (
<TableId value={row.getValue('id') as number} className='w-[60px]' />
)
},
},
{
@@ -99,7 +102,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
<StatusBadge
label={t('Expired')}
variant='warning'
showDot={true}
copyable={false}
/>
)
@@ -115,7 +117,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
showDot={statusConfig.showDot}
copyable={false}
/>
)
@@ -44,6 +44,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { DateTimePicker } from '@/components/datetime-picker'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { createRedemption, updateRedemption, getRedemption } from '../api'
import { SUCCESS_MESSAGES } from '../constants'
import {
@@ -151,8 +158,8 @@ export function RedemptionsMutateDrawer({
}
}}
>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate
? t('Update Redemption Code')
@@ -171,141 +178,143 @@ export function RedemptionsMutateDrawer({
<form
id='redemption-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
className={sideDrawerFormClassName()}
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormDescription>
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='space-y-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<SideDrawerSection>
<FormField
control={form.control}
name='count'
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormLabel>{t('Name')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
<Input {...field} placeholder={t('Enter a name')} />
</FormControl>
<FormDescription>
{t('Create multiple redemption codes at once (1-100)')}
{t('Name for this redemption code (1-20 characters)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name='quota_dollars'
render={({ field }) => (
<FormItem>
<FormLabel>{quotaLabel}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
step={tokensOnly ? 1 : 0.01}
placeholder={quotaPlaceholder}
onChange={(e) =>
field.onChange(parseFloat(e.target.value) || 0)
}
/>
</FormControl>
<FormDescription>
{tokensOnly
? t('Enter the quota amount in tokens')
: t('Enter the quota amount in {{currency}}', {
currency: currencyLabel,
})}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='expired_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Expiration Time')}</FormLabel>
<div className='flex flex-col gap-2'>
<FormControl>
<DateTimePicker
value={field.value}
onChange={field.onChange}
placeholder={t('Never expires')}
/>
</FormControl>
<div className='grid grid-cols-4 gap-1.5 sm:flex sm:gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1M')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 7, 0)}
>
{t('1W')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
</Button>
</div>
</div>
<FormDescription>
{t('Leave empty for never expires')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{!isUpdate && (
<FormField
control={form.control}
name='count'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quantity')}</FormLabel>
<FormControl>
<Input
{...field}
type='number'
min='1'
max='100'
placeholder={t('Number of codes to create')}
onChange={(e) =>
field.onChange(parseInt(e.target.value, 10) || 1)
}
/>
</FormControl>
<FormDescription>
{t('Create multiple redemption codes at once (1-100)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</SideDrawerSection>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
+1 -4
View File
@@ -36,7 +36,7 @@ export const REDEMPTION_STATUS_VALUES = Object.values(REDEMPTION_STATUS).map(
// labelKey values are i18n keys; use t(config.labelKey) in components
export const REDEMPTION_STATUSES: Record<
number,
Pick<StatusBadgeProps, 'variant' | 'showDot'> & {
Pick<StatusBadgeProps, 'variant'> & {
labelKey: string
value: number
}
@@ -45,19 +45,16 @@ export const REDEMPTION_STATUSES: Record<
labelKey: 'Unused',
variant: 'success',
value: REDEMPTION_STATUS.ENABLED,
showDot: true,
},
[REDEMPTION_STATUS.DISABLED]: {
labelKey: 'Disabled',
variant: 'neutral',
value: REDEMPTION_STATUS.DISABLED,
showDot: true,
},
[REDEMPTION_STATUS.USED]: {
labelKey: 'Used',
variant: 'neutral',
value: REDEMPTION_STATUS.USED,
showDot: true,
},
} as const
@@ -45,7 +45,13 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import {
getAdminPlans,
getUserSubscriptions,
@@ -191,15 +197,15 @@ export function UserSubscriptionsDialog(props: Props) {
return (
<>
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
<SheetContent className='overflow-y-auto sm:max-w-2xl'>
<SheetHeader>
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>{t('User Subscription Management')}</SheetTitle>
<SheetDescription>
{props.user?.username || '-'} (ID: {props.user?.id || '-'})
</SheetDescription>
</SheetHeader>
<div className='mt-4 space-y-4'>
<div className={sideDrawerFormClassName()}>
<div className='flex gap-2'>
<Select
items={[
@@ -279,14 +285,16 @@ export function UserSubscriptionsDialog(props: Props) {
return (
<TableRow key={sub.id}>
<TableCell>#{sub.id}</TableCell>
<TableCell>
<TableId value={sub.id} />
</TableCell>
<TableCell>
<div>
<div className='font-medium'>
{planTitleMap.get(sub.plan_id) ||
`#${sub.plan_id}`}
</div>
<div className='text-muted-foreground text-xs'>
<div className='text-muted-foreground text-sm'>
{t('Source')}: {sub.source || '-'}
</div>
</div>
@@ -295,7 +303,7 @@ export function UserSubscriptionsDialog(props: Props) {
<SubscriptionStatusBadge sub={sub} t={t} />
</TableCell>
<TableCell>
<div className='text-xs'>
<div className='text-sm'>
<div>
{t('Start')}: {formatTimestamp(sub.start_time)}
</div>
@@ -23,6 +23,7 @@ import { formatQuota } from '@/lib/format'
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { formatDuration, formatResetPeriod } from '../lib'
import type { PlanRecord } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
@@ -39,9 +40,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
header: ({ column }) => (
<DataTableColumnHeader column={column} title='ID' />
),
cell: ({ row }) => (
<span className='text-muted-foreground'>#{row.original.plan.id}</span>
),
cell: ({ row }) => <TableId value={row.original.plan.id} />,
size: 60,
},
{
@@ -51,6 +51,14 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import {
createPlan,
updatePlan,
@@ -243,8 +251,8 @@ export function SubscriptionsMutateDrawer({
}
}}
>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isEdit ? t('Update plan info') : t('Create new subscription plan')}
</SheetTitle>
@@ -260,10 +268,10 @@ export function SubscriptionsMutateDrawer({
<form
id='subscription-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
className={sideDrawerFormClassName()}
>
{/* Basic Info */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<Settings2 className='h-4 w-4' />
{t('Basic Info')}
@@ -440,24 +448,24 @@ export function SubscriptionsMutateDrawer({
control={form.control}
name='enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center gap-2 pt-8'>
<FormItem className={sideDrawerSwitchItemClassName()}>
<FormLabel className='!mt-0'>
{t('Enabled Status')}
</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className='!mt-0'>
{t('Enabled Status')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
</SideDrawerSection>
{/* Duration Settings */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CalendarClock className='h-4 w-4' />
{t('Duration Settings')}
@@ -544,10 +552,10 @@ export function SubscriptionsMutateDrawer({
/>
)}
</div>
</div>
</SideDrawerSection>
{/* Quota Reset */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<RefreshCw className='h-4 w-4' />
{t('Quota Reset')}
@@ -612,10 +620,10 @@ export function SubscriptionsMutateDrawer({
)}
/>
</div>
</div>
</SideDrawerSection>
{/* Payment Config */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='flex items-center gap-2 text-sm font-medium'>
<CreditCard className='h-4 w-4' />
{t('Third-party Payment Config')}
@@ -709,10 +717,10 @@ export function SubscriptionsMutateDrawer({
)
}}
/>
</div>
</SideDrawerSection>
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
@@ -87,7 +87,7 @@ export function ProviderTable(props: ProviderTableProps) {
{provider.icon ? (
<span className='text-lg'>{provider.icon}</span>
) : (
<span className='text-muted-foreground text-xs'>--</span>
<span className='text-muted-foreground text-sm'>--</span>
)}
</TableCell>
<TableCell className='font-medium'>{provider.name}</TableCell>
@@ -105,7 +105,7 @@ export function ProviderTable(props: ProviderTableProps) {
copyable={false}
/>
</TableCell>
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono text-xs'>
<TableCell className='text-muted-foreground max-w-[120px] truncate font-mono'>
{provider.client_id}
</TableCell>
<TableCell className='text-right'>
@@ -41,7 +41,7 @@ import {
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StatusBadge } from '@/components/status-badge'
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
import { SettingsSwitchField } from '../../components/settings-form-layout'
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
import { SettingsSection } from '../../components/settings-section'
@@ -64,6 +64,24 @@ function parseRules(jsonStr: string): AffinityRule[] {
}
}
function RuleBadgeList(props: { items: string[] }) {
return (
<StatusBadgeList
items={props.items}
max={2}
getKey={(item) => item}
renderItem={(item) => (
<StatusBadge
label={item}
variant='neutral'
size='sm'
copyable={false}
/>
)}
/>
)
}
function serializeRules(rules: AffinityRule[]): string {
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
}
@@ -500,65 +518,15 @@ export function ChannelAffinitySection(props: Props) {
{rule.name || '-'}
</TableCell>
<TableCell>
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
{(rule.model_regex || []).length > 0 && (
<span
className='size-1.5 shrink-0 rounded-full bg-slate-400'
aria-hidden='true'
/>
)}
{(rule.model_regex || [])
.slice(0, 2)
.map((r, i, arr) => (
<span
key={i}
className='flex items-center gap-1.5'
>
{r}
{i < arr.length - 1 && (
<span className='text-muted-foreground/30'>
·
</span>
)}
</span>
))}
{(rule.model_regex || []).length > 2 && (
<span className='text-muted-foreground/50'>
+{(rule.model_regex || []).length - 2}
</span>
)}
</div>
<RuleBadgeList items={rule.model_regex || []} />
</TableCell>
<TableCell>
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
{(rule.key_sources || []).length > 0 && (
<span
className='size-1.5 shrink-0 rounded-full bg-slate-400'
aria-hidden='true'
/>
<RuleBadgeList
items={(rule.key_sources || []).map(
(src) =>
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
)}
{(rule.key_sources || [])
.slice(0, 2)
.map((src, i, arr) => (
<span
key={i}
className='flex items-center gap-1.5'
>
{src.type}:
{src.type === 'gjson' ? src.path : src.key}
{i < arr.length - 1 && (
<span className='text-muted-foreground/30'>
·
</span>
)}
</span>
))}
{(rule.key_sources || []).length > 2 && (
<span className='text-muted-foreground/50'>
+{(rule.key_sources || []).length - 2}
</span>
)}
</div>
/>
</TableCell>
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
<TableCell>
@@ -582,27 +550,7 @@ export function ChannelAffinitySection(props: Props) {
rule.include_rule_name && t('Rule'),
].filter(Boolean) as string[]
if (scopeItems.length === 0) return '-'
return (
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
<span
className='size-1.5 shrink-0 rounded-full bg-slate-400'
aria-hidden='true'
/>
{scopeItems.map((item, idx, arr) => (
<span
key={idx}
className='flex items-center gap-1.5'
>
{item}
{idx < arr.length - 1 && (
<span className='text-muted-foreground/30'>
·
</span>
)}
</span>
))}
</div>
)
return <RuleBadgeList items={scopeItems} />
})()}
</TableCell>
<TableCell>
@@ -166,7 +166,7 @@ export function AmountDiscountVisualEditor({
</span>
</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{discount.discountRate.toFixed(2)}
</code>
</TableCell>
@@ -201,7 +201,7 @@ export function CreemProductsVisualEditor({
{product.name}
</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{product.productId}
</code>
</TableCell>
@@ -311,7 +311,7 @@ export function PaymentMethodsVisualEditor({
{method.name}
</TableCell>
<TableCell>
<code className='bg-muted rounded px-1.5 py-0.5 text-xs'>
<code className='bg-muted rounded px-1.5 py-0.5 text-sm'>
{method.type}
</code>
</TableCell>
@@ -323,7 +323,7 @@ export function PaymentMethodsVisualEditor({
style={{ backgroundColor: colorPreview }}
/>
)}
<span className='text-muted-foreground truncate font-mono text-xs'>
<span className='text-muted-foreground truncate font-mono text-sm'>
{method.color}
</span>
</div>
@@ -91,12 +91,12 @@ export function ConflictConfirmDialog({
{conflict.model}
</TableCell>
<TableCell>
<pre className='text-xs whitespace-pre-wrap'>
<pre className='text-sm whitespace-pre-wrap'>
{conflict.current}
</pre>
</TableCell>
<TableCell>
<pre className='text-xs whitespace-pre-wrap'>
<pre className='text-sm whitespace-pre-wrap'>
{conflict.newVal}
</pre>
</TableCell>
@@ -45,6 +45,11 @@ import {
} from '@/components/ui/sheet'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
sideDrawerContentClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import {
SettingsForm,
SettingsSwitchContent,
@@ -337,8 +342,11 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
<SheetHeader className='border-b p-4'>
<SheetContent
side='right'
className={sideDrawerContentClassName('sm:max-w-2xl')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
<SheetDescription>
{t(
@@ -347,7 +355,7 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
</SheetDescription>
</SheetHeader>
<div className='space-y-5 overflow-y-auto p-4'>
<div className={sideDrawerFormClassName('gap-5')}>
<section className='space-y-2'>
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
@@ -61,6 +61,10 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
} from '@/components/drawer-layout'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
import {
SettingsControlGroup,
@@ -387,7 +391,10 @@ export function ModelPricingSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
<SheetContent
side='right'
className={sideDrawerContentClassName('sm:max-w-2xl')}
>
<SheetHeader className='sr-only'>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
@@ -733,7 +740,7 @@ export function ModelPricingEditorPanel({
return (
<div
className={cn(
'bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border',
'bg-background flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border',
className
)}
>
@@ -948,7 +955,11 @@ export function ModelPricingEditorPanel({
</FieldGroup>
</div>
<SheetFooter className='bg-background/95 border-t sm:flex-row sm:items-center sm:justify-between'>
<SheetFooter
className={sideDrawerFooterClassName(
'grid-cols-1 sm:items-center sm:justify-between'
)}
>
<div className='text-muted-foreground text-xs'>
{selectedTargetCount > 0
? t('{{count}} selected targets available for bulk copy.', {
@@ -21,7 +21,6 @@ import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import { Zap } from 'lucide-react'
import { formatTimestampToDate, formatTokens } from '@/lib/format'
import { cn } from '@/lib/utils'
import {
Tooltip,
TooltipContent,
@@ -97,36 +96,6 @@ export function createTimestampColumn<T>(config: {
}
}
/**
* Duration pill colors matching common logs timing column
*/
const durationPillBg: Record<string, string> = {
green:
'border border-emerald-200/60 bg-emerald-50/70 dark:border-emerald-800/50 dark:bg-emerald-950/25',
red: 'border border-rose-200/70 bg-rose-50/70 dark:border-rose-800/50 dark:bg-rose-950/25',
success:
'border border-emerald-200/60 bg-emerald-50/50 dark:border-emerald-800/50 dark:bg-emerald-950/20',
info: 'border border-sky-200/60 bg-sky-50/50 dark:border-sky-800/50 dark:bg-sky-950/20',
warning:
'border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/50 dark:bg-amber-950/20',
}
const durationTextColor: Record<string, string> = {
green: 'text-emerald-700 dark:text-emerald-400',
red: 'text-rose-700 dark:text-rose-400',
success: 'text-emerald-700 dark:text-emerald-400',
info: 'text-sky-700 dark:text-sky-400',
warning: 'text-amber-700 dark:text-amber-400',
}
const durationDotColor: Record<string, string> = {
green: 'bg-emerald-500',
red: 'bg-rose-500',
success: 'bg-emerald-500',
info: 'bg-sky-500',
warning: 'bg-amber-500',
}
/**
* Create a duration column - pill style matching common logs timing
*/
@@ -163,25 +132,16 @@ export function createDurationColumn<T>(config: {
}
const variant =
duration.durationSec > warningThresholdSec ? 'red' : 'green'
duration.durationSec > warningThresholdSec ? 'danger' : 'success'
return (
<span
className={cn(
'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
durationPillBg[variant],
durationTextColor[variant]
)}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
durationDotColor[variant]
)}
aria-hidden='true'
/>
{duration.durationSec.toFixed(1)}s
</span>
<StatusBadge
label={`${duration.durationSec.toFixed(1)}s`}
variant={variant}
size='sm'
copyable={false}
className='font-mono'
/>
)
},
meta: { label: headerLabel },
@@ -37,6 +37,7 @@ import {
} from '@/components/ui/tooltip'
import { DataTableColumnHeader } from '@/components/data-table'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { LOG_TYPE_ALL_VALUE } from '../../constants'
import type { UsageLog } from '../../data/schema'
import {
formatModelName,
@@ -281,7 +282,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
)
},
filterFn: (row, _id, value) => {
if (!value || value.length === 0) return true
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,
@@ -488,7 +490,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
icon={KeyRound}
copyText={sensitiveVisible ? tokenName : undefined}
size='sm'
showDot={false}
className='border-border/60 bg-muted/30 text-foreground max-w-full overflow-hidden rounded-md border px-1.5 py-0.5 font-mono'
/>
</TooltipTrigger>
@@ -554,59 +555,32 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
const pillBg: Record<string, string> = {
success:
'border border-emerald-200/40 bg-emerald-50/35 dark:border-emerald-900/40 dark:bg-emerald-950/15',
warning:
'border border-amber-200/45 bg-amber-50/35 dark:border-amber-900/40 dark:bg-amber-950/15',
danger:
'border border-rose-200/50 bg-rose-50/35 dark:border-rose-900/40 dark:bg-rose-950/15',
}
const pillText: Record<string, string> = {
success: 'text-emerald-700/85 dark:text-emerald-400/85',
warning: 'text-amber-700/85 dark:text-amber-400/85',
danger: 'text-rose-700/85 dark:text-rose-400/85',
}
const pillDot: Record<string, string> = {
success: 'bg-emerald-500/80',
warning: 'bg-amber-500/80',
danger: 'bg-rose-500/80',
}
return (
<div className='flex flex-col gap-1'>
<div className='flex items-center gap-1.5'>
<span
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
pillBg[timeVariant],
pillText[timeVariant]
)}
>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
pillDot[timeVariant]
)}
aria-hidden='true'
/>
{formatUseTime(useTime)}
</span>
<StatusBadge
label={formatUseTime(useTime)}
variant={timeVariant as StatusBadgeProps['variant']}
size='sm'
copyable={false}
className='font-mono'
/>
{log.is_stream &&
(frt != null && frt > 0 ? (
<span
className={cn(
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
pillBg[frtVariant!],
pillText[frtVariant!]
)}
>
{formatUseTime(frt / 1000)}
</span>
<StatusBadge
label={formatUseTime(frt / 1000)}
variant={frtVariant as StatusBadgeProps['variant']}
size='sm'
copyable={false}
className='font-mono'
/>
) : (
<span className='border-border/60 text-muted-foreground/50 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[11px]'>
N/A
</span>
<StatusBadge
label='N/A'
variant='neutral'
size='sm'
copyable={false}
/>
))}
</div>
<div className='flex items-center gap-1 text-[11px]'>
@@ -724,15 +698,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
<Tooltip>
<TooltipTrigger
render={
<span className='inline-flex items-center gap-1 rounded-md border border-emerald-200 bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-300' />
<StatusBadge
label={t('Subscription')}
variant='success'
size='sm'
copyable={false}
className='cursor-help'
/>
}
>
<span
className='size-1.5 rounded-full bg-emerald-500'
aria-hidden='true'
/>
{t('Subscription')}
</TooltipTrigger>
/>
<TooltipContent>
<span>
{t('Deducted by subscription')}: {formatLogQuota(quota)}
@@ -132,7 +132,6 @@ export function useDrawingLogsColumns(
icon={getDrawingTypeIcon(action)}
size='sm'
copyable={false}
showDot={false}
/>
)
},
@@ -157,7 +156,6 @@ export function useDrawingLogsColumns(
label={mjId}
autoColor={mjId}
size='sm'
showDot={false}
className='border-border/60 bg-muted/30 max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
/>
</div>
@@ -189,7 +187,6 @@ export function useDrawingLogsColumns(
variant={mjSubmitResultMapper.getVariant(String(code))}
size='sm'
copyable={false}
showDot
/>
)
},
@@ -183,7 +183,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
label={taskId}
autoColor={taskId}
size='sm'
showDot={false}
className='border-border/60 bg-muted/30 max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
/>
<span className='text-muted-foreground/60 truncate text-[11px]'>
@@ -214,7 +213,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
variant={taskStatusMapper.getVariant(status)}
size='sm'
copyable={false}
showDot
/>
)
},
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useQueryClient, useIsFetching } from '@tanstack/react-query'
import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
@@ -24,7 +24,6 @@ import { Eye, EyeOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
@@ -38,13 +37,17 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableToolbar } from '@/components/data-table'
import { LOG_TYPES } from '../constants'
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 { useUsageLogsContext } from './usage-logs-provider'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -75,30 +78,32 @@ export function CommonLogsFilterBar<TData>(
const { start, end } = getDefaultTimeRange()
return { startTime: start, endTime: end }
})
const [logType, setLogType] = useState<LogTypeValue | ''>('')
const [logType, setLogType] = useState<LogTypeValue>(LOG_TYPE_ALL_VALUE)
useEffect(() => {
const next: Partial<CommonLogFilters> = {}
if (searchParams.startTime)
next.startTime = new Date(searchParams.startTime)
if (searchParams.endTime) next.endTime = new Date(searchParams.endTime)
if (searchParams.channel) next.channel = String(searchParams.channel)
if (searchParams.model) next.model = searchParams.model
if (searchParams.token) next.token = searchParams.token
if (searchParams.group) next.group = searchParams.group
if (searchParams.username) next.username = searchParams.username
if (searchParams.requestId) next.requestId = searchParams.requestId
if (searchParams.upstreamRequestId)
next.upstreamRequestId = searchParams.upstreamRequestId
if (Object.keys(next).length > 0) {
setFilters((prev) => ({ ...prev, ...next }))
}
const { start, end } = getDefaultTimeRange()
setFilters({
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,
})
const typeArr = searchParams.type
if (Array.isArray(typeArr) && typeArr.length === 1) {
setLogType(typeArr[0])
}
const nextLogType =
Array.isArray(typeArr) &&
typeArr.length === 1 &&
isLogTypeValue(typeArr[0])
? typeArr[0]
: LOG_TYPE_ALL_VALUE
setLogType(nextLogType)
}, [
searchParams.startTime,
searchParams.endTime,
@@ -126,7 +131,7 @@ export function CommonLogsFilterBar<TData>(
params: { section: 'common' },
search: {
...filterParams,
...(logType ? { type: [logType] } : {}),
type: [logType],
page: 1,
},
})
@@ -138,13 +143,14 @@ export function CommonLogsFilterBar<TData>(
const { start, end } = getDefaultTimeRange()
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
setFilters(resetFilters)
setLogType('')
setLogType(LOG_TYPE_ALL_VALUE)
navigate({
to: '/usage-logs/$section',
params: { section: 'common' },
search: {
page: 1,
type: [LOG_TYPE_ALL_VALUE],
startTime: start.getTime(),
endTime: end.getTime(),
},
@@ -167,11 +173,28 @@ export function CommonLogsFilterBar<TData>(
!!filters.requestId ||
!!filters.upstreamRequestId
const hasTypeFilter = logType !== LOG_TYPE_ALL_VALUE
const hasAdditionalFilters =
!!filters.model || !!filters.group || !!logType || hasExpandedFilters
!!filters.model || !!filters.group || hasTypeFilter || hasExpandedFilters
const inputClass = 'w-full sm:w-[140px] lg:w-[160px]'
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'>
@@ -197,114 +220,145 @@ export function CommonLogsFilterBar<TData>(
</div>
)
return (
<DataTableToolbar
table={props.table}
leftActions={statsBar}
customSearch={
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
className='w-full sm:w-[340px]'
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) => {
setLogType(
value !== null && isLogTypeValue(value) ? value : LOG_TYPE_ALL_VALUE
)
}}
>
<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}
/>
}
additionalSearch={
<>
<Input
placeholder={t('Model Name')}
value={filters.model || ''}
onChange={(e) => handleChange('model', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
<Input
placeholder={t('Group')}
</LogsFilterField>
{isAdmin && (
<LogsFilterField>
<LogsFilterInput
placeholder={t('Username')}
type={sensitiveType}
value={filters.group || ''}
onChange={(e) => handleChange('group', e.target.value)}
value={filters.username || ''}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
<Select
items={[
{ value: 'all', label: t('All Types') },
...LOG_TYPES.map((type) => ({
value: String(type.value),
label: t(type.label),
})),
]}
value={logType}
onValueChange={(value) => {
setLogType(value !== null && isLogTypeValue(value) ? value : '')
}}
>
<SelectTrigger className={inputClass}>
<SelectValue placeholder={t('All Types')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='all'>{t('All Types')}</SelectItem>
{LOG_TYPES.map((type) => (
<SelectItem key={type.value} value={String(type.value)}>
{t(type.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</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}
primaryFilters={
<>
{dateRangeFilter}
{modelFilter}
{groupFilter}
{typeFilter}
</>
}
expandable={
advancedFilters={advancedFilters}
mobilePinnedFilters={dateRangeFilter}
mobileFilters={
<>
<Input
placeholder={t('Token Name')}
type={sensitiveType}
value={filters.token || ''}
onChange={(e) => handleChange('token', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
{isAdmin && (
<Input
placeholder={t('Username')}
type={sensitiveType}
value={filters.username || ''}
onChange={(e) => handleChange('username', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
)}
{isAdmin && (
<Input
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
)}
<Input
placeholder={t('Request ID')}
value={filters.requestId || ''}
onChange={(e) => handleChange('requestId', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
<Input
placeholder={t('Upstream Request ID')}
value={filters.upstreamRequestId || ''}
onChange={(e) => handleChange('upstreamRequestId', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
{modelFilter}
{groupFilter}
{typeFilter}
{advancedFilters}
</>
}
hasExpandedActiveFilters={hasExpandedFilters}
hasAdditionalFilters={hasAdditionalFilters}
mobileFilterCount={
[filters.model, filters.group, hasTypeFilter].filter(Boolean).length +
expandedFilterCount
}
hasAdvancedActiveFilters={hasExpandedFilters}
advancedFilterCount={expandedFilterCount}
hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply}
searchLoading={fetchingLogs > 0}
onReset={handleReset}
@@ -123,7 +123,7 @@ export function CompactDateTimeRangePicker({
type='button'
variant='outline'
className={cn(
'w-full justify-start gap-2 px-2.5 font-mono text-xs font-normal',
'w-full justify-start gap-2 px-2.5 text-sm leading-5 font-normal tabular-nums',
!start && !end && 'text-muted-foreground',
className
)}
@@ -147,7 +147,7 @@ export function CompactDateTimeRangePicker({
type='datetime-local'
value={draftStart}
onChange={(e) => setDraftStart(e.target.value)}
className='h-8 font-mono text-xs'
className='h-8 text-sm leading-5 tabular-nums'
/>
</div>
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
@@ -161,7 +161,7 @@ export function CompactDateTimeRangePicker({
type='datetime-local'
value={draftEnd}
onChange={(e) => setDraftEnd(e.target.value)}
className='h-8 font-mono text-xs'
className='h-8 text-sm leading-5 tabular-nums'
/>
</div>
</div>
@@ -0,0 +1,252 @@
/*
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, type ComponentProps, type ReactNode } from 'react'
import { type Table } from '@tanstack/react-table'
import { useMediaQuery } from '@/hooks'
import { ChevronDown, Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
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 { DataTableViewOptions } from '@/components/data-table'
interface LogsFilterToolbarProps<TData> {
table: Table<TData>
primaryFilters: ReactNode
advancedFilters?: ReactNode
mobilePinnedFilters?: ReactNode
mobileFilters?: ReactNode
mobileFilterCount?: number
stats?: 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 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)
}
if (isMobile && props.mobilePinnedFilters != null) {
return (
<Drawer open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
<div
className={cn('bg-card/50 rounded-lg border p-2.5', props.className)}
>
<div className='grid gap-2'>{props.mobilePinnedFilters}</div>
<div className='mt-2 flex flex-col gap-2'>
{props.stats}
<div className='flex items-center justify-end gap-1.5'>
<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='grid grid-cols-1 gap-2 sm:grid-cols-[repeat(auto-fit,minmax(10rem,1fr))]'>
{props.primaryFilters}
{advancedOpen && 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'>
{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>
)}
<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>
)
}
@@ -22,12 +22,15 @@ import { useNavigate, getRouteApi } from '@tanstack/react-router'
import { type Table } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import { useIsAdmin } from '@/hooks/use-admin'
import { Input } from '@/components/ui/input'
import { DataTableToolbar } from '@/components/data-table'
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'
const route = getRouteApi('/_authenticated/usage-logs/$section')
@@ -160,45 +163,60 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
props.logCategory === 'drawing'
? t('Filter by Midjourney task ID')
: t('Filter by task ID')
const inputClass = 'w-full sm:w-[180px] lg:w-[200px]'
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 (
<DataTableToolbar
<LogsFilterToolbar
table={props.table}
customSearch={
<CompactDateTimeRangePicker
start={filters.startTime}
end={filters.endTime}
onChange={({ start, end }) => {
handleChange('startTime', start)
handleChange('endTime', end)
}}
className='w-full sm:w-[340px]'
/>
}
additionalSearch={
primaryFilters={
<>
<Input
aria-label={t('Task ID')}
placeholder={placeholder}
value={filterValue}
onChange={(e) => handleFilterChange(e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
{isAdmin && (
<Input
placeholder={t('Channel ID')}
value={filters.channel || ''}
onChange={(e) => handleChange('channel', e.target.value)}
onKeyDown={handleKeyDown}
className={inputClass}
/>
)}
{dateRangeFilter}
{taskIdFilter}
{channelFilter}
</>
}
hasAdditionalFilters={hasAdditionalFilters}
mobilePinnedFilters={dateRangeFilter}
mobileFilters={
<>
{taskIdFilter}
{channelFilter}
</>
}
mobileFilterCount={[filterValue, filters.channel].filter(Boolean).length}
hasActiveFilters={hasAdditionalFilters}
onSearch={handleApply}
searchLoading={fetchingLogs > 0}
onReset={handleReset}
@@ -37,7 +37,11 @@ import { useIsAdmin } from '@/hooks/use-admin'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { TableCell, TableRow } from '@/components/ui/table'
import { DataTablePage } from '@/components/data-table'
import { DEFAULT_LOGS_DATA, LOG_TYPE_ENUM } from '../constants'
import {
DEFAULT_LOGS_DATA,
LOG_TYPE_ALL_VALUE,
LOG_TYPE_ENUM,
} from '../constants'
import { useColumnsByCategory } from '../lib/columns'
import { fetchLogsByCategory } from '../lib/utils'
import type { LogCategory } from '../types'
@@ -51,6 +55,11 @@ const logTypeRowTint: Record<number, string> = {
[LOG_TYPE_ENUM.REFUND]: 'bg-blue-50/30 dark:bg-blue-950/15',
}
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
}
@@ -73,7 +82,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
globalFilter: { enabled: false },
columnFilters: [
{ columnId: 'created_at', searchKey: 'type', type: 'array' as const },
{
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 },
+17 -4
View File
@@ -60,6 +60,12 @@ export const LOG_TYPE_ENUM = {
REFUND: 6,
} 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
// ============================================================================
@@ -93,11 +99,18 @@ export const LOG_TYPES = [
/**
* 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 = LOG_TYPES.map((type) => ({
label: type.label,
value: String(type.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 (Midjourney) Constants
+10 -2
View File
@@ -180,9 +180,17 @@ export function buildApiParams(config: {
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
// Helper to process type parameter (single value from array)
const processType = (value: unknown) => {
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 Number(value[0])
return parseType(value[0])
}
if (typeof value === 'string' && value !== '') {
return parseType(value)
}
return undefined
}
+46 -41
View File
@@ -30,7 +30,8 @@ import {
import { DataTableColumnHeader } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { LongText } from '@/components/long-text'
import { StatusBadge, dotColorMap } from '@/components/status-badge'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { USER_STATUSES, USER_ROLES, isUserDeleted } from '../constants'
import { type User } from '../types'
import { DataTableRowActions } from './data-table-row-actions'
@@ -73,7 +74,9 @@ export function useUsersColumns(): ColumnDef<User>[] {
<DataTableColumnHeader column={column} title='ID' />
),
cell: ({ row }) => {
return <div className='w-[60px]'>{row.getValue('id')}</div>
return (
<TableId value={row.getValue('id') as number} className='w-[60px]' />
)
},
meta: { label: t('ID'), mobileHidden: true },
},
@@ -140,7 +143,6 @@ export function useUsersColumns(): ColumnDef<User>[] {
<StatusBadge
label={t(statusConfig.labelKey)}
variant={statusConfig.variant}
showDot={statusConfig.showDot}
copyable={false}
/>
</TooltipTrigger>
@@ -276,59 +278,62 @@ export function useUsersColumns(): ColumnDef<User>[] {
const inviterId = user.inviter_id || 0
return (
<div className='flex items-center gap-1.5 text-xs font-medium'>
<span
className={cn(
'size-1.5 shrink-0 rounded-full',
dotColorMap.neutral
)}
aria-hidden='true'
/>
<div className='flex items-center gap-1'>
<Tooltip>
<TooltipTrigger
render={<span className='text-muted-foreground cursor-help' />}
>
{t('Invited')}: {affCount}
</TooltipTrigger>
render={
<StatusBadge
label={`${t('Invited')}: ${affCount}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Number of users invited')}</p>
</TooltipContent>
</Tooltip>
<span className='text-muted-foreground/30'>·</span>
<Tooltip>
<TooltipTrigger
render={<span className='text-muted-foreground cursor-help' />}
>
{t('Revenue')}: {formatQuota(affHistoryQuota)}
</TooltipTrigger>
render={
<StatusBadge
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>{t('Total invitation revenue')}</p>
</TooltipContent>
</Tooltip>
{inviterId > 0 && (
<>
<span className='text-muted-foreground/30'>·</span>
<Tooltip>
<TooltipTrigger
render={
<span className='text-muted-foreground cursor-help' />
}
>
{t('Inviter')}: {inviterId}
</TooltipTrigger>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
</>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={`${t('Inviter')}: ${inviterId}`}
variant='neutral'
copyable={false}
className='cursor-help'
/>
}
/>
<TooltipContent>
<p className='text-xs'>
{t('Invited by user ID')} {inviterId}
</p>
</TooltipContent>
</Tooltip>
)}
{inviterId === 0 && (
<>
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground'>{t('No Inviter')}</span>
</>
<StatusBadge
label={t('No Inviter')}
variant='neutral'
copyable={false}
/>
)}
</div>
)
@@ -55,6 +55,13 @@ import {
SheetTitle,
} from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import {
SideDrawerSection,
sideDrawerContentClassName,
sideDrawerFooterClassName,
sideDrawerFormClassName,
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { createUser, updateUser, getUser, getGroups } from '../api'
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
import {
@@ -182,8 +189,10 @@ export function UsersMutateDrawer({
}
}}
>
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'>
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
<SheetContent
className={sideDrawerContentClassName('sm:max-w-[600px]')}
>
<SheetHeader className={sideDrawerHeaderClassName()}>
<SheetTitle>
{isUpdate ? t('Update') : t('Create')} {t('User')}
</SheetTitle>
@@ -197,10 +206,10 @@ export function UsersMutateDrawer({
<form
id='user-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-6 sm:px-4'
className={sideDrawerFormClassName()}
>
{/* Basic Information */}
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Basic Information')}
</h3>
@@ -304,11 +313,11 @@ export function UsersMutateDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
{/* Group & Quota Settings (Update only) */}
{isUpdate && (
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
<FormField
@@ -405,12 +414,12 @@ export function UsersMutateDrawer({
</FormItem>
)}
/>
</div>
</SideDrawerSection>
)}
{/* Binding Information (Read-only) */}
{isUpdate && (
<div className='space-y-4'>
<SideDrawerSection>
<h3 className='text-sm font-medium'>
{t('Binding Information')}
</h3>
@@ -420,7 +429,7 @@ export function UsersMutateDrawer({
)}
</p>
<div className='space-y-3'>
<div className='flex flex-col gap-3'>
{BINDING_FIELDS.map(({ key, label }) => (
<div key={key}>
<Label className='text-muted-foreground text-xs'>
@@ -436,11 +445,11 @@ export function UsersMutateDrawer({
</div>
))}
</div>
</div>
</SideDrawerSection>
)}
</form>
</Form>
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
<SheetFooter className={sideDrawerFooterClassName()}>
<SheetClose render={<Button variant='outline' />}>
{t('Close')}
</SheetClose>
-3
View File
@@ -41,19 +41,16 @@ export const USER_STATUSES = {
labelKey: 'Enabled',
variant: 'success' as const,
value: USER_STATUS.ENABLED,
showDot: true,
},
[USER_STATUS.DISABLED]: {
labelKey: 'Disabled',
variant: 'neutral' as const,
value: USER_STATUS.DISABLED,
showDot: true,
},
DELETED: {
labelKey: 'Deleted',
variant: 'danger' as const,
value: -1,
showDot: false,
},
} as const