✨ 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:
parent
b302be30e3
commit
583da45296
16
web/default/src/components/config-drawer.tsx
vendored
16
web/default/src/components/config-drawer.tsx
vendored
@ -53,6 +53,12 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { useSidebar } from './ui/sidebar'
|
import { useSidebar } from './ui/sidebar'
|
||||||
|
|
||||||
const Item = RadioPrimitive.Root
|
const Item = RadioPrimitive.Root
|
||||||
@ -88,14 +94,14 @@ export function ConfigDrawer() {
|
|||||||
>
|
>
|
||||||
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
<Palette className='size-[1.2rem]' aria-hidden='true' />
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className='flex w-full flex-col sm:max-w-md'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-md')}>
|
||||||
<SheetHeader className='pb-0 text-start'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>{t('Theme Settings')}</SheetTitle>
|
<SheetTitle>{t('Theme Settings')}</SheetTitle>
|
||||||
<SheetDescription id='config-drawer-description'>
|
<SheetDescription id='config-drawer-description'>
|
||||||
{t('Adjust the appearance and layout to suit your preferences.')}
|
{t('Adjust the appearance and layout to suit your preferences.')}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className='space-y-6 overflow-y-auto px-4'>
|
<div className={sideDrawerFormClassName()}>
|
||||||
<ThemeConfig />
|
<ThemeConfig />
|
||||||
<PresetConfig />
|
<PresetConfig />
|
||||||
<RadiusConfig />
|
<RadiusConfig />
|
||||||
@ -105,7 +111,7 @@ export function ConfigDrawer() {
|
|||||||
<ContentLayoutConfig />
|
<ContentLayoutConfig />
|
||||||
<DirConfig />
|
<DirConfig />
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter className='gap-2'>
|
<SheetFooter className={sideDrawerFooterClassName('grid-cols-1')}>
|
||||||
<Button
|
<Button
|
||||||
variant='destructive'
|
variant='destructive'
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
@ -302,7 +308,7 @@ const RADIUS_OPTIONS: {
|
|||||||
// CSS border-radius value used to render the visual preview corner.
|
// CSS border-radius value used to render the visual preview corner.
|
||||||
preview: string
|
preview: string
|
||||||
}[] = [
|
}[] = [
|
||||||
{ value: 'default', label: 'Auto', preview: '999px' },
|
{ value: 'default', label: 'Auto', preview: '1rem' },
|
||||||
{ value: 'none', label: '0', preview: '0' },
|
{ value: 'none', label: '0', preview: '0' },
|
||||||
{ value: 'sm', label: '0.3', preview: '0.3rem' },
|
{ value: 'sm', label: '0.3', preview: '0.3rem' },
|
||||||
{ value: 'md', label: '0.5', preview: '0.5rem' },
|
{ value: 'md', label: '0.5', preview: '0.5rem' },
|
||||||
|
|||||||
105
web/default/src/components/drawer-layout.ts
vendored
Normal file
105
web/default/src/components/drawer-layout.ts
vendored
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
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 { createElement, type ReactNode } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export const sideDrawerContentClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'bg-background text-foreground flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 shadow-none',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sideDrawerHeaderClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'border-border/70 bg-background/95 border-b px-4 py-3 text-start backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:px-6 sm:py-4',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sideDrawerFormClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-5',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sideDrawerFooterClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'border-border/70 bg-background/95 grid grid-cols-2 gap-2 border-t px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:flex sm:flex-row sm:justify-end sm:px-6 sm:py-4',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sideDrawerSectionClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'border-border/60 flex flex-col gap-4 border-b pb-6 last:border-b-0 last:pb-0',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export const sideDrawerSwitchItemClassName = (className?: string) =>
|
||||||
|
cn(
|
||||||
|
'border-border/60 flex min-h-16 flex-row items-center justify-between gap-3 border-y py-3',
|
||||||
|
className
|
||||||
|
)
|
||||||
|
|
||||||
|
export function SideDrawerSection(props: {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return createElement(
|
||||||
|
'section',
|
||||||
|
{ className: sideDrawerSectionClassName(props.className) },
|
||||||
|
props.children
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SideDrawerSectionHeader(props: {
|
||||||
|
title: ReactNode
|
||||||
|
description?: ReactNode
|
||||||
|
icon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return createElement(
|
||||||
|
'div',
|
||||||
|
{ className: cn('flex items-start gap-3', props.className) },
|
||||||
|
props.icon
|
||||||
|
? createElement(
|
||||||
|
'span',
|
||||||
|
{
|
||||||
|
className:
|
||||||
|
'bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md',
|
||||||
|
},
|
||||||
|
props.icon
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
createElement(
|
||||||
|
'div',
|
||||||
|
{ className: 'min-w-0 flex-1' },
|
||||||
|
createElement(
|
||||||
|
'h3',
|
||||||
|
{ className: 'text-sm leading-none font-semibold tracking-tight' },
|
||||||
|
props.title
|
||||||
|
),
|
||||||
|
props.description
|
||||||
|
? createElement(
|
||||||
|
'p',
|
||||||
|
{ className: 'text-muted-foreground mt-1 text-xs leading-5' },
|
||||||
|
props.description
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
9
web/default/src/components/group-badge.tsx
vendored
9
web/default/src/components/group-badge.tsx
vendored
@ -31,12 +31,12 @@ type GroupBadgeProps = Omit<
|
|||||||
|
|
||||||
function getGroupRatioClassName(ratio: number): string {
|
function getGroupRatioClassName(ratio: number): string {
|
||||||
if (ratio > 1) {
|
if (ratio > 1) {
|
||||||
return 'border-warning/25 bg-warning/10 text-warning'
|
return 'bg-warning/10 text-warning'
|
||||||
}
|
}
|
||||||
if (ratio < 1) {
|
if (ratio < 1) {
|
||||||
return 'border-info/25 bg-info/10 text-info'
|
return 'bg-info/10 text-info'
|
||||||
}
|
}
|
||||||
return 'border-border bg-muted text-muted-foreground'
|
return 'bg-muted text-muted-foreground'
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGroupLabel(params: {
|
function getGroupLabel(params: {
|
||||||
@ -94,11 +94,10 @@ export function GroupBadge(props: GroupBadgeProps) {
|
|||||||
{badge}
|
{badge}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
|
'inline-flex h-6 items-center rounded-full px-2 font-mono text-sm leading-none font-medium tabular-nums',
|
||||||
getGroupRatioClassName(ratio)
|
getGroupRatioClassName(ratio)
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className='size-1 rounded-full bg-current opacity-60' />
|
|
||||||
<span>{ratio}x</span>
|
<span>{ratio}x</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -20,8 +20,7 @@ import { useNotifications } from '@/hooks/use-notifications'
|
|||||||
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
import { useTopNavLinks } from '@/hooks/use-top-nav-links'
|
||||||
import { ConfigDrawer } from '@/components/config-drawer'
|
import { ConfigDrawer } from '@/components/config-drawer'
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
import { NotificationButton } from '@/components/notification-button'
|
import { NotificationPopover } from '@/components/notification-popover'
|
||||||
import { NotificationDialog } from '@/components/notification-dialog'
|
|
||||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||||
import { Search } from '@/components/search'
|
import { Search } from '@/components/search'
|
||||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||||
@ -128,9 +127,15 @@ export function AppHeader({
|
|||||||
)}
|
)}
|
||||||
{showSearch && <Search />}
|
{showSearch && <Search />}
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<NotificationButton
|
<NotificationPopover
|
||||||
|
open={notifications.popoverOpen}
|
||||||
|
onOpenChange={notifications.setPopoverOpen}
|
||||||
unreadCount={notifications.unreadCount}
|
unreadCount={notifications.unreadCount}
|
||||||
onClick={() => notifications.openDialog()}
|
activeTab={notifications.activeTab}
|
||||||
|
onTabChange={notifications.setActiveTab}
|
||||||
|
notice={notifications.notice}
|
||||||
|
announcements={notifications.announcements}
|
||||||
|
loading={notifications.loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@ -139,20 +144,6 @@ export function AppHeader({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
{/* Notification Dialog */}
|
|
||||||
{showNotifications && (
|
|
||||||
<NotificationDialog
|
|
||||||
open={notifications.dialogOpen}
|
|
||||||
onOpenChange={notifications.setDialogOpen}
|
|
||||||
activeTab={notifications.activeTab}
|
|
||||||
onTabChange={notifications.setActiveTab}
|
|
||||||
notice={notifications.notice}
|
|
||||||
announcements={notifications.announcements}
|
|
||||||
loading={notifications.loading}
|
|
||||||
onCloseToday={notifications.closeToday}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,7 @@ import {
|
|||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||||
import { NotificationButton } from '@/components/notification-button'
|
import { NotificationPopover } from '@/components/notification-popover'
|
||||||
import { NotificationDialog } from '@/components/notification-dialog'
|
|
||||||
import { ProfileDropdown } from '@/components/profile-dropdown'
|
import { ProfileDropdown } from '@/components/profile-dropdown'
|
||||||
import { ThemeSwitch } from '@/components/theme-switch'
|
import { ThemeSwitch } from '@/components/theme-switch'
|
||||||
import { defaultTopNavLinks } from '../config/top-nav.config'
|
import { defaultTopNavLinks } from '../config/top-nav.config'
|
||||||
@ -271,9 +270,15 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
{showLanguageSwitcher && <LanguageSwitcher />}
|
{showLanguageSwitcher && <LanguageSwitcher />}
|
||||||
{showThemeSwitch && <ThemeSwitch />}
|
{showThemeSwitch && <ThemeSwitch />}
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<NotificationButton
|
<NotificationPopover
|
||||||
|
open={notifications.popoverOpen}
|
||||||
|
onOpenChange={notifications.setPopoverOpen}
|
||||||
unreadCount={notifications.unreadCount}
|
unreadCount={notifications.unreadCount}
|
||||||
onClick={() => notifications.openDialog()}
|
activeTab={notifications.activeTab}
|
||||||
|
onTabChange={notifications.setActiveTab}
|
||||||
|
notice={notifications.notice}
|
||||||
|
announcements={notifications.announcements}
|
||||||
|
loading={notifications.loading}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -445,20 +450,6 @@ export function PublicHeader(props: PublicHeaderProps) {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Notification Dialog */}
|
|
||||||
{showNotifications && (
|
|
||||||
<NotificationDialog
|
|
||||||
open={notifications.dialogOpen}
|
|
||||||
onOpenChange={notifications.setDialogOpen}
|
|
||||||
activeTab={notifications.activeTab}
|
|
||||||
onTabChange={notifications.setActiveTab}
|
|
||||||
notice={notifications.notice}
|
|
||||||
announcements={notifications.announcements}
|
|
||||||
loading={notifications.loading}
|
|
||||||
onCloseToday={notifications.closeToday}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
/*
|
|
||||||
Copyright (C) 2023-2026 QuantumNous
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as
|
|
||||||
published by the Free Software Foundation, either version 3 of the
|
|
||||||
License, or (at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
For commercial licensing, please contact support@quantumnous.com
|
|
||||||
*/
|
|
||||||
import { Bell } 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'
|
|
||||||
|
|
||||||
interface NotificationButtonProps {
|
|
||||||
unreadCount: number
|
|
||||||
onClick: () => void
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notification bell button with unread badge
|
|
||||||
* Displays in the app header next to theme switch and profile dropdown
|
|
||||||
*/
|
|
||||||
export function NotificationButton({
|
|
||||||
unreadCount,
|
|
||||||
onClick,
|
|
||||||
className,
|
|
||||||
}: NotificationButtonProps) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
|
||||||
<div className='relative'>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='icon'
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn('h-9 w-9', className)}
|
|
||||||
aria-label={t('Notifications')}
|
|
||||||
>
|
|
||||||
<Bell className='size-[1.2rem]' />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant='destructive'
|
|
||||||
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-semibold tabular-nums'
|
|
||||||
>
|
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -22,15 +22,23 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { getAnnouncementColorClass } from '@/lib/colors'
|
import { getAnnouncementColorClass } from '@/lib/colors'
|
||||||
import { formatDateTimeObject } from '@/lib/time'
|
import { formatDateTimeObject } from '@/lib/time'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Empty,
|
||||||
DialogContent,
|
EmptyDescription,
|
||||||
DialogFooter,
|
EmptyHeader,
|
||||||
DialogHeader,
|
EmptyMedia,
|
||||||
DialogTitle,
|
EmptyTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/empty'
|
||||||
import { Markdown } from '@/components/ui/markdown'
|
import { Markdown } from '@/components/ui/markdown'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
@ -42,15 +50,16 @@ interface AnnouncementItem {
|
|||||||
publishDate?: string | Date
|
publishDate?: string | Date
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationDialogProps {
|
interface NotificationPopoverProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
|
unreadCount: number
|
||||||
activeTab: 'notice' | 'announcements'
|
activeTab: 'notice' | 'announcements'
|
||||||
onTabChange: (tab: 'notice' | 'announcements') => void
|
onTabChange: (tab: 'notice' | 'announcements') => void
|
||||||
notice: string
|
notice: string
|
||||||
announcements: AnnouncementItem[]
|
announcements: AnnouncementItem[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
onCloseToday: () => void
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -113,7 +122,7 @@ function AnnouncementDot({ type }: { type?: string }) {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
|
'mt-1.5 inline-block size-2 shrink-0 rounded-full',
|
||||||
getAnnouncementColorClass(type)
|
getAnnouncementColorClass(type)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
@ -123,11 +132,25 @@ function AnnouncementDot({ type }: { type?: string }) {
|
|||||||
/**
|
/**
|
||||||
* Empty state component
|
* Empty state component
|
||||||
*/
|
*/
|
||||||
function EmptyState({ message }: { message: string }) {
|
function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col items-center justify-center py-12 text-center'>
|
<Empty className='min-h-48 border-0 p-4'>
|
||||||
<p className='text-muted-foreground text-sm'>{message}</p>
|
<EmptyHeader>
|
||||||
</div>
|
<EmptyMedia variant='icon'>{icon}</EmptyMedia>
|
||||||
|
<EmptyTitle>{title}</EmptyTitle>
|
||||||
|
{description ? (
|
||||||
|
<EmptyDescription>{description}</EmptyDescription>
|
||||||
|
) : null}
|
||||||
|
</EmptyHeader>
|
||||||
|
</Empty>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,15 +167,23 @@ function NoticeContent({
|
|||||||
t: TFunction
|
t: TFunction
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <EmptyState message={t('Loading...')} />
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Bell />}
|
||||||
|
title={t('Loading...')}
|
||||||
|
description={t('Latest platform updates and notices')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!notice) {
|
if (!notice) {
|
||||||
return <EmptyState message={t('No announcements at this time')} />
|
return (
|
||||||
|
<EmptyState icon={<Bell />} title={t('No announcements at this time')} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className='h-[50vh] pr-4'>
|
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||||
<Markdown>{notice}</Markdown>
|
<Markdown>{notice}</Markdown>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
@ -171,16 +202,24 @@ function AnnouncementsContent({
|
|||||||
t: TFunction
|
t: TFunction
|
||||||
}) {
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <EmptyState message={t('Loading...')} />
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Megaphone />}
|
||||||
|
title={t('Loading...')}
|
||||||
|
description={t('Latest platform updates and notices')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (announcements.length === 0) {
|
if (announcements.length === 0) {
|
||||||
return <EmptyState message={t('No system announcements')} />
|
return (
|
||||||
|
<EmptyState icon={<Megaphone />} title={t('No system announcements')} />
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea className='h-[50vh] pr-4'>
|
<ScrollArea className='h-[min(52vh,28rem)] pr-3'>
|
||||||
<div className='space-y-0'>
|
<div className='flex flex-col'>
|
||||||
{announcements.map((item, idx) => {
|
{announcements.map((item, idx) => {
|
||||||
const publishDate = item.publishDate
|
const publishDate = item.publishDate
|
||||||
? new Date(item.publishDate)
|
? new Date(item.publishDate)
|
||||||
@ -197,30 +236,27 @@ function AnnouncementsContent({
|
|||||||
<div className='py-3'>
|
<div className='py-3'>
|
||||||
<div className='flex items-start gap-3'>
|
<div className='flex items-start gap-3'>
|
||||||
<AnnouncementDot type={item.type} />
|
<AnnouncementDot type={item.type} />
|
||||||
<div className='min-w-0 flex-1 space-y-2'>
|
<div className='flex min-w-0 flex-1 flex-col gap-2'>
|
||||||
{/* Content */}
|
|
||||||
<div className='text-sm'>
|
<div className='text-sm'>
|
||||||
<Markdown>{item.content || ''}</Markdown>
|
<Markdown>{item.content || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Extra info */}
|
{item.extra ? (
|
||||||
{item.extra && (
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
<Markdown>{item.extra}</Markdown>
|
<Markdown>{item.extra}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{/* Time */}
|
{absoluteTime ? (
|
||||||
{absoluteTime && (
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
{relativeTime && `${relativeTime} • `}
|
{relativeTime ? `${relativeTime} • ` : null}
|
||||||
{absoluteTime}
|
{absoluteTime}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{idx < announcements.length - 1 && <Separator />}
|
{idx < announcements.length - 1 ? <Separator /> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -230,25 +266,54 @@ function AnnouncementsContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notification dialog with Notice and Announcements tabs
|
* Notification popover with Notice and Announcements tabs
|
||||||
*/
|
*/
|
||||||
export function NotificationDialog({
|
export function NotificationPopover({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
unreadCount,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
notice,
|
notice,
|
||||||
announcements,
|
announcements,
|
||||||
loading,
|
loading,
|
||||||
onCloseToday,
|
className,
|
||||||
}: NotificationDialogProps) {
|
}: NotificationPopoverProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl'>
|
<PopoverTrigger
|
||||||
<DialogHeader>
|
render={
|
||||||
<DialogTitle>{t('System Announcements')}</DialogTitle>
|
<Button
|
||||||
</DialogHeader>
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className={cn('relative size-9', className)}
|
||||||
|
aria-label={t('Notifications')}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Bell className='size-[1.2rem]' />
|
||||||
|
{unreadCount > 0 ? (
|
||||||
|
<Badge
|
||||||
|
variant='destructive'
|
||||||
|
className='absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center px-1 text-[10px] font-semibold tabular-nums'
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
align='end'
|
||||||
|
sideOffset={8}
|
||||||
|
className='w-[min(26rem,calc(100vw-1rem))] gap-3 p-3'
|
||||||
|
>
|
||||||
|
<PopoverHeader className='gap-1 px-1'>
|
||||||
|
<PopoverTitle>{t('System Announcements')}</PopoverTitle>
|
||||||
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
{t('Latest platform updates and notices')}
|
||||||
|
</p>
|
||||||
|
</PopoverHeader>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
@ -256,20 +321,20 @@ export function NotificationDialog({
|
|||||||
>
|
>
|
||||||
<TabsList className='grid w-full grid-cols-2'>
|
<TabsList className='grid w-full grid-cols-2'>
|
||||||
<TabsTrigger value='notice' className='gap-1.5'>
|
<TabsTrigger value='notice' className='gap-1.5'>
|
||||||
<Bell className='h-3.5 w-3.5' />
|
<Bell className='size-3.5' />
|
||||||
{t('Notice')}
|
{t('Notice')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value='announcements' className='gap-1.5'>
|
<TabsTrigger value='announcements' className='gap-1.5'>
|
||||||
<Megaphone className='h-3.5 w-3.5' />
|
<Megaphone className='size-3.5' />
|
||||||
{t('Timeline')}
|
{t('Timeline')}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value='notice' className='mt-4'>
|
<TabsContent value='notice' className='mt-2'>
|
||||||
<NoticeContent notice={notice} loading={loading} t={t} />
|
<NoticeContent notice={notice} loading={loading} t={t} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value='announcements' className='mt-4'>
|
<TabsContent value='announcements' className='mt-2'>
|
||||||
<AnnouncementsContent
|
<AnnouncementsContent
|
||||||
announcements={announcements}
|
announcements={announcements}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@ -278,13 +343,12 @@ export function NotificationDialog({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<DialogFooter className='gap-2'>
|
<div className='flex justify-end'>
|
||||||
<Button variant='outline' onClick={onCloseToday}>
|
<Button size='sm' onClick={() => onOpenChange(false)}>
|
||||||
{t('Close Today')}
|
{t('Close')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => onOpenChange(false)}>{t('Close')}</Button>
|
</div>
|
||||||
</DialogFooter>
|
</PopoverContent>
|
||||||
</DialogContent>
|
</Popover>
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
114
web/default/src/components/status-badge.tsx
vendored
114
web/default/src/components/status-badge.tsx
vendored
@ -74,9 +74,33 @@ export const textColorMap = {
|
|||||||
export type StatusVariant = keyof typeof dotColorMap
|
export type StatusVariant = keyof typeof dotColorMap
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
sm: 'text-xs gap-1.5',
|
sm: 'h-6 gap-1 px-2 text-sm leading-none',
|
||||||
md: 'text-xs gap-1.5',
|
md: 'h-6 gap-1 px-2 text-sm leading-none',
|
||||||
lg: 'text-sm gap-2',
|
lg: 'h-7 gap-1.5 px-2.5 text-sm leading-none',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const badgeSurfaceMap = {
|
||||||
|
success: 'bg-success/10 text-success',
|
||||||
|
warning: 'bg-warning/10 text-warning',
|
||||||
|
danger: 'bg-destructive/10 text-destructive',
|
||||||
|
info: 'bg-info/10 text-info',
|
||||||
|
neutral: 'bg-muted text-muted-foreground',
|
||||||
|
purple: 'bg-chart-4/10 text-chart-4',
|
||||||
|
amber: 'bg-warning/10 text-warning',
|
||||||
|
blue: 'bg-chart-1/10 text-chart-1',
|
||||||
|
cyan: 'bg-chart-2/10 text-chart-2',
|
||||||
|
green: 'bg-success/10 text-success',
|
||||||
|
grey: 'bg-muted text-muted-foreground',
|
||||||
|
indigo: 'bg-chart-1/10 text-chart-1',
|
||||||
|
'light-blue': 'bg-info/10 text-info',
|
||||||
|
'light-green': 'bg-success/10 text-success',
|
||||||
|
lime: 'bg-chart-3/10 text-chart-3',
|
||||||
|
orange: 'bg-warning/10 text-warning',
|
||||||
|
pink: 'bg-chart-5/10 text-chart-5',
|
||||||
|
red: 'bg-destructive/10 text-destructive',
|
||||||
|
teal: 'bg-chart-2/10 text-chart-2',
|
||||||
|
violet: 'bg-chart-4/10 text-chart-4',
|
||||||
|
yellow: 'bg-warning/10 text-warning',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface StatusBadgeProps extends Omit<
|
export interface StatusBadgeProps extends Omit<
|
||||||
@ -87,7 +111,7 @@ export interface StatusBadgeProps extends Omit<
|
|||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
icon?: LucideIcon
|
icon?: LucideIcon
|
||||||
pulse?: boolean
|
pulse?: boolean
|
||||||
/** When false, hides the leading dot */
|
/** Kept for compatibility. Badges no longer render leading dots. */
|
||||||
showDot?: boolean
|
showDot?: boolean
|
||||||
variant?: StatusVariant | null
|
variant?: StatusVariant | null
|
||||||
size?: 'sm' | 'md' | 'lg' | null
|
size?: 'sm' | 'md' | 'lg' | null
|
||||||
@ -103,7 +127,7 @@ export function StatusBadge({
|
|||||||
variant,
|
variant,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
pulse = false,
|
pulse = false,
|
||||||
showDot = true,
|
showDot = false,
|
||||||
copyable = true,
|
copyable = true,
|
||||||
copyText,
|
copyText,
|
||||||
autoColor,
|
autoColor,
|
||||||
@ -112,6 +136,7 @@ export function StatusBadge({
|
|||||||
...props
|
...props
|
||||||
}: StatusBadgeProps) {
|
}: StatusBadgeProps) {
|
||||||
const { copyToClipboard } = useCopyToClipboard()
|
const { copyToClipboard } = useCopyToClipboard()
|
||||||
|
void showDot
|
||||||
|
|
||||||
const computedVariant: StatusVariant = autoColor
|
const computedVariant: StatusVariant = autoColor
|
||||||
? (stringToColor(autoColor) as StatusVariant)
|
? (stringToColor(autoColor) as StatusVariant)
|
||||||
@ -131,58 +156,101 @@ export function StatusBadge({
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex w-fit shrink-0 items-center font-medium whitespace-nowrap',
|
'inline-flex w-fit max-w-full shrink-0 items-center rounded-full font-medium tracking-normal whitespace-nowrap transition-colors',
|
||||||
sizeMap[size ?? 'sm'],
|
sizeMap[size ?? 'sm'],
|
||||||
textColorMap[computedVariant],
|
badgeSurfaceMap[computedVariant],
|
||||||
pulse && 'animate-pulse',
|
pulse && 'animate-pulse',
|
||||||
copyable &&
|
copyable &&
|
||||||
'cursor-pointer transition-opacity hover:opacity-70 active:scale-95',
|
'cursor-copy hover:brightness-95 active:scale-95 dark:hover:brightness-110',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
|
title={copyable ? `Click to copy: ${copyText || label || ''}` : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showDot && (
|
{Icon && <Icon className='size-3.5 shrink-0' />}
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-block size-1.5 shrink-0 rounded-full',
|
|
||||||
dotColorMap[computedVariant]
|
|
||||||
)}
|
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{Icon && <Icon className='size-3 shrink-0' />}
|
|
||||||
{content}
|
{content}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatusBadgeListProps<T> extends Omit<
|
||||||
|
React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
'children'
|
||||||
|
> {
|
||||||
|
empty?: React.ReactNode
|
||||||
|
getKey?: (item: T, index: number) => React.Key
|
||||||
|
items: T[]
|
||||||
|
max?: number
|
||||||
|
moreLabel?: (remaining: number) => string
|
||||||
|
renderItem: (item: T, index: number) => React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadgeList<T>(props: StatusBadgeListProps<T>) {
|
||||||
|
const {
|
||||||
|
className,
|
||||||
|
empty = <span className='text-muted-foreground text-xs'>-</span>,
|
||||||
|
getKey,
|
||||||
|
items,
|
||||||
|
max = 2,
|
||||||
|
moreLabel,
|
||||||
|
renderItem,
|
||||||
|
...domProps
|
||||||
|
} = props
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return empty
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayed = items.slice(0, max)
|
||||||
|
const remaining = items.length - max
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex max-w-full items-center gap-1 overflow-hidden',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...domProps}
|
||||||
|
>
|
||||||
|
{displayed.map((item, index) => (
|
||||||
|
<React.Fragment key={getKey?.(item, index) ?? index}>
|
||||||
|
{renderItem(item, index)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
{remaining > 0 && (
|
||||||
|
<StatusBadge
|
||||||
|
label={moreLabel?.(remaining) ?? `+${remaining}`}
|
||||||
|
variant='neutral'
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
className='shrink-0'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const statusPresets = {
|
export const statusPresets = {
|
||||||
active: {
|
active: {
|
||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
label: 'Active',
|
label: 'Active',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
inactive: {
|
inactive: {
|
||||||
variant: 'neutral' as const,
|
variant: 'neutral' as const,
|
||||||
label: 'Inactive',
|
label: 'Inactive',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
invited: {
|
invited: {
|
||||||
variant: 'info' as const,
|
variant: 'info' as const,
|
||||||
label: 'Invited',
|
label: 'Invited',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
suspended: {
|
suspended: {
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
label: 'Suspended',
|
label: 'Suspended',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
pending: {
|
pending: {
|
||||||
variant: 'warning' as const,
|
variant: 'warning' as const,
|
||||||
label: 'Pending',
|
label: 'Pending',
|
||||||
showDot: true,
|
|
||||||
pulse: true,
|
pulse: true,
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
37
web/default/src/components/table-id.tsx
vendored
Normal file
37
web/default/src/components/table-id.tsx
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
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 { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type TableIdProps = {
|
||||||
|
className?: string
|
||||||
|
value: number | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableId(props: TableIdProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground inline-block font-mono tabular-nums',
|
||||||
|
props.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
web/default/src/components/ui/drawer.tsx
vendored
2
web/default/src/components/ui/drawer.tsx
vendored
@ -73,7 +73,7 @@ function DrawerContent({
|
|||||||
<DrawerPrimitive.Content
|
<DrawerPrimitive.Content
|
||||||
data-slot='drawer-content'
|
data-slot='drawer-content'
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/drawer-content bg-popover text-popover-foreground fixed z-50 flex h-auto flex-col text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
'group/drawer-content bg-background text-foreground fixed z-50 flex h-auto flex-col overflow-hidden text-sm shadow-none data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
2
web/default/src/components/ui/sheet.tsx
vendored
2
web/default/src/components/ui/sheet.tsx
vendored
@ -76,7 +76,7 @@ function SheetContent({
|
|||||||
data-slot='sheet-content'
|
data-slot='sheet-content'
|
||||||
data-side={side}
|
data-side={side}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
'bg-background text-foreground fixed z-50 flex flex-col gap-4 overflow-hidden bg-clip-padding text-sm shadow-none transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0',
|
||||||
side === 'right' &&
|
side === 'right' &&
|
||||||
'inset-y-0 right-0 h-full w-3/4 border-l data-ending-style:translate-x-[2.5rem] data-starting-style:translate-x-[2.5rem] sm:max-w-sm',
|
'inset-y-0 right-0 h-full w-3/4 border-l data-ending-style:translate-x-[2.5rem] data-starting-style:translate-x-[2.5rem] sm:max-w-sm',
|
||||||
side === 'left' &&
|
side === 'left' &&
|
||||||
|
|||||||
32
web/default/src/components/ui/sonner.tsx
vendored
32
web/default/src/components/ui/sonner.tsx
vendored
@ -26,15 +26,15 @@ import {
|
|||||||
Loading03Icon,
|
Loading03Icon,
|
||||||
} from '@hugeicons/core-free-icons'
|
} from '@hugeicons/core-free-icons'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
import { Toaster as Sonner, type ToasterProps } from 'sonner'
|
||||||
|
import { useTheme } from '@/context/theme-provider'
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
const Toaster = (props: ToasterProps) => {
|
||||||
const { theme = 'system' } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps['theme']}
|
theme={resolvedTheme}
|
||||||
className='toaster group'
|
className='toaster group'
|
||||||
icons={{
|
icons={{
|
||||||
success: (
|
success: (
|
||||||
@ -78,14 +78,28 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
|||||||
'--normal-bg': 'var(--popover)',
|
'--normal-bg': 'var(--popover)',
|
||||||
'--normal-text': 'var(--popover-foreground)',
|
'--normal-text': 'var(--popover-foreground)',
|
||||||
'--normal-border': 'var(--border)',
|
'--normal-border': 'var(--border)',
|
||||||
|
'--success-bg':
|
||||||
|
'color-mix(in oklch, var(--success) 16%, var(--popover))',
|
||||||
|
'--success-border':
|
||||||
|
'color-mix(in oklch, var(--success) 35%, var(--border))',
|
||||||
|
'--success-text': 'var(--success)',
|
||||||
|
'--info-bg': 'color-mix(in oklch, var(--info) 16%, var(--popover))',
|
||||||
|
'--info-border':
|
||||||
|
'color-mix(in oklch, var(--info) 35%, var(--border))',
|
||||||
|
'--info-text': 'var(--info)',
|
||||||
|
'--warning-bg':
|
||||||
|
'color-mix(in oklch, var(--warning) 18%, var(--popover))',
|
||||||
|
'--warning-border':
|
||||||
|
'color-mix(in oklch, var(--warning) 38%, var(--border))',
|
||||||
|
'--warning-text': 'var(--warning)',
|
||||||
|
'--error-bg':
|
||||||
|
'color-mix(in oklch, var(--destructive) 16%, var(--popover))',
|
||||||
|
'--error-border':
|
||||||
|
'color-mix(in oklch, var(--destructive) 35%, var(--border))',
|
||||||
|
'--error-text': 'var(--destructive)',
|
||||||
'--border-radius': 'var(--radius)',
|
'--border-radius': 'var(--radius)',
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
toastOptions={{
|
|
||||||
classNames: {
|
|
||||||
toast: 'cn-toast',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
7
web/default/src/components/ui/table.tsx
vendored
7
web/default/src/components/ui/table.tsx
vendored
@ -25,11 +25,14 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot='table-container'
|
data-slot='table-container'
|
||||||
className='relative w-full overflow-x-auto'
|
className='relative w-full overflow-x-auto overflow-y-hidden'
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot='table'
|
data-slot='table'
|
||||||
className={cn('w-full caption-bottom text-sm', className)}
|
className={cn(
|
||||||
|
'w-full caption-bottom text-sm tabular-nums [&_td]:text-sm [&_td_*]:text-sm [&_th]:text-sm [&_th_*]:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -35,7 +35,7 @@ import {
|
|||||||
formatQuota as formatQuotaValue,
|
formatQuota as formatQuotaValue,
|
||||||
} from '@/lib/format'
|
} from '@/lib/format'
|
||||||
import { getLobeIcon } from '@/lib/lobe-icon'
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
||||||
import { cn, truncateText } from '@/lib/utils'
|
import { truncateText } from '@/lib/utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
@ -47,11 +47,8 @@ import {
|
|||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import {
|
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||||
StatusBadge,
|
import { TableId } from '@/components/table-id'
|
||||||
dotColorMap,
|
|
||||||
textColorMap,
|
|
||||||
} from '@/components/status-badge'
|
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
import { getCodexUsage } from '../api'
|
import { getCodexUsage } from '../api'
|
||||||
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
|
import { CHANNEL_STATUS_CONFIG, MODEL_FETCHABLE_TYPES } from '../constants'
|
||||||
@ -107,25 +104,12 @@ function renderLimitedItems(
|
|||||||
items: React.ReactNode[],
|
items: React.ReactNode[],
|
||||||
maxDisplay: number = 2
|
maxDisplay: number = 2
|
||||||
): React.ReactNode {
|
): 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 (
|
return (
|
||||||
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
|
<StatusBadgeList
|
||||||
{displayed}
|
items={items}
|
||||||
{remaining > 0 && (
|
max={maxDisplay}
|
||||||
<StatusBadge
|
renderItem={(item) => item}
|
||||||
label={`+${remaining}`}
|
/>
|
||||||
variant='neutral'
|
|
||||||
size='sm'
|
|
||||||
copyable={false}
|
|
||||||
className='flex-shrink-0'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,47 +345,50 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
<div className='flex items-center gap-1'>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'size-1.5 shrink-0 rounded-full',
|
|
||||||
dotColorMap[isUpdating ? 'neutral' : variant]
|
|
||||||
)}
|
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={<span className='text-muted-foreground cursor-help' />}
|
render={
|
||||||
>
|
<StatusBadge
|
||||||
{usedDisplay}
|
label={usedDisplay}
|
||||||
</TooltipTrigger>
|
variant='neutral'
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
className='cursor-help'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{t('Used:')} {usedDisplay}
|
{t('Used:')} {usedDisplay}
|
||||||
</p>
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
label={
|
||||||
'cursor-pointer transition-opacity hover:opacity-70',
|
isUpdating
|
||||||
|
? t('Updating...')
|
||||||
|
: channel.type === 57
|
||||||
|
? t('Account Info')
|
||||||
|
: remainingDisplay
|
||||||
|
}
|
||||||
|
variant={
|
||||||
channel.type === 57
|
channel.type === 57
|
||||||
? 'text-primary'
|
? 'info'
|
||||||
: textColorMap[isUpdating ? 'neutral' : variant]
|
: isUpdating
|
||||||
)}
|
? 'neutral'
|
||||||
|
: variant
|
||||||
|
}
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
className='cursor-pointer'
|
||||||
onClick={handleClickUpdate}
|
onClick={handleClickUpdate}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{isUpdating
|
|
||||||
? 'Updating...'
|
|
||||||
: channel.type === 57
|
|
||||||
? t('Account Info')
|
|
||||||
: remainingDisplay}
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>
|
<p>
|
||||||
{channel.type === 57
|
{channel.type === 57
|
||||||
@ -491,15 +478,7 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const id = row.getValue('id') as number
|
const id = row.getValue('id') as number
|
||||||
return (
|
return <TableId value={id} />
|
||||||
<StatusBadge
|
|
||||||
label={String(id)}
|
|
||||||
variant='neutral'
|
|
||||||
copyText={String(id)}
|
|
||||||
size='sm'
|
|
||||||
className='font-mono'
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
size: 80,
|
size: 80,
|
||||||
},
|
},
|
||||||
@ -695,8 +674,13 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
<StatusBadge
|
||||||
<span className={cn(textColorMap.purple)}>IO.NET</span>
|
label='IO.NET'
|
||||||
|
variant='purple'
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
className='cursor-pointer'
|
||||||
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side='top'>
|
<TooltipContent side='top'>
|
||||||
<div className='max-w-xs space-y-1'>
|
<div className='max-w-xs space-y-1'>
|
||||||
@ -747,7 +731,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={`Active (${childrenCount})`}
|
label={`Active (${childrenCount})`}
|
||||||
variant='success'
|
variant='success'
|
||||||
showDot
|
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
@ -806,7 +789,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={label}
|
label={label}
|
||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
showDot={config.showDot}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
@ -835,7 +817,6 @@ export function useChannelsColumns(): ColumnDef<Channel>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={label}
|
label={label}
|
||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
showDot={config.showDot}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -75,6 +75,12 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
|
||||||
import { DataTablePagination } from '@/components/data-table/pagination'
|
import { DataTablePagination } from '@/components/data-table/pagination'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { formatResponseTime, handleTestChannel } from '../../lib'
|
import { formatResponseTime, handleTestChannel } from '../../lib'
|
||||||
import { useChannels } from '../channels-provider'
|
import { useChannels } from '../channels-provider'
|
||||||
@ -833,19 +839,19 @@ function FailureDetailsSheet({
|
|||||||
side={isMobile ? 'bottom' : 'right'}
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
className={
|
className={
|
||||||
isMobile
|
isMobile
|
||||||
? 'max-h-[85dvh] gap-0 overflow-hidden rounded-t-xl p-0'
|
? sideDrawerContentClassName('h-auto max-h-[85dvh] rounded-t-xl')
|
||||||
: 'h-dvh w-full gap-0 overflow-hidden p-0 sm:max-w-lg'
|
: sideDrawerContentClassName('sm:max-w-lg')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{details && (
|
{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>
|
<SheetTitle className='pr-10'>{t('Details')}</SheetTitle>
|
||||||
<SheetDescription className='pr-10 wrap-break-word'>
|
<SheetDescription className='pr-10 wrap-break-word'>
|
||||||
{details.model}
|
{details.model}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</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'>
|
<section className='space-y-1'>
|
||||||
<div className='text-muted-foreground text-xs font-medium'>
|
<div className='text-muted-foreground text-xs font-medium'>
|
||||||
{t('Model')}
|
{t('Model')}
|
||||||
@ -869,7 +875,7 @@ function FailureDetailsSheet({
|
|||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
className='w-full sm:w-auto'
|
className='w-full sm:w-auto'
|
||||||
|
|||||||
@ -99,6 +99,14 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
sideDrawerSectionClassName,
|
||||||
|
sideDrawerSwitchItemClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { JsonEditor } from '@/components/json-editor'
|
import { JsonEditor } from '@/components/json-editor'
|
||||||
import { MultiSelect } from '@/components/multi-select'
|
import { MultiSelect } from '@/components/multi-select'
|
||||||
import {
|
import {
|
||||||
@ -269,9 +277,9 @@ function formatUnixTime(timestamp: unknown): string {
|
|||||||
|
|
||||||
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
|
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-2.5'>
|
<div className='flex items-center gap-3'>
|
||||||
{icon && (
|
{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}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -1087,10 +1095,10 @@ export function ChannelMutateDrawer({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-3xl')}>
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle className='flex items-center gap-3'>
|
<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)}
|
{getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
@ -1115,10 +1123,10 @@ export function ChannelMutateDrawer({
|
|||||||
<form
|
<form
|
||||||
id='channel-form'
|
id='channel-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
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 ── */}
|
{/* ── Basic Information ── */}
|
||||||
<div className='bg-card space-y-4 rounded-xl border p-3 sm:p-5'>
|
<div className={sideDrawerSectionClassName()}>
|
||||||
<CardHeading
|
<CardHeading
|
||||||
title={t('Basic Information')}
|
title={t('Basic Information')}
|
||||||
icon={<Server className='h-4 w-4' />}
|
icon={<Server className='h-4 w-4' />}
|
||||||
@ -1173,8 +1181,8 @@ export function ChannelMutateDrawer({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name='status'
|
name='status'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||||
<div className='space-y-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<FormLabel>{t('Enabled')}</FormLabel>
|
<FormLabel>{t('Enabled')}</FormLabel>
|
||||||
<FormDescription className='text-xs'>
|
<FormDescription className='text-xs'>
|
||||||
{t('Enable or disable this channel')}
|
{t('Enable or disable this channel')}
|
||||||
@ -1213,7 +1221,7 @@ export function ChannelMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── API Access ── */}
|
{/* ── API Access ── */}
|
||||||
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
<div className={sideDrawerSectionClassName()}>
|
||||||
<CardHeading
|
<CardHeading
|
||||||
title={t('API Access')}
|
title={t('API Access')}
|
||||||
icon={<Link2 className='h-4 w-4' />}
|
icon={<Link2 className='h-4 w-4' />}
|
||||||
@ -1945,7 +1953,7 @@ export function ChannelMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
{isEditing && (
|
{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 className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm font-medium'>
|
<p className='text-sm font-medium'>
|
||||||
@ -2007,9 +2015,9 @@ export function ChannelMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{currentType === 57 && (
|
{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='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'>
|
<div className='text-sm font-semibold'>
|
||||||
{t('Codex Authorization')}
|
{t('Codex Authorization')}
|
||||||
</div>
|
</div>
|
||||||
@ -2171,7 +2179,7 @@ export function ChannelMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Models & Groups ── */}
|
{/* ── Models & Groups ── */}
|
||||||
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
<div className={sideDrawerSectionClassName()}>
|
||||||
<CardHeading
|
<CardHeading
|
||||||
title={t('Models & Groups')}
|
title={t('Models & Groups')}
|
||||||
icon={<Boxes className='h-4 w-4' />}
|
icon={<Boxes className='h-4 w-4' />}
|
||||||
@ -2429,11 +2437,11 @@ export function ChannelMutateDrawer({
|
|||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
type='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'>
|
<div className='text-[13px] font-semibold'>
|
||||||
{t('Advanced Settings')}
|
{t('Advanced Settings')}
|
||||||
</div>
|
</div>
|
||||||
@ -2451,14 +2459,14 @@ export function ChannelMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
<CollapsibleContent className='mt-5 space-y-5'>
|
<CollapsibleContent className='mt-5 flex flex-col gap-5'>
|
||||||
{/* ── Routing & Overrides ── */}
|
{/* ── Routing & Overrides ── */}
|
||||||
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
<div className={sideDrawerSectionClassName()}>
|
||||||
<CardHeading
|
<CardHeading
|
||||||
title={t('Routing & Overrides')}
|
title={t('Routing & Overrides')}
|
||||||
icon={<Route className='h-4 w-4' />}
|
icon={<Route className='h-4 w-4' />}
|
||||||
/>
|
/>
|
||||||
<div className='space-y-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<SubHeading
|
<SubHeading
|
||||||
title={t('Routing Strategy')}
|
title={t('Routing Strategy')}
|
||||||
icon={<Route className='h-3.5 w-3.5' />}
|
icon={<Route className='h-3.5 w-3.5' />}
|
||||||
@ -2557,7 +2565,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
|
<SubHeading
|
||||||
title={t('Internal Notes')}
|
title={t('Internal Notes')}
|
||||||
icon={<FileText className='h-3.5 w-3.5' />}
|
icon={<FileText className='h-3.5 w-3.5' />}
|
||||||
@ -2606,7 +2614,7 @@ export function ChannelMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='space-y-4 border-t pt-4'>
|
<div className='flex flex-col gap-4 border-t pt-4'>
|
||||||
<SubHeading
|
<SubHeading
|
||||||
title={t('Override Rules')}
|
title={t('Override Rules')}
|
||||||
icon={<Code className='h-3.5 w-3.5' />}
|
icon={<Code className='h-3.5 w-3.5' />}
|
||||||
@ -2848,13 +2856,13 @@ export function ChannelMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Extra Settings ── */}
|
{/* ── Extra Settings ── */}
|
||||||
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
<div className={sideDrawerSectionClassName()}>
|
||||||
<CardHeading
|
<CardHeading
|
||||||
title={t('Channel Extra Settings')}
|
title={t('Channel Extra Settings')}
|
||||||
icon={<Settings className='h-4 w-4' />}
|
icon={<Settings className='h-4 w-4' />}
|
||||||
/>
|
/>
|
||||||
{(currentType === 1 || currentType === 14) && (
|
{(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
|
<SubHeading
|
||||||
title={t('Field passthrough controls')}
|
title={t('Field passthrough controls')}
|
||||||
icon={<SlidersHorizontal className='h-3.5 w-3.5' />}
|
icon={<SlidersHorizontal className='h-3.5 w-3.5' />}
|
||||||
@ -3220,7 +3228,7 @@ export function ChannelMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{MODEL_FETCHABLE_TYPES.has(currentType) && (
|
{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
|
<SubHeading
|
||||||
title={t('Upstream Model Detection Settings')}
|
title={t('Upstream Model Detection Settings')}
|
||||||
icon={<RefreshCw className='h-3.5 w-3.5' />}
|
icon={<RefreshCw className='h-3.5 w-3.5' />}
|
||||||
@ -3341,7 +3349,7 @@ export function ChannelMutateDrawer({
|
|||||||
</form>
|
</form>
|
||||||
</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
|
<SheetClose
|
||||||
render={<Button variant='outline' disabled={isSubmitting} />}
|
render={<Button variant='outline' disabled={isSubmitting} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -131,22 +131,18 @@ export const CHANNEL_STATUS_CONFIG = {
|
|||||||
[CHANNEL_STATUS.UNKNOWN]: {
|
[CHANNEL_STATUS.UNKNOWN]: {
|
||||||
variant: 'neutral' as const,
|
variant: 'neutral' as const,
|
||||||
label: 'Unknown',
|
label: 'Unknown',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[CHANNEL_STATUS.ENABLED]: {
|
[CHANNEL_STATUS.ENABLED]: {
|
||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
label: 'Enabled',
|
label: 'Enabled',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[CHANNEL_STATUS.MANUAL_DISABLED]: {
|
[CHANNEL_STATUS.MANUAL_DISABLED]: {
|
||||||
variant: 'neutral' as const,
|
variant: 'neutral' as const,
|
||||||
label: 'Disabled',
|
label: 'Disabled',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[CHANNEL_STATUS.AUTO_DISABLED]: {
|
[CHANNEL_STATUS.AUTO_DISABLED]: {
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
label: 'Auto Disabled',
|
label: 'Auto Disabled',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,6 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
Play,
|
|
||||||
RadioTower,
|
RadioTower,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
TerminalSquare,
|
TerminalSquare,
|
||||||
@ -505,10 +504,10 @@ export function OverviewDashboard() {
|
|||||||
const quickActions = useMemo<QuickAction[]>(
|
const quickActions = useMemo<QuickAction[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
title: t('Playground'),
|
title: t('API Keys'),
|
||||||
description: t('Test models and prompts from the browser'),
|
description: t('Create a key for your app or service'),
|
||||||
to: '/playground',
|
to: '/keys',
|
||||||
icon: Play,
|
icon: KeyRound,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('Channels'),
|
title: t('Channels'),
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import { VCHART_OPTION } from '@/lib/vchart'
|
|||||||
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
import { useThemeCustomization } from '@/context/theme-customization-provider'
|
||||||
import { useTheme } from '@/context/theme-provider'
|
import { useTheme } from '@/context/theme-provider'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
|
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
|
||||||
import {
|
import {
|
||||||
TIME_GRANULARITY_OPTIONS,
|
TIME_GRANULARITY_OPTIONS,
|
||||||
@ -154,61 +155,64 @@ export function UserCharts() {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div className='flex items-center gap-1.5 overflow-x-auto pb-1 sm:gap-2'>
|
<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'>
|
<Tabs
|
||||||
{TIME_RANGE_PRESETS.map((preset) => (
|
value={String(selectedRange)}
|
||||||
<button
|
onValueChange={(value) => handleRangeChange(Number(value))}
|
||||||
key={preset.days}
|
className='shrink-0'
|
||||||
type='button'
|
>
|
||||||
onClick={() => handleRangeChange(preset.days)}
|
<TabsList>
|
||||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
{TIME_RANGE_PRESETS.map((preset) => (
|
||||||
selectedRange === preset.days
|
<TabsTrigger
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
key={preset.days}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
value={String(preset.days)}
|
||||||
}`}
|
className='px-2.5 text-xs'
|
||||||
>
|
>
|
||||||
{t(preset.label)}
|
{t(preset.label)}
|
||||||
</button>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
<Tabs
|
||||||
{TIME_GRANULARITY_OPTIONS.map((opt) => (
|
value={timeGranularity}
|
||||||
<button
|
onValueChange={(value) =>
|
||||||
key={opt.value}
|
handleGranularityChange(value as TimeGranularity)
|
||||||
type='button'
|
}
|
||||||
onClick={() =>
|
className='shrink-0'
|
||||||
handleGranularityChange(opt.value as TimeGranularity)
|
>
|
||||||
}
|
<TabsList>
|
||||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
{TIME_GRANULARITY_OPTIONS.map((opt) => (
|
||||||
timeGranularity === opt.value
|
<TabsTrigger
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
key={opt.value}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
value={opt.value}
|
||||||
}`}
|
className='px-2.5 text-xs'
|
||||||
>
|
>
|
||||||
{t(opt.label)}
|
{t(opt.label)}
|
||||||
</button>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<div className='flex shrink-0 items-center gap-1.5 rounded-lg border p-0.5'>
|
<Tabs
|
||||||
<span className='text-muted-foreground px-2 text-xs font-medium'>
|
value={String(topUserLimit)}
|
||||||
{t('Top Users')}
|
onValueChange={(value) => setTopUserLimit(Number(value))}
|
||||||
</span>
|
className='shrink-0'
|
||||||
{TOP_USER_LIMIT_OPTIONS.map((limit) => (
|
>
|
||||||
<button
|
<TabsList>
|
||||||
key={limit}
|
<span className='text-muted-foreground px-2 text-xs font-medium whitespace-nowrap'>
|
||||||
type='button'
|
{t('Top Users')}
|
||||||
onClick={() => setTopUserLimit(limit)}
|
</span>
|
||||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
{TOP_USER_LIMIT_OPTIONS.map((limit) => (
|
||||||
topUserLimit === limit
|
<TabsTrigger
|
||||||
? 'bg-primary text-primary-foreground shadow-sm'
|
key={limit}
|
||||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
value={String(limit)}
|
||||||
}`}
|
className='px-2.5 text-xs'
|
||||||
>
|
>
|
||||||
{t('Top {{count}}', { count: limit })}
|
{t('Top {{count}}', { count: limit })}
|
||||||
</button>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</div>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
<Loader2 className='text-muted-foreground size-4 animate-spin' />
|
||||||
|
|||||||
@ -118,7 +118,6 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t(statusConfig.label)}
|
label={t(statusConfig.label)}
|
||||||
variant={statusConfig.variant}
|
variant={statusConfig.variant}
|
||||||
showDot={statusConfig.showDot}
|
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -212,12 +211,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
|||||||
>
|
>
|
||||||
<GroupBadge group='auto' />
|
<GroupBadge group='auto' />
|
||||||
{apiKey.cross_group_retry && (
|
{apiKey.cross_group_retry && (
|
||||||
<>
|
<StatusBadge
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
label={t('Cross-group')}
|
||||||
<span className='text-muted-foreground/60'>
|
variant='info'
|
||||||
{t('Cross-group')}
|
copyable={false}
|
||||||
</span>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<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
|
For commercial licensing, please contact support@quantumnous.com
|
||||||
*/
|
*/
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
import { ApiKeysDeleteDialog } from './api-keys-delete-dialog'
|
||||||
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
import { ApiKeysMutateDrawer } from './api-keys-mutate-drawer'
|
||||||
import { useApiKeys } from './api-keys-provider'
|
import { useApiKeys } from './api-keys-provider'
|
||||||
@ -24,19 +23,6 @@ import { CCSwitchDialog } from './dialogs/cc-switch-dialog'
|
|||||||
|
|
||||||
export function ApiKeysDialogs() {
|
export function ApiKeysDialogs() {
|
||||||
const { open, setOpen, currentRow, resolvedKey } = useApiKeys()
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -44,7 +30,6 @@ export function ApiKeysDialogs() {
|
|||||||
open={open === 'create' || open === 'update'}
|
open={open === 'create' || open === 'update'}
|
||||||
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
onOpenChange={(isOpen) => !isOpen && setOpen(null)}
|
||||||
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
currentRow={open === 'update' ? currentRow || undefined : undefined}
|
||||||
side={mutateSide}
|
|
||||||
/>
|
/>
|
||||||
<ApiKeysDeleteDialog />
|
<ApiKeysDeleteDialog />
|
||||||
<CCSwitchDialog
|
<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
|
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 { useForm, type SubmitErrorHandler } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import {
|
import { ChevronDown, KeyRound, Settings2, WalletCards } from 'lucide-react'
|
||||||
ChevronDown,
|
|
||||||
KeyRound,
|
|
||||||
Settings2,
|
|
||||||
WalletCards,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { getUserModels, getUserGroups } from '@/lib/api'
|
import { getUserModels, getUserGroups } from '@/lib/api'
|
||||||
@ -61,6 +55,15 @@ import {
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { DateTimePicker } from '@/components/datetime-picker'
|
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 { MultiSelect } from '@/components/multi-select'
|
||||||
import { createApiKey, updateApiKey, getApiKey } from '../api'
|
import { createApiKey, updateApiKey, getApiKey } from '../api'
|
||||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||||
@ -82,42 +85,12 @@ type ApiKeyMutateDrawerProps = {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
currentRow?: ApiKey
|
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({
|
export function ApiKeysMutateDrawer({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
currentRow,
|
currentRow,
|
||||||
side = 'right',
|
|
||||||
}: ApiKeyMutateDrawerProps) {
|
}: ApiKeyMutateDrawerProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isUpdate = !!currentRow
|
const isUpdate = !!currentRow
|
||||||
@ -284,31 +257,30 @@ export function ApiKeysMutateDrawer({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side={side}
|
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
|
||||||
className='bg-background flex !h-dvh !w-screen max-w-none gap-0 overflow-hidden p-0 sm:!w-full sm:!max-w-[620px]'
|
|
||||||
>
|
>
|
||||||
<SheetHeader className='bg-background border-b px-4 py-3 text-start sm:px-5 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle className='text-base sm:text-lg'>
|
<SheetTitle>
|
||||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className='pr-6 text-xs sm:text-sm'>
|
<SheetDescription>
|
||||||
{isUpdate
|
{isUpdate
|
||||||
? t('Update the API key by providing necessary info.')
|
? t('Update the API key by providing necessary info.')
|
||||||
: t('Add a new API key by providing necessary info.')}{' '}
|
: t('Add a new API key by providing necessary info.')}
|
||||||
{t("Click save when you're done.")}
|
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
id='api-key-form'
|
id='api-key-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit, onInvalid)}
|
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
|
<SideDrawerSection>
|
||||||
title={t('Basic Information')}
|
<SideDrawerSectionHeader
|
||||||
description={t('Set API key basic information')}
|
title={t('Basic Information')}
|
||||||
icon={KeyRound}
|
description={t('Set API key basic information')}
|
||||||
>
|
icon={<KeyRound className='size-4' />}
|
||||||
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='name'
|
name='name'
|
||||||
@ -347,8 +319,8 @@ export function ApiKeysMutateDrawer({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name='cross_group_retry'
|
name='cross_group_retry'
|
||||||
render={({ field }) => (
|
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'>
|
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||||
<div className='space-y-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<FormLabel className='text-sm'>
|
<FormLabel className='text-sm'>
|
||||||
{t('Cross-group retry')}
|
{t('Cross-group retry')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@ -456,13 +428,14 @@ export function ApiKeysMutateDrawer({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ApiKeyFormSection>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<ApiKeyFormSection
|
<SideDrawerSection>
|
||||||
title={t('Quota Settings')}
|
<SideDrawerSectionHeader
|
||||||
description={t('Set quota amount and limits')}
|
title={t('Quota Settings')}
|
||||||
icon={WalletCards}
|
description={t('Set quota amount and limits')}
|
||||||
>
|
icon={<WalletCards className='size-4' />}
|
||||||
|
/>
|
||||||
{!unlimitedQuota && (
|
{!unlimitedQuota && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -498,8 +471,8 @@ export function ApiKeysMutateDrawer({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name='unlimited_quota'
|
name='unlimited_quota'
|
||||||
render={({ field }) => (
|
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'>
|
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||||
<div className='space-y-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<FormLabel className='text-sm'>
|
<FormLabel className='text-sm'>
|
||||||
{t('Unlimited Quota')}
|
{t('Unlimited Quota')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@ -516,29 +489,24 @@ export function ApiKeysMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</ApiKeyFormSection>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||||
<section className='bg-card rounded-lg border'>
|
<SideDrawerSection>
|
||||||
<CollapsibleTrigger
|
<CollapsibleTrigger
|
||||||
render={
|
render={
|
||||||
<button
|
<button
|
||||||
type='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'>
|
<SideDrawerSectionHeader
|
||||||
<Settings2 className='size-4 sm:size-5' />
|
className='flex-1'
|
||||||
</div>
|
title={t('Advanced Settings')}
|
||||||
<div className='min-w-0 flex-1'>
|
description={t('Set API key access restrictions')}
|
||||||
<h3 className='text-sm leading-none font-medium'>
|
icon={<Settings2 className='size-4' />}
|
||||||
{t('Advanced Settings')}
|
/>
|
||||||
</h3>
|
|
||||||
<p className='text-muted-foreground mt-1 text-xs'>
|
|
||||||
{t('Set API key access restrictions')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-muted-foreground size-4 shrink-0 transition-transform',
|
'text-muted-foreground size-4 shrink-0 transition-transform',
|
||||||
@ -547,7 +515,7 @@ export function ApiKeysMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent>
|
<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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='model_limits'
|
name='model_limits'
|
||||||
@ -604,11 +572,11 @@ export function ApiKeysMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</section>
|
</SideDrawerSection>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</form>
|
</form>
|
||||||
</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
|
<SheetClose
|
||||||
render={<Button variant='outline' className='w-full sm:w-auto' />}
|
render={<Button variant='outline' className='w-full sm:w-auto' />}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -153,7 +153,6 @@ function ApiKeysMobileList({
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t(statusConfig.label)}
|
label={t(statusConfig.label)}
|
||||||
variant={statusConfig.variant}
|
variant={statusConfig.variant}
|
||||||
showDot={statusConfig.showDot}
|
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
6
web/default/src/features/keys/constants.ts
vendored
6
web/default/src/features/keys/constants.ts
vendored
@ -32,7 +32,7 @@ export const API_KEY_STATUS = {
|
|||||||
|
|
||||||
export const API_KEY_STATUSES: Record<
|
export const API_KEY_STATUSES: Record<
|
||||||
number,
|
number,
|
||||||
Pick<StatusBadgeProps, 'variant' | 'showDot'> & {
|
Pick<StatusBadgeProps, 'variant'> & {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
@ -41,25 +41,21 @@ export const API_KEY_STATUSES: Record<
|
|||||||
label: 'Enabled',
|
label: 'Enabled',
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
value: API_KEY_STATUS.ENABLED,
|
value: API_KEY_STATUS.ENABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[API_KEY_STATUS.DISABLED]: {
|
[API_KEY_STATUS.DISABLED]: {
|
||||||
label: 'Disabled',
|
label: 'Disabled',
|
||||||
variant: 'neutral',
|
variant: 'neutral',
|
||||||
value: API_KEY_STATUS.DISABLED,
|
value: API_KEY_STATUS.DISABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[API_KEY_STATUS.EXPIRED]: {
|
[API_KEY_STATUS.EXPIRED]: {
|
||||||
label: 'Expired',
|
label: 'Expired',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
value: API_KEY_STATUS.EXPIRED,
|
value: API_KEY_STATUS.EXPIRED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[API_KEY_STATUS.EXHAUSTED]: {
|
[API_KEY_STATUS.EXHAUSTED]: {
|
||||||
label: 'Exhausted',
|
label: 'Exhausted',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
value: API_KEY_STATUS.EXHAUSTED,
|
value: API_KEY_STATUS.EXHAUSTED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { formatTimestampToDate } from '@/lib/format'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableId } from '@/components/table-id'
|
||||||
import { getDeploymentStatusConfig } from '../constants'
|
import { getDeploymentStatusConfig } from '../constants'
|
||||||
import {
|
import {
|
||||||
formatRemainingMinutes,
|
formatRemainingMinutes,
|
||||||
@ -50,15 +51,7 @@ export function useDeploymentsColumns(opts: {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const id = row.original.id
|
const id = row.original.id
|
||||||
return (
|
return <TableId value={id} />
|
||||||
<StatusBadge
|
|
||||||
label={String(id)}
|
|
||||||
variant='neutral'
|
|
||||||
copyText={String(id)}
|
|
||||||
size='sm'
|
|
||||||
className='font-mono'
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
size: 120,
|
size: 120,
|
||||||
},
|
},
|
||||||
@ -100,7 +93,6 @@ export function useDeploymentsColumns(opts: {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={config.label}
|
label={config.label}
|
||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
showDot={config.showDot}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -51,6 +51,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { MultiSelect } from '@/components/multi-select'
|
import { MultiSelect } from '@/components/multi-select'
|
||||||
import {
|
import {
|
||||||
checkClusterNameAvailability,
|
checkClusterNameAvailability,
|
||||||
@ -375,8 +382,8 @@ export function CreateDeploymentDrawer({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SheetContent className='flex w-full flex-col sm:max-w-[600px]'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
|
||||||
<SheetHeader className='text-start'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>{t('Create deployment')}</SheetTitle>
|
<SheetTitle>{t('Create deployment')}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{t('Configure and deploy a new container instance.')}
|
{t('Configure and deploy a new container instance.')}
|
||||||
@ -389,10 +396,10 @@ export function CreateDeploymentDrawer({
|
|||||||
onSubmit={form.handleSubmit((values) =>
|
onSubmit={form.handleSubmit((values) =>
|
||||||
createMutation.mutate(values)
|
createMutation.mutate(values)
|
||||||
)}
|
)}
|
||||||
className='flex-1 space-y-6 overflow-y-auto px-4'
|
className={sideDrawerFormClassName()}
|
||||||
>
|
>
|
||||||
{/* Basic Configuration */}
|
{/* Basic Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>
|
<h3 className='text-sm font-medium'>
|
||||||
{t('Basic Configuration')}
|
{t('Basic Configuration')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -435,10 +442,10 @@ export function CreateDeploymentDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Resource Configuration */}
|
{/* Resource Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>
|
<h3 className='text-sm font-medium'>
|
||||||
{t('Resource Configuration')}
|
{t('Resource Configuration')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -604,10 +611,10 @@ export function CreateDeploymentDrawer({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Price Estimation */}
|
{/* Price Estimation */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>{t('Price estimation')}</h3>
|
<h3 className='text-sm font-medium'>{t('Price estimation')}</h3>
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
{t('Price estimation description')}
|
{t('Price estimation description')}
|
||||||
@ -642,10 +649,10 @@ export function CreateDeploymentDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Advanced Configuration */}
|
{/* Advanced Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>
|
<h3 className='text-sm font-medium'>
|
||||||
{t('Advanced Configuration')}
|
{t('Advanced Configuration')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -653,7 +660,7 @@ export function CreateDeploymentDrawer({
|
|||||||
{t('Optional settings for advanced container configuration.')}
|
{t('Optional settings for advanced container configuration.')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className='space-y-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
<div className='grid gap-4 sm:grid-cols-2'>
|
<div className='grid gap-4 sm:grid-cols-2'>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -758,11 +765,11 @@ export function CreateDeploymentDrawer({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<SheetFooter className='gap-2'>
|
<SheetFooter className={sideDrawerFooterClassName()}>
|
||||||
<SheetClose render={<Button variant='outline' />}>
|
<SheetClose render={<Button variant='outline' />}>
|
||||||
{t('Cancel')}
|
{t('Cancel')}
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
|
|||||||
@ -65,6 +65,7 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableId } from '@/components/table-id'
|
||||||
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
import { deletePrefillGroup, getPrefillGroups } from '../../api'
|
||||||
import { prefillGroupsQueryKeys } from '../../lib'
|
import { prefillGroupsQueryKeys } from '../../lib'
|
||||||
import type { PrefillGroup } from '../../types'
|
import type { PrefillGroup } from '../../types'
|
||||||
@ -405,13 +406,7 @@ export function PrefillGroupManagementDialog({
|
|||||||
<span className='font-medium'>
|
<span className='font-medium'>
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
<StatusBadge
|
<TableId value={group.id} />
|
||||||
label={`#${group.id}`}
|
|
||||||
variant='neutral'
|
|
||||||
size='sm'
|
|
||||||
copyable={false}
|
|
||||||
className='font-mono'
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{group.description ? (
|
{group.description ? (
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-muted-foreground text-xs'>
|
||||||
|
|||||||
@ -50,7 +50,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
@ -62,6 +61,14 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
sideDrawerSwitchItemClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { JsonEditor } from '@/components/json-editor'
|
import { JsonEditor } from '@/components/json-editor'
|
||||||
import { TagInput } from '@/components/tag-input'
|
import { TagInput } from '@/components/tag-input'
|
||||||
import {
|
import {
|
||||||
@ -627,8 +634,8 @@ export function ModelMutateDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isEditing ? t('Edit Model') : t('Create Model')}
|
{isEditing ? t('Edit Model') : t('Create Model')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
@ -647,10 +654,10 @@ export function ModelMutateDrawer({
|
|||||||
onSubmit={form.handleSubmit(
|
onSubmit={form.handleSubmit(
|
||||||
onSubmit as Parameters<typeof form.handleSubmit>[0]
|
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 */}
|
{/* Basic Information */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-semibold'>
|
<h3 className='text-sm font-semibold'>
|
||||||
{t('Basic Information')}
|
{t('Basic Information')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -774,12 +781,10 @@ export function ModelMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Matching Configuration */}
|
{/* Matching Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-semibold'>{t('Matching Rules')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Matching Rules')}</h3>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -822,12 +827,10 @@ export function ModelMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Endpoints Configuration */}
|
{/* Endpoints Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<h3 className='text-sm font-semibold'>{t('Endpoints')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Endpoints')}</h3>
|
||||||
<Select<string>
|
<Select<string>
|
||||||
@ -883,12 +886,10 @@ export function ModelMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Pricing Configuration */}
|
{/* Pricing Configuration */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-semibold'>
|
<h3 className='text-sm font-semibold'>
|
||||||
{t('Pricing Configuration')}
|
{t('Pricing Configuration')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -1114,7 +1115,7 @@ export function ModelMutateDrawer({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
<CollapsibleContent className='space-y-6 pt-6'>
|
<CollapsibleContent className='flex flex-col gap-4 pt-4'>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='cacheRatio'
|
name='cacheRatio'
|
||||||
@ -1226,20 +1227,18 @@ export function ModelMutateDrawer({
|
|||||||
</Collapsible>
|
</Collapsible>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* Status & Sync */}
|
{/* Status & Sync */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-semibold'>{t('Status & Sync')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Status & Sync')}</h3>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='status'
|
name='status'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
|
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||||
<div className='space-y-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<FormLabel className='text-base'>
|
<FormLabel className='text-base'>
|
||||||
{t('Enabled')}
|
{t('Enabled')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@ -1261,8 +1260,8 @@ export function ModelMutateDrawer({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name='sync_official'
|
name='sync_official'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className='flex items-center justify-between rounded-lg border p-4'>
|
<FormItem className={sideDrawerSwitchItemClassName()}>
|
||||||
<div className='space-y-0.5'>
|
<div className='flex flex-col gap-0.5'>
|
||||||
<FormLabel className='text-base'>
|
<FormLabel className='text-base'>
|
||||||
{t('Official Sync')}
|
{t('Official Sync')}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
@ -1279,11 +1278,11 @@ export function ModelMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
</form>
|
</form>
|
||||||
</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
|
<SheetClose
|
||||||
render={<Button variant='outline' disabled={isSubmitting} />}
|
render={<Button variant='outline' disabled={isSubmitting} />}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -42,7 +42,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetClose,
|
SheetClose,
|
||||||
@ -53,6 +52,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { JsonEditor } from '@/components/json-editor'
|
import { JsonEditor } from '@/components/json-editor'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { TagInput } from '@/components/tag-input'
|
import { TagInput } from '@/components/tag-input'
|
||||||
@ -180,8 +186,8 @@ export function PrefillGroupFormDrawer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||||
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
|
{isEdit ? t('Edit Prefill Group') : t('Create Prefill Group')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
@ -196,10 +202,10 @@ export function PrefillGroupFormDrawer({
|
|||||||
<form
|
<form
|
||||||
id='prefill-group-form'
|
id='prefill-group-form'
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
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'>
|
<SideDrawerSection>
|
||||||
<div className='space-y-1'>
|
<div className='flex flex-col gap-1'>
|
||||||
<h3 className='text-sm font-semibold'>{t('Group details')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Group details')}</h3>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
{t(
|
{t(
|
||||||
@ -252,12 +258,10 @@ export function PrefillGroupFormDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
<Separator />
|
<SideDrawerSection>
|
||||||
|
<div className='flex flex-col gap-1'>
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<h3 className='text-sm font-semibold'>{t('Configuration')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Configuration')}</h3>
|
||||||
<p className='text-muted-foreground text-sm'>
|
<p className='text-muted-foreground text-sm'>
|
||||||
{t('Choose the bundle type and define the items inside it.')}
|
{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'>
|
<div className='flex items-center gap-2'>
|
||||||
<h4 className='text-sm font-medium'>{t('Project')}</h4>
|
<h4 className='text-sm font-medium'>{t('Project')}</h4>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
@ -379,11 +383,11 @@ export function PrefillGroupFormDrawer({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
</form>
|
</form>
|
||||||
</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
|
<SheetClose
|
||||||
render={
|
render={
|
||||||
<Button type='button' variant='outline' disabled={isSaving} />
|
<Button type='button' variant='outline' disabled={isSaving} />
|
||||||
|
|||||||
@ -29,7 +29,8 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
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 {
|
import {
|
||||||
getModelStatusConfig,
|
getModelStatusConfig,
|
||||||
getNameRuleConfig,
|
getNameRuleConfig,
|
||||||
@ -47,25 +48,12 @@ function renderLimitedItems(
|
|||||||
items: React.ReactNode[],
|
items: React.ReactNode[],
|
||||||
maxDisplay: number = 2
|
maxDisplay: number = 2
|
||||||
): React.ReactNode {
|
): 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 (
|
return (
|
||||||
<div className='flex max-w-full items-center gap-1 overflow-x-auto'>
|
<StatusBadgeList
|
||||||
{displayed}
|
items={items}
|
||||||
{remaining > 0 && (
|
max={maxDisplay}
|
||||||
<StatusBadge
|
renderItem={(item) => item}
|
||||||
label={`+${remaining}`}
|
/>
|
||||||
variant='neutral'
|
|
||||||
size='sm'
|
|
||||||
copyable={false}
|
|
||||||
className='flex-shrink-0'
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,15 +106,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const id = row.getValue('id') as number
|
const id = row.getValue('id') as number
|
||||||
return (
|
return <TableId value={id} />
|
||||||
<StatusBadge
|
|
||||||
label={String(id)}
|
|
||||||
variant='neutral'
|
|
||||||
copyText={String(id)}
|
|
||||||
size='sm'
|
|
||||||
className='font-mono'
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
size: 80,
|
size: 80,
|
||||||
},
|
},
|
||||||
@ -250,7 +230,6 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={config.label}
|
label={config.label}
|
||||||
variant={config.variant}
|
variant={config.variant}
|
||||||
showDot={config.showDot}
|
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
12
web/default/src/features/models/constants.ts
vendored
12
web/default/src/features/models/constants.ts
vendored
@ -79,12 +79,9 @@ export function getModelStatusOptions(t: TFunction) {
|
|||||||
|
|
||||||
export function getModelStatusConfig(
|
export function getModelStatusConfig(
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): Record<
|
): Record<ModelStatus, { label: string; variant: 'success' | 'neutral' }> {
|
||||||
ModelStatus,
|
|
||||||
{ label: string; variant: 'success' | 'neutral'; showDot?: boolean }
|
|
||||||
> {
|
|
||||||
return {
|
return {
|
||||||
1: { label: t('Enabled'), variant: 'success', showDot: true },
|
1: { label: t('Enabled'), variant: 'success' },
|
||||||
0: { label: t('Disabled'), variant: 'neutral' },
|
0: { label: t('Disabled'), variant: 'neutral' },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,11 +119,10 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
|
|||||||
{
|
{
|
||||||
label: string
|
label: string
|
||||||
variant: 'success' | 'neutral' | 'warning' | 'danger'
|
variant: 'success' | 'neutral' | 'warning' | 'danger'
|
||||||
showDot?: boolean
|
|
||||||
}
|
}
|
||||||
> {
|
> {
|
||||||
return {
|
return {
|
||||||
running: { label: t('Running'), variant: 'success', showDot: true },
|
running: { label: t('Running'), variant: 'success' },
|
||||||
completed: { label: t('Completed'), variant: 'success' },
|
completed: { label: t('Completed'), variant: 'success' },
|
||||||
failed: { label: t('Failed'), variant: 'danger' },
|
failed: { label: t('Failed'), variant: 'danger' },
|
||||||
error: { label: t('Failed'), variant: 'danger' },
|
error: { label: t('Failed'), variant: 'danger' },
|
||||||
@ -134,12 +130,10 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
|
|||||||
'deployment requested': {
|
'deployment requested': {
|
||||||
label: t('Deployment requested'),
|
label: t('Deployment requested'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
'termination requested': {
|
'termination requested': {
|
||||||
label: t('Termination requested'),
|
label: t('Termination requested'),
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -311,13 +311,13 @@ export function DynamicPricingBreakdown({
|
|||||||
<Table className='text-sm'>
|
<Table className='text-sm'>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className='hover:bg-transparent'>
|
<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')}
|
{t('Tier')}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
{visiblePriceFields.map((v) => (
|
{visiblePriceFields.map((v) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
key={v.field}
|
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)}
|
{t(v.shortLabel)}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
|||||||
@ -574,14 +574,10 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||||
<TableHead className='h-9 w-44 text-xs'>
|
<TableHead className='h-9 w-44'>{t('Parameter')}</TableHead>
|
||||||
{t('Parameter')}
|
<TableHead className='h-9 w-24'>{t('Type')}</TableHead>
|
||||||
</TableHead>
|
<TableHead className='h-9 w-32'>{t('Default / range')}</TableHead>
|
||||||
<TableHead className='h-9 w-24 text-xs'>{t('Type')}</TableHead>
|
<TableHead className='h-9'>{t('Description')}</TableHead>
|
||||||
<TableHead className='h-9 w-32 text-xs'>
|
|
||||||
{t('Default / range')}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className='h-9 text-xs'>{t('Description')}</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -589,13 +585,13 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
|||||||
<TableRow key={p.name} className='hover:bg-muted/20'>
|
<TableRow key={p.name} className='hover:bg-muted/20'>
|
||||||
<TableCell className='py-2 align-top'>
|
<TableCell className='py-2 align-top'>
|
||||||
<div className='flex items-center gap-1.5'>
|
<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}
|
{p.name}
|
||||||
</code>
|
</code>
|
||||||
{p.required && (
|
{p.required && (
|
||||||
<Badge
|
<Badge
|
||||||
variant='outline'
|
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')}
|
{t('required')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -605,7 +601,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
|||||||
<TableCell className='py-2 align-top'>
|
<TableCell className='py-2 align-top'>
|
||||||
<Badge
|
<Badge
|
||||||
variant='secondary'
|
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}
|
{p.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -613,7 +609,7 @@ function SupportedParametersSection(props: { model: PricingModel }) {
|
|||||||
<TableCell className='py-2 align-top'>
|
<TableCell className='py-2 align-top'>
|
||||||
<ParamRangeCell param={p} />
|
<ParamRangeCell param={p} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='text-muted-foreground py-2 align-top text-xs'>
|
<TableCell className='text-muted-foreground py-2 align-top'>
|
||||||
{t(p.descriptionKey)}
|
{t(p.descriptionKey)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -630,21 +626,19 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
|
|||||||
if (defaultValue !== undefined) {
|
if (defaultValue !== undefined) {
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-wrap items-center gap-1'>
|
<div className='flex flex-wrap items-center gap-1'>
|
||||||
<span className='text-muted-foreground text-[11px]'>=</span>
|
<span className='text-muted-foreground text-sm'>=</span>
|
||||||
<code className='bg-muted rounded px-1 py-0.5 font-mono text-[11px]'>
|
<code className='bg-muted rounded px-1.5 py-0.5 font-mono text-sm'>
|
||||||
{String(defaultValue)}
|
{String(defaultValue)}
|
||||||
</code>
|
</code>
|
||||||
{range && (
|
{range && (
|
||||||
<span className='text-muted-foreground text-[11px]'>{range}</span>
|
<span className='text-muted-foreground text-sm'>{range}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (range) {
|
if (range) {
|
||||||
return (
|
return (
|
||||||
<span className='text-muted-foreground font-mono text-[11px]'>
|
<span className='text-muted-foreground font-mono text-sm'>{range}</span>
|
||||||
{range}
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (enumValues && enumValues.length > 0) {
|
if (enumValues && enumValues.length > 0) {
|
||||||
@ -653,7 +647,7 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
|
|||||||
{enumValues.map((v) => (
|
{enumValues.map((v) => (
|
||||||
<code
|
<code
|
||||||
key={v}
|
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}
|
{v}
|
||||||
</code>
|
</code>
|
||||||
@ -661,7 +655,7 @@ function ParamRangeCell(props: { param: SupportedParameter }) {
|
|||||||
</div>
|
</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>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
<TableRow className='bg-muted/30 hover:bg-muted/30'>
|
||||||
<TableHead className='h-9 text-xs'>{t('Group')}</TableHead>
|
<TableHead className='h-9'>{t('Group')}</TableHead>
|
||||||
<TableHead className='h-9 text-right text-xs'>RPM</TableHead>
|
<TableHead className='h-9 text-right'>RPM</TableHead>
|
||||||
<TableHead className='h-9 text-right text-xs'>TPM</TableHead>
|
<TableHead className='h-9 text-right'>TPM</TableHead>
|
||||||
<TableHead className='h-9 text-right text-xs'>RPD</TableHead>
|
<TableHead className='h-9 text-right'>RPD</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{limits.map((l) => (
|
{limits.map((l) => (
|
||||||
<TableRow key={l.group} className='hover:bg-muted/20'>
|
<TableRow key={l.group} className='hover:bg-muted/20'>
|
||||||
<TableCell className='py-2 font-mono text-xs'>
|
<TableCell className='py-2 font-mono'>{l.group}</TableCell>
|
||||||
{l.group}
|
<TableCell className='py-2 text-right font-mono'>
|
||||||
</TableCell>
|
|
||||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
|
||||||
{formatRateLimit(l.rpm)}
|
{formatRateLimit(l.rpm)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
<TableCell className='py-2 text-right font-mono'>
|
||||||
{formatRateLimit(l.tpm)}
|
{formatRateLimit(l.tpm)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='py-2 text-right font-mono text-xs'>
|
<TableCell className='py-2 text-right font-mono'>
|
||||||
{formatRateLimit(l.rpd)}
|
{formatRateLimit(l.rpd)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@ -199,13 +199,13 @@ export function ModelDetailsApps(props: { model: PricingModel }) {
|
|||||||
<div className='text-sm font-medium'>
|
<div className='text-sm font-medium'>
|
||||||
<AppLink app={app} />
|
<AppLink app={app} />
|
||||||
</div>
|
</div>
|
||||||
<p className='text-muted-foreground line-clamp-1 text-xs'>
|
<p className='text-muted-foreground line-clamp-1 text-sm'>
|
||||||
{app.description}
|
{app.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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}
|
{app.category}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
|
<TableCell className='py-2.5 text-right font-mono tabular-nums'>
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { CopyButton } from '@/components/copy-button'
|
import { CopyButton } from '@/components/copy-button'
|
||||||
|
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { PublicLayout } from '@/components/layout'
|
import { PublicLayout } from '@/components/layout'
|
||||||
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
import { getPerfMetrics } from '@/features/performance-metrics/api'
|
||||||
@ -735,7 +736,7 @@ function GroupPricingSection(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={`${group}-${tier.label || tierIndex}`}>
|
<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')}
|
{tier.label || t('Default')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{priceFields.map((fieldEntry) => {
|
{priceFields.map((fieldEntry) => {
|
||||||
@ -808,7 +809,7 @@ function GroupPricingSection(props: {
|
|||||||
<TableCell className='py-2.5'>
|
<TableCell className='py-2.5'>
|
||||||
<GroupBadge group={group} size='sm' />
|
<GroupBadge group={group} size='sm' />
|
||||||
</TableCell>
|
</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
|
{ratio}x
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{isTokenBased ? (
|
{isTokenBased ? (
|
||||||
@ -1006,7 +1007,9 @@ export function ModelDetailsDrawer(props: ModelDetailsDrawerProps) {
|
|||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side='right'
|
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'>
|
<SheetHeader className='sr-only'>
|
||||||
<SheetTitle>{props.model.model_name}</SheetTitle>
|
<SheetTitle>{props.model.model_name}</SheetTitle>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
|
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
||||||
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
import { DEFAULT_TOKEN_UNIT, QUOTA_TYPE_VALUES } from '../constants'
|
||||||
import {
|
import {
|
||||||
getDynamicDisplayGroupRatio,
|
getDynamicDisplayGroupRatio,
|
||||||
@ -56,19 +57,15 @@ function renderLimitedTags(
|
|||||||
items: string[],
|
items: string[],
|
||||||
maxDisplay: number = 3
|
maxDisplay: number = 3
|
||||||
): React.ReactNode {
|
): 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 (
|
return (
|
||||||
<span className='text-muted-foreground text-xs'>
|
<StatusBadgeList
|
||||||
{displayed.join(', ')}
|
items={items}
|
||||||
{remaining > 0 && (
|
max={maxDisplay}
|
||||||
<span className='text-muted-foreground/50'> +{remaining}</span>
|
getKey={(item) => item}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<StatusBadge label={item} autoColor={item} size='sm' copyable={false} />
|
||||||
)}
|
)}
|
||||||
</span>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,21 +73,13 @@ function renderLimitedGroupBadges(
|
|||||||
groups: string[],
|
groups: string[],
|
||||||
maxDisplay: number = 2
|
maxDisplay: number = 2
|
||||||
): React.ReactNode {
|
): 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 (
|
return (
|
||||||
<div className='flex max-w-full items-center gap-1 overflow-hidden'>
|
<StatusBadgeList
|
||||||
{displayed.map((group) => (
|
items={groups}
|
||||||
<GroupBadge key={group} group={group} size='sm' />
|
max={maxDisplay}
|
||||||
))}
|
getKey={(group) => group}
|
||||||
{remaining > 0 && (
|
renderItem={(group) => <GroupBadge group={group} size='sm' />}
|
||||||
<span className='text-muted-foreground/50 text-xs'>+{remaining}</span>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,9 +130,11 @@ export function usePricingColumns(
|
|||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
const isTokenBased = row.original.quota_type === QUOTA_TYPE_VALUES.TOKEN
|
||||||
return (
|
return (
|
||||||
<span className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
|
<StatusBadge
|
||||||
{isTokenBased ? t('Token') : t('Request')}
|
label={isTokenBased ? t('Token') : t('Request')}
|
||||||
</span>
|
variant={isTokenBased ? 'info' : 'neutral'}
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
size: 80,
|
size: 80,
|
||||||
@ -365,9 +356,14 @@ export function usePricingColumns(
|
|||||||
? getLobeIcon(model.vendor_icon, 12)
|
? getLobeIcon(model.vendor_icon, 12)
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<span className='text-muted-foreground flex items-center gap-1.5 text-xs'>
|
<span className='flex items-center gap-1.5'>
|
||||||
{vendorIcon}
|
{vendorIcon}
|
||||||
{model.vendor_name}
|
<StatusBadge
|
||||||
|
label={model.vendor_name}
|
||||||
|
autoColor={model.vendor_name}
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -102,7 +102,7 @@ export function PricingTable(props: PricingTableProps) {
|
|||||||
<TableHead
|
<TableHead
|
||||||
key={header.id}
|
key={header.id}
|
||||||
style={{ width: header.getSize() }}
|
style={{ width: header.getSize() }}
|
||||||
className='text-muted-foreground text-[10px] font-medium tracking-wider uppercase'
|
className='text-muted-foreground font-medium'
|
||||||
>
|
>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
|
|||||||
@ -40,6 +40,11 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import {
|
import {
|
||||||
VIEW_MODES,
|
VIEW_MODES,
|
||||||
getSortLabels,
|
getSortLabels,
|
||||||
@ -269,15 +274,15 @@ export function PricingToolbar(props: PricingToolbarProps) {
|
|||||||
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
<Sheet open={mobileFiltersOpen} onOpenChange={setMobileFiltersOpen}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side='right'
|
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>
|
<SheetTitle>{t('Filter')}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{t('Filter models by provider, group, type, endpoint, and tags.')}
|
{t('Filter models by provider, group, type, endpoint, and tags.')}
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className='flex-1 overflow-y-auto p-3 sm:p-4'>
|
<div className={sideDrawerFormClassName('gap-0')}>
|
||||||
<PricingSidebar
|
<PricingSidebar
|
||||||
quotaTypeFilter={props.quotaTypeFilter}
|
quotaTypeFilter={props.quotaTypeFilter}
|
||||||
endpointTypeFilter={props.endpointTypeFilter}
|
endpointTypeFilter={props.endpointTypeFilter}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
import { DataTableColumnHeader } from '@/components/data-table'
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
import { MaskedValueDisplay } from '@/components/masked-value-display'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableId } from '@/components/table-id'
|
||||||
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
|
import { REDEMPTION_FILTER_EXPIRED, REDEMPTION_STATUSES } from '../constants'
|
||||||
import { isRedemptionExpired, isTimestampExpired } from '../lib'
|
import { isRedemptionExpired, isTimestampExpired } from '../lib'
|
||||||
import { type Redemption } from '../types'
|
import { type Redemption } from '../types'
|
||||||
@ -66,7 +67,9 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
|||||||
<DataTableColumnHeader column={column} title={t('ID')} />
|
<DataTableColumnHeader column={column} title={t('ID')} />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
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
|
<StatusBadge
|
||||||
label={t('Expired')}
|
label={t('Expired')}
|
||||||
variant='warning'
|
variant='warning'
|
||||||
showDot={true}
|
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -115,7 +117,6 @@ export function useRedemptionsColumns(): ColumnDef<Redemption>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t(statusConfig.labelKey)}
|
label={t(statusConfig.labelKey)}
|
||||||
variant={statusConfig.variant}
|
variant={statusConfig.variant}
|
||||||
showDot={statusConfig.showDot}
|
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -44,6 +44,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { DateTimePicker } from '@/components/datetime-picker'
|
import { DateTimePicker } from '@/components/datetime-picker'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { createRedemption, updateRedemption, getRedemption } from '../api'
|
import { createRedemption, updateRedemption, getRedemption } from '../api'
|
||||||
import { SUCCESS_MESSAGES } from '../constants'
|
import { SUCCESS_MESSAGES } from '../constants'
|
||||||
import {
|
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]'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isUpdate
|
{isUpdate
|
||||||
? t('Update Redemption Code')
|
? t('Update Redemption Code')
|
||||||
@ -171,141 +178,143 @@ export function RedemptionsMutateDrawer({
|
|||||||
<form
|
<form
|
||||||
id='redemption-form'
|
id='redemption-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
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
|
<SideDrawerSection>
|
||||||
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 && (
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name='count'
|
name='name'
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('Quantity')}</FormLabel>
|
<FormLabel>{t('Name')}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input {...field} placeholder={t('Enter a name')} />
|
||||||
{...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>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t('Create multiple redemption codes at once (1-100)')}
|
{t('Name for this redemption code (1-20 characters)')}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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>
|
||||||
</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' />}>
|
<SheetClose render={<Button variant='outline' />}>
|
||||||
{t('Close')}
|
{t('Close')}
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
|
|||||||
@ -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
|
// labelKey values are i18n keys; use t(config.labelKey) in components
|
||||||
export const REDEMPTION_STATUSES: Record<
|
export const REDEMPTION_STATUSES: Record<
|
||||||
number,
|
number,
|
||||||
Pick<StatusBadgeProps, 'variant' | 'showDot'> & {
|
Pick<StatusBadgeProps, 'variant'> & {
|
||||||
labelKey: string
|
labelKey: string
|
||||||
value: number
|
value: number
|
||||||
}
|
}
|
||||||
@ -45,19 +45,16 @@ export const REDEMPTION_STATUSES: Record<
|
|||||||
labelKey: 'Unused',
|
labelKey: 'Unused',
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
value: REDEMPTION_STATUS.ENABLED,
|
value: REDEMPTION_STATUS.ENABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[REDEMPTION_STATUS.DISABLED]: {
|
[REDEMPTION_STATUS.DISABLED]: {
|
||||||
labelKey: 'Disabled',
|
labelKey: 'Disabled',
|
||||||
variant: 'neutral',
|
variant: 'neutral',
|
||||||
value: REDEMPTION_STATUS.DISABLED,
|
value: REDEMPTION_STATUS.DISABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[REDEMPTION_STATUS.USED]: {
|
[REDEMPTION_STATUS.USED]: {
|
||||||
labelKey: 'Used',
|
labelKey: 'Used',
|
||||||
variant: 'neutral',
|
variant: 'neutral',
|
||||||
value: REDEMPTION_STATUS.USED,
|
value: REDEMPTION_STATUS.USED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@ -45,7 +45,13 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableId } from '@/components/table-id'
|
||||||
import {
|
import {
|
||||||
getAdminPlans,
|
getAdminPlans,
|
||||||
getUserSubscriptions,
|
getUserSubscriptions,
|
||||||
@ -191,15 +197,15 @@ export function UserSubscriptionsDialog(props: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
<Sheet open={props.open} onOpenChange={props.onOpenChange}>
|
||||||
<SheetContent className='overflow-y-auto sm:max-w-2xl'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-2xl')}>
|
||||||
<SheetHeader>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>{t('User Subscription Management')}</SheetTitle>
|
<SheetTitle>{t('User Subscription Management')}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{props.user?.username || '-'} (ID: {props.user?.id || '-'})
|
{props.user?.username || '-'} (ID: {props.user?.id || '-'})
|
||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className='mt-4 space-y-4'>
|
<div className={sideDrawerFormClassName()}>
|
||||||
<div className='flex gap-2'>
|
<div className='flex gap-2'>
|
||||||
<Select
|
<Select
|
||||||
items={[
|
items={[
|
||||||
@ -279,14 +285,16 @@ export function UserSubscriptionsDialog(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={sub.id}>
|
<TableRow key={sub.id}>
|
||||||
<TableCell>#{sub.id}</TableCell>
|
<TableCell>
|
||||||
|
<TableId value={sub.id} />
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<div className='font-medium'>
|
<div className='font-medium'>
|
||||||
{planTitleMap.get(sub.plan_id) ||
|
{planTitleMap.get(sub.plan_id) ||
|
||||||
`#${sub.plan_id}`}
|
`#${sub.plan_id}`}
|
||||||
</div>
|
</div>
|
||||||
<div className='text-muted-foreground text-xs'>
|
<div className='text-muted-foreground text-sm'>
|
||||||
{t('Source')}: {sub.source || '-'}
|
{t('Source')}: {sub.source || '-'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -295,7 +303,7 @@ export function UserSubscriptionsDialog(props: Props) {
|
|||||||
<SubscriptionStatusBadge sub={sub} t={t} />
|
<SubscriptionStatusBadge sub={sub} t={t} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='text-xs'>
|
<div className='text-sm'>
|
||||||
<div>
|
<div>
|
||||||
{t('Start')}: {formatTimestamp(sub.start_time)}
|
{t('Start')}: {formatTimestamp(sub.start_time)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { formatQuota } from '@/lib/format'
|
|||||||
import { DataTableColumnHeader } from '@/components/data-table'
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
|
import { TableId } from '@/components/table-id'
|
||||||
import { formatDuration, formatResetPeriod } from '../lib'
|
import { formatDuration, formatResetPeriod } from '../lib'
|
||||||
import type { PlanRecord } from '../types'
|
import type { PlanRecord } from '../types'
|
||||||
import { DataTableRowActions } from './data-table-row-actions'
|
import { DataTableRowActions } from './data-table-row-actions'
|
||||||
@ -39,9 +40,7 @@ export function useSubscriptionsColumns(): ColumnDef<PlanRecord>[] {
|
|||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<DataTableColumnHeader column={column} title='ID' />
|
<DataTableColumnHeader column={column} title='ID' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <TableId value={row.original.plan.id} />,
|
||||||
<span className='text-muted-foreground'>#{row.original.plan.id}</span>
|
|
||||||
),
|
|
||||||
size: 60,
|
size: 60,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -51,6 +51,14 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
sideDrawerSwitchItemClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import {
|
import {
|
||||||
createPlan,
|
createPlan,
|
||||||
updatePlan,
|
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]'>
|
<SheetContent className={sideDrawerContentClassName('sm:max-w-[600px]')}>
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isEdit ? t('Update plan info') : t('Create new subscription plan')}
|
{isEdit ? t('Update plan info') : t('Create new subscription plan')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
@ -260,10 +268,10 @@ export function SubscriptionsMutateDrawer({
|
|||||||
<form
|
<form
|
||||||
id='subscription-form'
|
id='subscription-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
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 */}
|
{/* Basic Info */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
||||||
<Settings2 className='h-4 w-4' />
|
<Settings2 className='h-4 w-4' />
|
||||||
{t('Basic Info')}
|
{t('Basic Info')}
|
||||||
@ -440,24 +448,24 @@ export function SubscriptionsMutateDrawer({
|
|||||||
control={form.control}
|
control={form.control}
|
||||||
name='enabled'
|
name='enabled'
|
||||||
render={({ field }) => (
|
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>
|
<FormControl>
|
||||||
<Switch
|
<Switch
|
||||||
checked={field.value}
|
checked={field.value}
|
||||||
onCheckedChange={field.onChange}
|
onCheckedChange={field.onChange}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormLabel className='!mt-0'>
|
|
||||||
{t('Enabled Status')}
|
|
||||||
</FormLabel>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Duration Settings */}
|
{/* Duration Settings */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
||||||
<CalendarClock className='h-4 w-4' />
|
<CalendarClock className='h-4 w-4' />
|
||||||
{t('Duration Settings')}
|
{t('Duration Settings')}
|
||||||
@ -544,10 +552,10 @@ export function SubscriptionsMutateDrawer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Quota Reset */}
|
{/* Quota Reset */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
||||||
<RefreshCw className='h-4 w-4' />
|
<RefreshCw className='h-4 w-4' />
|
||||||
{t('Quota Reset')}
|
{t('Quota Reset')}
|
||||||
@ -612,10 +620,10 @@ export function SubscriptionsMutateDrawer({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Payment Config */}
|
{/* Payment Config */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
<h3 className='flex items-center gap-2 text-sm font-medium'>
|
||||||
<CreditCard className='h-4 w-4' />
|
<CreditCard className='h-4 w-4' />
|
||||||
{t('Third-party Payment Config')}
|
{t('Third-party Payment Config')}
|
||||||
@ -709,10 +717,10 @@ export function SubscriptionsMutateDrawer({
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
</form>
|
</form>
|
||||||
</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' />}>
|
<SheetClose render={<Button variant='outline' />}>
|
||||||
{t('Close')}
|
{t('Close')}
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export function ProviderTable(props: ProviderTableProps) {
|
|||||||
{provider.icon ? (
|
{provider.icon ? (
|
||||||
<span className='text-lg'>{provider.icon}</span>
|
<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>
|
||||||
<TableCell className='font-medium'>{provider.name}</TableCell>
|
<TableCell className='font-medium'>{provider.name}</TableCell>
|
||||||
@ -105,7 +105,7 @@ export function ProviderTable(props: ProviderTableProps) {
|
|||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</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}
|
{provider.client_id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='text-right'>
|
<TableCell className='text-right'>
|
||||||
|
|||||||
@ -41,7 +41,7 @@ import {
|
|||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
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 { SettingsSwitchField } from '../../components/settings-form-layout'
|
||||||
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
|
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
|
||||||
import { SettingsSection } from '../../components/settings-section'
|
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 {
|
function serializeRules(rules: AffinityRule[]): string {
|
||||||
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
|
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
|
||||||
}
|
}
|
||||||
@ -500,65 +518,15 @@ export function ChannelAffinitySection(props: Props) {
|
|||||||
{rule.name || '-'}
|
{rule.name || '-'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
<RuleBadgeList items={rule.model_regex || []} />
|
||||||
{(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>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
<RuleBadgeList
|
||||||
{(rule.key_sources || []).length > 0 && (
|
items={(rule.key_sources || []).map(
|
||||||
<span
|
(src) =>
|
||||||
className='size-1.5 shrink-0 rounded-full bg-slate-400'
|
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{(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>
|
||||||
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@ -582,27 +550,7 @@ export function ChannelAffinitySection(props: Props) {
|
|||||||
rule.include_rule_name && t('Rule'),
|
rule.include_rule_name && t('Rule'),
|
||||||
].filter(Boolean) as string[]
|
].filter(Boolean) as string[]
|
||||||
if (scopeItems.length === 0) return '-'
|
if (scopeItems.length === 0) return '-'
|
||||||
return (
|
return <RuleBadgeList items={scopeItems} />
|
||||||
<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>
|
|
||||||
)
|
|
||||||
})()}
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@ -166,7 +166,7 @@ export function AmountDiscountVisualEditor({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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)}
|
{discount.discountRate.toFixed(2)}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -201,7 +201,7 @@ export function CreemProductsVisualEditor({
|
|||||||
{product.name}
|
{product.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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}
|
{product.productId}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -311,7 +311,7 @@ export function PaymentMethodsVisualEditor({
|
|||||||
{method.name}
|
{method.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<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}
|
{method.type}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -323,7 +323,7 @@ export function PaymentMethodsVisualEditor({
|
|||||||
style={{ backgroundColor: colorPreview }}
|
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}
|
{method.color}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -91,12 +91,12 @@ export function ConflictConfirmDialog({
|
|||||||
{conflict.model}
|
{conflict.model}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<pre className='text-xs whitespace-pre-wrap'>
|
<pre className='text-sm whitespace-pre-wrap'>
|
||||||
{conflict.current}
|
{conflict.current}
|
||||||
</pre>
|
</pre>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<pre className='text-xs whitespace-pre-wrap'>
|
<pre className='text-sm whitespace-pre-wrap'>
|
||||||
{conflict.newVal}
|
{conflict.newVal}
|
||||||
</pre>
|
</pre>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@ -45,6 +45,11 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import {
|
import {
|
||||||
SettingsForm,
|
SettingsForm,
|
||||||
SettingsSwitchContent,
|
SettingsSwitchContent,
|
||||||
@ -337,8 +342,11 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
|
<SheetContent
|
||||||
<SheetHeader className='border-b p-4'>
|
side='right'
|
||||||
|
className={sideDrawerContentClassName('sm:max-w-2xl')}
|
||||||
|
>
|
||||||
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
|
<SheetTitle>{t('Group pricing usage guide')}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
{t(
|
{t(
|
||||||
@ -347,7 +355,7 @@ function GroupPricingGuide({ open, onOpenChange }: GroupPricingGuideProps) {
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className='space-y-5 overflow-y-auto p-4'>
|
<div className={sideDrawerFormClassName('gap-5')}>
|
||||||
<section className='space-y-2'>
|
<section className='space-y-2'>
|
||||||
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
|
<h3 className='text-sm font-semibold'>{t('Core concepts')}</h3>
|
||||||
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
|
<div className='text-muted-foreground space-y-2 text-sm leading-6'>
|
||||||
|
|||||||
@ -61,6 +61,10 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
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 { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
||||||
import {
|
import {
|
||||||
SettingsControlGroup,
|
SettingsControlGroup,
|
||||||
@ -387,7 +391,10 @@ export function ModelPricingSheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<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'>
|
<SheetHeader className='sr-only'>
|
||||||
<SheetTitle>{title}</SheetTitle>
|
<SheetTitle>{title}</SheetTitle>
|
||||||
<SheetDescription>{description}</SheetDescription>
|
<SheetDescription>{description}</SheetDescription>
|
||||||
@ -733,7 +740,7 @@ export function ModelPricingEditorPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -948,7 +955,11 @@ export function ModelPricingEditorPanel({
|
|||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</div>
|
</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'>
|
<div className='text-muted-foreground text-xs'>
|
||||||
{selectedTargetCount > 0
|
{selectedTargetCount > 0
|
||||||
? t('{{count}} selected targets available for bulk copy.', {
|
? t('{{count}} selected targets available for bulk copy.', {
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import { useState } from 'react'
|
|||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { Zap } from 'lucide-react'
|
import { Zap } from 'lucide-react'
|
||||||
import { formatTimestampToDate, formatTokens } from '@/lib/format'
|
import { formatTimestampToDate, formatTokens } from '@/lib/format'
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
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
|
* Create a duration column - pill style matching common logs timing
|
||||||
*/
|
*/
|
||||||
@ -163,25 +132,16 @@ export function createDurationColumn<T>(config: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const variant =
|
const variant =
|
||||||
duration.durationSec > warningThresholdSec ? 'red' : 'green'
|
duration.durationSec > warningThresholdSec ? 'danger' : 'success'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
label={`${duration.durationSec.toFixed(1)}s`}
|
||||||
'inline-flex w-fit items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
variant={variant}
|
||||||
durationPillBg[variant],
|
size='sm'
|
||||||
durationTextColor[variant]
|
copyable={false}
|
||||||
)}
|
className='font-mono'
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'size-1.5 shrink-0 rounded-full',
|
|
||||||
durationDotColor[variant]
|
|
||||||
)}
|
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
{duration.durationSec.toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { label: headerLabel },
|
meta: { label: headerLabel },
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import {
|
|||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { DataTableColumnHeader } from '@/components/data-table'
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
|
||||||
|
import { LOG_TYPE_ALL_VALUE } from '../../constants'
|
||||||
import type { UsageLog } from '../../data/schema'
|
import type { UsageLog } from '../../data/schema'
|
||||||
import {
|
import {
|
||||||
formatModelName,
|
formatModelName,
|
||||||
@ -281,7 +282,8 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
filterFn: (row, _id, value) => {
|
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))
|
return value.includes(String(row.original.type))
|
||||||
},
|
},
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@ -488,7 +490,6 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
icon={KeyRound}
|
icon={KeyRound}
|
||||||
copyText={sensitiveVisible ? tokenName : undefined}
|
copyText={sensitiveVisible ? tokenName : undefined}
|
||||||
size='sm'
|
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'
|
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>
|
</TooltipTrigger>
|
||||||
@ -554,59 +555,32 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
const timeVariant = getResponseTimeColor(useTime, log.completion_tokens)
|
||||||
const frtVariant = frt ? getFirstResponseTimeColor(frt / 1000) : null
|
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 (
|
return (
|
||||||
<div className='flex flex-col gap-1'>
|
<div className='flex flex-col gap-1'>
|
||||||
<div className='flex items-center gap-1.5'>
|
<div className='flex items-center gap-1.5'>
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
label={formatUseTime(useTime)}
|
||||||
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
variant={timeVariant as StatusBadgeProps['variant']}
|
||||||
pillBg[timeVariant],
|
size='sm'
|
||||||
pillText[timeVariant]
|
copyable={false}
|
||||||
)}
|
className='font-mono'
|
||||||
>
|
/>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'size-1.5 shrink-0 rounded-full',
|
|
||||||
pillDot[timeVariant]
|
|
||||||
)}
|
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
{formatUseTime(useTime)}
|
|
||||||
</span>
|
|
||||||
{log.is_stream &&
|
{log.is_stream &&
|
||||||
(frt != null && frt > 0 ? (
|
(frt != null && frt > 0 ? (
|
||||||
<span
|
<StatusBadge
|
||||||
className={cn(
|
label={formatUseTime(frt / 1000)}
|
||||||
'inline-flex items-center rounded-md px-1.5 py-0.5 font-mono text-xs font-medium',
|
variant={frtVariant as StatusBadgeProps['variant']}
|
||||||
pillBg[frtVariant!],
|
size='sm'
|
||||||
pillText[frtVariant!]
|
copyable={false}
|
||||||
)}
|
className='font-mono'
|
||||||
>
|
/>
|
||||||
{formatUseTime(frt / 1000)}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className='border-border/60 text-muted-foreground/50 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[11px]'>
|
<StatusBadge
|
||||||
N/A
|
label='N/A'
|
||||||
</span>
|
variant='neutral'
|
||||||
|
size='sm'
|
||||||
|
copyable={false}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-1 text-[11px]'>
|
<div className='flex items-center gap-1 text-[11px]'>
|
||||||
@ -724,15 +698,15 @@ export function useCommonLogsColumns(isAdmin: boolean): ColumnDef<UsageLog>[] {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
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>
|
<TooltipContent>
|
||||||
<span>
|
<span>
|
||||||
{t('Deducted by subscription')}: {formatLogQuota(quota)}
|
{t('Deducted by subscription')}: {formatLogQuota(quota)}
|
||||||
|
|||||||
@ -132,7 +132,6 @@ export function useDrawingLogsColumns(
|
|||||||
icon={getDrawingTypeIcon(action)}
|
icon={getDrawingTypeIcon(action)}
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
showDot={false}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -157,7 +156,6 @@ export function useDrawingLogsColumns(
|
|||||||
label={mjId}
|
label={mjId}
|
||||||
autoColor={mjId}
|
autoColor={mjId}
|
||||||
size='sm'
|
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'
|
className='border-border/60 bg-muted/30 max-w-full truncate rounded-md border px-1.5 py-0.5 font-mono'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -189,7 +187,6 @@ export function useDrawingLogsColumns(
|
|||||||
variant={mjSubmitResultMapper.getVariant(String(code))}
|
variant={mjSubmitResultMapper.getVariant(String(code))}
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
copyable={false}
|
||||||
showDot
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@ -183,7 +183,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
|||||||
label={taskId}
|
label={taskId}
|
||||||
autoColor={taskId}
|
autoColor={taskId}
|
||||||
size='sm'
|
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'
|
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]'>
|
<span className='text-muted-foreground/60 truncate text-[11px]'>
|
||||||
@ -214,7 +213,6 @@ export function useTaskLogsColumns(isAdmin: boolean): ColumnDef<TaskLog>[] {
|
|||||||
variant={taskStatusMapper.getVariant(status)}
|
variant={taskStatusMapper.getVariant(status)}
|
||||||
size='sm'
|
size='sm'
|
||||||
copyable={false}
|
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
|
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 { useQueryClient, useIsFetching } from '@tanstack/react-query'
|
||||||
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
import { useNavigate, getRouteApi } from '@tanstack/react-router'
|
||||||
import { type Table } from '@tanstack/react-table'
|
import { type Table } from '@tanstack/react-table'
|
||||||
@ -24,7 +24,6 @@ import { Eye, EyeOff } from 'lucide-react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useIsAdmin } from '@/hooks/use-admin'
|
import { useIsAdmin } from '@/hooks/use-admin'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -38,13 +37,17 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip'
|
} from '@/components/ui/tooltip'
|
||||||
import { DataTableToolbar } from '@/components/data-table'
|
import { LOG_TYPE_ALL_VALUE, LOG_TYPE_FILTERS } from '../constants'
|
||||||
import { LOG_TYPES } from '../constants'
|
|
||||||
import { buildSearchParams } from '../lib/filter'
|
import { buildSearchParams } from '../lib/filter'
|
||||||
import { getDefaultTimeRange } from '../lib/utils'
|
import { getDefaultTimeRange } from '../lib/utils'
|
||||||
import type { CommonLogFilters } from '../types'
|
import type { CommonLogFilters } from '../types'
|
||||||
import { CommonLogsStats } from './common-logs-stats'
|
import { CommonLogsStats } from './common-logs-stats'
|
||||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||||
|
import {
|
||||||
|
LogsFilterField,
|
||||||
|
LogsFilterInput,
|
||||||
|
LogsFilterToolbar,
|
||||||
|
} from './logs-filter-toolbar'
|
||||||
import { useUsageLogsContext } from './usage-logs-provider'
|
import { useUsageLogsContext } from './usage-logs-provider'
|
||||||
|
|
||||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||||
@ -75,30 +78,32 @@ export function CommonLogsFilterBar<TData>(
|
|||||||
const { start, end } = getDefaultTimeRange()
|
const { start, end } = getDefaultTimeRange()
|
||||||
return { startTime: start, endTime: end }
|
return { startTime: start, endTime: end }
|
||||||
})
|
})
|
||||||
const [logType, setLogType] = useState<LogTypeValue | ''>('')
|
const [logType, setLogType] = useState<LogTypeValue>(LOG_TYPE_ALL_VALUE)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const next: Partial<CommonLogFilters> = {}
|
const { start, end } = getDefaultTimeRange()
|
||||||
if (searchParams.startTime)
|
setFilters({
|
||||||
next.startTime = new Date(searchParams.startTime)
|
startTime: searchParams.startTime
|
||||||
if (searchParams.endTime) next.endTime = new Date(searchParams.endTime)
|
? new Date(searchParams.startTime)
|
||||||
if (searchParams.channel) next.channel = String(searchParams.channel)
|
: start,
|
||||||
if (searchParams.model) next.model = searchParams.model
|
endTime: searchParams.endTime ? new Date(searchParams.endTime) : end,
|
||||||
if (searchParams.token) next.token = searchParams.token
|
channel: searchParams.channel || undefined,
|
||||||
if (searchParams.group) next.group = searchParams.group
|
model: searchParams.model || undefined,
|
||||||
if (searchParams.username) next.username = searchParams.username
|
token: searchParams.token || undefined,
|
||||||
if (searchParams.requestId) next.requestId = searchParams.requestId
|
group: searchParams.group || undefined,
|
||||||
if (searchParams.upstreamRequestId)
|
username: searchParams.username || undefined,
|
||||||
next.upstreamRequestId = searchParams.upstreamRequestId
|
requestId: searchParams.requestId || undefined,
|
||||||
|
upstreamRequestId: searchParams.upstreamRequestId || undefined,
|
||||||
if (Object.keys(next).length > 0) {
|
})
|
||||||
setFilters((prev) => ({ ...prev, ...next }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeArr = searchParams.type
|
const typeArr = searchParams.type
|
||||||
if (Array.isArray(typeArr) && typeArr.length === 1) {
|
const nextLogType =
|
||||||
setLogType(typeArr[0])
|
Array.isArray(typeArr) &&
|
||||||
}
|
typeArr.length === 1 &&
|
||||||
|
isLogTypeValue(typeArr[0])
|
||||||
|
? typeArr[0]
|
||||||
|
: LOG_TYPE_ALL_VALUE
|
||||||
|
setLogType(nextLogType)
|
||||||
}, [
|
}, [
|
||||||
searchParams.startTime,
|
searchParams.startTime,
|
||||||
searchParams.endTime,
|
searchParams.endTime,
|
||||||
@ -126,7 +131,7 @@ export function CommonLogsFilterBar<TData>(
|
|||||||
params: { section: 'common' },
|
params: { section: 'common' },
|
||||||
search: {
|
search: {
|
||||||
...filterParams,
|
...filterParams,
|
||||||
...(logType ? { type: [logType] } : {}),
|
type: [logType],
|
||||||
page: 1,
|
page: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -138,13 +143,14 @@ export function CommonLogsFilterBar<TData>(
|
|||||||
const { start, end } = getDefaultTimeRange()
|
const { start, end } = getDefaultTimeRange()
|
||||||
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
|
const resetFilters: CommonLogFilters = { startTime: start, endTime: end }
|
||||||
setFilters(resetFilters)
|
setFilters(resetFilters)
|
||||||
setLogType('')
|
setLogType(LOG_TYPE_ALL_VALUE)
|
||||||
|
|
||||||
navigate({
|
navigate({
|
||||||
to: '/usage-logs/$section',
|
to: '/usage-logs/$section',
|
||||||
params: { section: 'common' },
|
params: { section: 'common' },
|
||||||
search: {
|
search: {
|
||||||
page: 1,
|
page: 1,
|
||||||
|
type: [LOG_TYPE_ALL_VALUE],
|
||||||
startTime: start.getTime(),
|
startTime: start.getTime(),
|
||||||
endTime: end.getTime(),
|
endTime: end.getTime(),
|
||||||
},
|
},
|
||||||
@ -167,11 +173,28 @@ export function CommonLogsFilterBar<TData>(
|
|||||||
!!filters.requestId ||
|
!!filters.requestId ||
|
||||||
!!filters.upstreamRequestId
|
!!filters.upstreamRequestId
|
||||||
|
|
||||||
|
const hasTypeFilter = logType !== LOG_TYPE_ALL_VALUE
|
||||||
const hasAdditionalFilters =
|
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 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 = (
|
const statsBar = (
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
@ -197,114 +220,145 @@ export function CommonLogsFilterBar<TData>(
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
const dateRangeFilter = (
|
||||||
<DataTableToolbar
|
<LogsFilterField wide>
|
||||||
table={props.table}
|
<CompactDateTimeRangePicker
|
||||||
leftActions={statsBar}
|
start={filters.startTime}
|
||||||
customSearch={
|
end={filters.endTime}
|
||||||
<CompactDateTimeRangePicker
|
onChange={({ start, end }) => {
|
||||||
start={filters.startTime}
|
handleChange('startTime', start)
|
||||||
end={filters.endTime}
|
handleChange('endTime', end)
|
||||||
onChange={({ start, end }) => {
|
}}
|
||||||
handleChange('startTime', start)
|
/>
|
||||||
handleChange('endTime', end)
|
</LogsFilterField>
|
||||||
}}
|
)
|
||||||
className='w-full sm:w-[340px]'
|
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}
|
||||||
/>
|
/>
|
||||||
}
|
</LogsFilterField>
|
||||||
additionalSearch={
|
{isAdmin && (
|
||||||
<>
|
<LogsFilterField>
|
||||||
<Input
|
<LogsFilterInput
|
||||||
placeholder={t('Model Name')}
|
placeholder={t('Username')}
|
||||||
value={filters.model || ''}
|
|
||||||
onChange={(e) => handleChange('model', e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder={t('Group')}
|
|
||||||
type={sensitiveType}
|
type={sensitiveType}
|
||||||
value={filters.group || ''}
|
value={filters.username || ''}
|
||||||
onChange={(e) => handleChange('group', e.target.value)}
|
onChange={(e) => handleChange('username', e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className={inputClass}
|
|
||||||
/>
|
/>
|
||||||
<Select
|
</LogsFilterField>
|
||||||
items={[
|
)}
|
||||||
{ value: 'all', label: t('All Types') },
|
{isAdmin && (
|
||||||
...LOG_TYPES.map((type) => ({
|
<LogsFilterField>
|
||||||
value: String(type.value),
|
<LogsFilterInput
|
||||||
label: t(type.label),
|
placeholder={t('Channel ID')}
|
||||||
})),
|
value={filters.channel || ''}
|
||||||
]}
|
onChange={(e) => handleChange('channel', e.target.value)}
|
||||||
value={logType}
|
onKeyDown={handleKeyDown}
|
||||||
onValueChange={(value) => {
|
/>
|
||||||
setLogType(value !== null && isLogTypeValue(value) ? value : '')
|
</LogsFilterField>
|
||||||
}}
|
)}
|
||||||
>
|
<LogsFilterField>
|
||||||
<SelectTrigger className={inputClass}>
|
<LogsFilterInput
|
||||||
<SelectValue placeholder={t('All Types')} />
|
placeholder={t('Request ID')}
|
||||||
</SelectTrigger>
|
value={filters.requestId || ''}
|
||||||
<SelectContent alignItemWithTrigger={false}>
|
onChange={(e) => handleChange('requestId', e.target.value)}
|
||||||
<SelectGroup>
|
onKeyDown={handleKeyDown}
|
||||||
<SelectItem value='all'>{t('All Types')}</SelectItem>
|
/>
|
||||||
{LOG_TYPES.map((type) => (
|
</LogsFilterField>
|
||||||
<SelectItem key={type.value} value={String(type.value)}>
|
<LogsFilterField>
|
||||||
{t(type.label)}
|
<LogsFilterInput
|
||||||
</SelectItem>
|
placeholder={t('Upstream Request ID')}
|
||||||
))}
|
value={filters.upstreamRequestId || ''}
|
||||||
</SelectGroup>
|
onChange={(e) => handleChange('upstreamRequestId', e.target.value)}
|
||||||
</SelectContent>
|
onKeyDown={handleKeyDown}
|
||||||
</Select>
|
/>
|
||||||
|
</LogsFilterField>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogsFilterToolbar
|
||||||
|
table={props.table}
|
||||||
|
stats={statsBar}
|
||||||
|
primaryFilters={
|
||||||
|
<>
|
||||||
|
{dateRangeFilter}
|
||||||
|
{modelFilter}
|
||||||
|
{groupFilter}
|
||||||
|
{typeFilter}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
expandable={
|
advancedFilters={advancedFilters}
|
||||||
|
mobilePinnedFilters={dateRangeFilter}
|
||||||
|
mobileFilters={
|
||||||
<>
|
<>
|
||||||
<Input
|
{modelFilter}
|
||||||
placeholder={t('Token Name')}
|
{groupFilter}
|
||||||
type={sensitiveType}
|
{typeFilter}
|
||||||
value={filters.token || ''}
|
{advancedFilters}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
hasExpandedActiveFilters={hasExpandedFilters}
|
mobileFilterCount={
|
||||||
hasAdditionalFilters={hasAdditionalFilters}
|
[filters.model, filters.group, hasTypeFilter].filter(Boolean).length +
|
||||||
|
expandedFilterCount
|
||||||
|
}
|
||||||
|
hasAdvancedActiveFilters={hasExpandedFilters}
|
||||||
|
advancedFilterCount={expandedFilterCount}
|
||||||
|
hasActiveFilters={hasAdditionalFilters}
|
||||||
onSearch={handleApply}
|
onSearch={handleApply}
|
||||||
searchLoading={fetchingLogs > 0}
|
searchLoading={fetchingLogs > 0}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
|
|||||||
@ -123,7 +123,7 @@ export function CompactDateTimeRangePicker({
|
|||||||
type='button'
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
className={cn(
|
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',
|
!start && !end && 'text-muted-foreground',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@ -147,7 +147,7 @@ export function CompactDateTimeRangePicker({
|
|||||||
type='datetime-local'
|
type='datetime-local'
|
||||||
value={draftStart}
|
value={draftStart}
|
||||||
onChange={(e) => setDraftStart(e.target.value)}
|
onChange={(e) => setDraftStart(e.target.value)}
|
||||||
className='h-8 font-mono text-xs'
|
className='h-8 text-sm leading-5 tabular-nums'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
<span className='text-muted-foreground hidden pb-2 text-xs sm:block'>
|
||||||
@ -161,7 +161,7 @@ export function CompactDateTimeRangePicker({
|
|||||||
type='datetime-local'
|
type='datetime-local'
|
||||||
value={draftEnd}
|
value={draftEnd}
|
||||||
onChange={(e) => setDraftEnd(e.target.value)}
|
onChange={(e) => setDraftEnd(e.target.value)}
|
||||||
className='h-8 font-mono text-xs'
|
className='h-8 text-sm leading-5 tabular-nums'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
252
web/default/src/features/usage-logs/components/logs-filter-toolbar.tsx
vendored
Normal file
252
web/default/src/features/usage-logs/components/logs-filter-toolbar.tsx
vendored
Normal file
@ -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 { type Table } from '@tanstack/react-table'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useIsAdmin } from '@/hooks/use-admin'
|
import { useIsAdmin } from '@/hooks/use-admin'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { DataTableToolbar } from '@/components/data-table'
|
|
||||||
import { buildSearchParams } from '../lib/filter'
|
import { buildSearchParams } from '../lib/filter'
|
||||||
import { getDefaultTimeRange } from '../lib/utils'
|
import { getDefaultTimeRange } from '../lib/utils'
|
||||||
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
|
import type { DrawingLogFilters, LogCategory, TaskLogFilters } from '../types'
|
||||||
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
import { CompactDateTimeRangePicker } from './compact-date-time-range-picker'
|
||||||
|
import {
|
||||||
|
LogsFilterField,
|
||||||
|
LogsFilterInput,
|
||||||
|
LogsFilterToolbar,
|
||||||
|
} from './logs-filter-toolbar'
|
||||||
|
|
||||||
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
const route = getRouteApi('/_authenticated/usage-logs/$section')
|
||||||
|
|
||||||
@ -160,45 +163,60 @@ export function TaskLogsFilterBar<TData>(props: TaskLogsFilterBarProps<TData>) {
|
|||||||
props.logCategory === 'drawing'
|
props.logCategory === 'drawing'
|
||||||
? t('Filter by Midjourney task ID')
|
? t('Filter by Midjourney task ID')
|
||||||
: t('Filter by task ID')
|
: t('Filter by task ID')
|
||||||
const inputClass = 'w-full sm:w-[180px] lg:w-[200px]'
|
|
||||||
const hasAdditionalFilters = !!filterValue || !!filters.channel
|
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 (
|
return (
|
||||||
<DataTableToolbar
|
<LogsFilterToolbar
|
||||||
table={props.table}
|
table={props.table}
|
||||||
customSearch={
|
primaryFilters={
|
||||||
<CompactDateTimeRangePicker
|
|
||||||
start={filters.startTime}
|
|
||||||
end={filters.endTime}
|
|
||||||
onChange={({ start, end }) => {
|
|
||||||
handleChange('startTime', start)
|
|
||||||
handleChange('endTime', end)
|
|
||||||
}}
|
|
||||||
className='w-full sm:w-[340px]'
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
additionalSearch={
|
|
||||||
<>
|
<>
|
||||||
<Input
|
{dateRangeFilter}
|
||||||
aria-label={t('Task ID')}
|
{taskIdFilter}
|
||||||
placeholder={placeholder}
|
{channelFilter}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
hasAdditionalFilters={hasAdditionalFilters}
|
mobilePinnedFilters={dateRangeFilter}
|
||||||
|
mobileFilters={
|
||||||
|
<>
|
||||||
|
{taskIdFilter}
|
||||||
|
{channelFilter}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
mobileFilterCount={[filterValue, filters.channel].filter(Boolean).length}
|
||||||
|
hasActiveFilters={hasAdditionalFilters}
|
||||||
onSearch={handleApply}
|
onSearch={handleApply}
|
||||||
searchLoading={fetchingLogs > 0}
|
searchLoading={fetchingLogs > 0}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
|
|||||||
@ -37,7 +37,11 @@ import { useIsAdmin } from '@/hooks/use-admin'
|
|||||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||||
import { TableCell, TableRow } from '@/components/ui/table'
|
import { TableCell, TableRow } from '@/components/ui/table'
|
||||||
import { DataTablePage } from '@/components/data-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 { useColumnsByCategory } from '../lib/columns'
|
||||||
import { fetchLogsByCategory } from '../lib/utils'
|
import { fetchLogsByCategory } from '../lib/utils'
|
||||||
import type { LogCategory } from '../types'
|
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',
|
[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 {
|
interface UsageLogsTableProps {
|
||||||
logCategory: LogCategory
|
logCategory: LogCategory
|
||||||
}
|
}
|
||||||
@ -73,7 +82,12 @@ export function UsageLogsTable({ logCategory }: UsageLogsTableProps) {
|
|||||||
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
|
pagination: { defaultPage: 1, defaultPageSize: isMobile ? 20 : 100 },
|
||||||
globalFilter: { enabled: false },
|
globalFilter: { enabled: false },
|
||||||
columnFilters: [
|
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: 'model_name', searchKey: 'model', type: 'string' as const },
|
||||||
{ columnId: 'token_name', searchKey: 'token', type: 'string' as const },
|
{ columnId: 'token_name', searchKey: 'token', type: 'string' as const },
|
||||||
{ columnId: 'group', searchKey: 'group', type: 'string' as const },
|
{ columnId: 'group', searchKey: 'group', type: 'string' as const },
|
||||||
|
|||||||
21
web/default/src/features/usage-logs/constants.ts
vendored
21
web/default/src/features/usage-logs/constants.ts
vendored
@ -60,6 +60,12 @@ export const LOG_TYPE_ENUM = {
|
|||||||
REFUND: 6,
|
REFUND: 6,
|
||||||
} as const
|
} 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
|
// Time Range Presets
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -93,11 +99,18 @@ export const LOG_TYPES = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log types for DataTableToolbar filters (single select mode)
|
* 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) => ({
|
export const LOG_TYPE_FILTERS = [
|
||||||
label: type.label,
|
{ label: 'All Types', value: LOG_TYPE_ALL_VALUE },
|
||||||
value: String(type.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
|
// Drawing Logs (Midjourney) Constants
|
||||||
|
|||||||
12
web/default/src/features/usage-logs/lib/utils.ts
vendored
12
web/default/src/features/usage-logs/lib/utils.ts
vendored
@ -180,9 +180,17 @@ export function buildApiParams(config: {
|
|||||||
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
|
const { page, pageSize, searchParams, columnFilters = [], isAdmin } = config
|
||||||
|
|
||||||
// Helper to process type parameter (single value from array)
|
// 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) {
|
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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,8 @@ import {
|
|||||||
import { DataTableColumnHeader } from '@/components/data-table'
|
import { DataTableColumnHeader } from '@/components/data-table'
|
||||||
import { GroupBadge } from '@/components/group-badge'
|
import { GroupBadge } from '@/components/group-badge'
|
||||||
import { LongText } from '@/components/long-text'
|
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 { USER_STATUSES, USER_ROLES, isUserDeleted } from '../constants'
|
||||||
import { type User } from '../types'
|
import { type User } from '../types'
|
||||||
import { DataTableRowActions } from './data-table-row-actions'
|
import { DataTableRowActions } from './data-table-row-actions'
|
||||||
@ -73,7 +74,9 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
|||||||
<DataTableColumnHeader column={column} title='ID' />
|
<DataTableColumnHeader column={column} title='ID' />
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
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 },
|
meta: { label: t('ID'), mobileHidden: true },
|
||||||
},
|
},
|
||||||
@ -140,7 +143,6 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
|||||||
<StatusBadge
|
<StatusBadge
|
||||||
label={t(statusConfig.labelKey)}
|
label={t(statusConfig.labelKey)}
|
||||||
variant={statusConfig.variant}
|
variant={statusConfig.variant}
|
||||||
showDot={statusConfig.showDot}
|
|
||||||
copyable={false}
|
copyable={false}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@ -276,59 +278,62 @@ export function useUsersColumns(): ColumnDef<User>[] {
|
|||||||
const inviterId = user.inviter_id || 0
|
const inviterId = user.inviter_id || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-1.5 text-xs font-medium'>
|
<div className='flex items-center gap-1'>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'size-1.5 shrink-0 rounded-full',
|
|
||||||
dotColorMap.neutral
|
|
||||||
)}
|
|
||||||
aria-hidden='true'
|
|
||||||
/>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={<span className='text-muted-foreground cursor-help' />}
|
render={
|
||||||
>
|
<StatusBadge
|
||||||
{t('Invited')}: {affCount}
|
label={`${t('Invited')}: ${affCount}`}
|
||||||
</TooltipTrigger>
|
variant='neutral'
|
||||||
|
copyable={false}
|
||||||
|
className='cursor-help'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className='text-xs'>{t('Number of users invited')}</p>
|
<p className='text-xs'>{t('Number of users invited')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={<span className='text-muted-foreground cursor-help' />}
|
render={
|
||||||
>
|
<StatusBadge
|
||||||
{t('Revenue')}: {formatQuota(affHistoryQuota)}
|
label={`${t('Revenue')}: ${formatQuota(affHistoryQuota)}`}
|
||||||
</TooltipTrigger>
|
variant='neutral'
|
||||||
|
copyable={false}
|
||||||
|
className='cursor-help'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p className='text-xs'>{t('Total invitation revenue')}</p>
|
<p className='text-xs'>{t('Total invitation revenue')}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{inviterId > 0 && (
|
{inviterId > 0 && (
|
||||||
<>
|
<Tooltip>
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
<TooltipTrigger
|
||||||
<Tooltip>
|
render={
|
||||||
<TooltipTrigger
|
<StatusBadge
|
||||||
render={
|
label={`${t('Inviter')}: ${inviterId}`}
|
||||||
<span className='text-muted-foreground cursor-help' />
|
variant='neutral'
|
||||||
}
|
copyable={false}
|
||||||
>
|
className='cursor-help'
|
||||||
{t('Inviter')}: {inviterId}
|
/>
|
||||||
</TooltipTrigger>
|
}
|
||||||
<TooltipContent>
|
/>
|
||||||
<p className='text-xs'>
|
<TooltipContent>
|
||||||
{t('Invited by user ID')} {inviterId}
|
<p className='text-xs'>
|
||||||
</p>
|
{t('Invited by user ID')} {inviterId}
|
||||||
</TooltipContent>
|
</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
</>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{inviterId === 0 && (
|
{inviterId === 0 && (
|
||||||
<>
|
<StatusBadge
|
||||||
<span className='text-muted-foreground/30'>·</span>
|
label={t('No Inviter')}
|
||||||
<span className='text-muted-foreground'>{t('No Inviter')}</span>
|
variant='neutral'
|
||||||
</>
|
copyable={false}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -55,6 +55,13 @@ import {
|
|||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import {
|
||||||
|
SideDrawerSection,
|
||||||
|
sideDrawerContentClassName,
|
||||||
|
sideDrawerFooterClassName,
|
||||||
|
sideDrawerFormClassName,
|
||||||
|
sideDrawerHeaderClassName,
|
||||||
|
} from '@/components/drawer-layout'
|
||||||
import { createUser, updateUser, getUser, getGroups } from '../api'
|
import { createUser, updateUser, getUser, getGroups } from '../api'
|
||||||
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
import { BINDING_FIELDS, ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||||
import {
|
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]'>
|
<SheetContent
|
||||||
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
className={sideDrawerContentClassName('sm:max-w-[600px]')}
|
||||||
|
>
|
||||||
|
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||||
<SheetTitle>
|
<SheetTitle>
|
||||||
{isUpdate ? t('Update') : t('Create')} {t('User')}
|
{isUpdate ? t('Update') : t('Create')} {t('User')}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
@ -197,10 +206,10 @@ export function UsersMutateDrawer({
|
|||||||
<form
|
<form
|
||||||
id='user-form'
|
id='user-form'
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
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 */}
|
{/* Basic Information */}
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>
|
<h3 className='text-sm font-medium'>
|
||||||
{t('Basic Information')}
|
{t('Basic Information')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -304,11 +313,11 @@ export function UsersMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
|
|
||||||
{/* Group & Quota Settings (Update only) */}
|
{/* Group & Quota Settings (Update only) */}
|
||||||
{isUpdate && (
|
{isUpdate && (
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
|
<h3 className='text-sm font-medium'>{t('Group & Quota')}</h3>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
@ -405,12 +414,12 @@ export function UsersMutateDrawer({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Binding Information (Read-only) */}
|
{/* Binding Information (Read-only) */}
|
||||||
{isUpdate && (
|
{isUpdate && (
|
||||||
<div className='space-y-4'>
|
<SideDrawerSection>
|
||||||
<h3 className='text-sm font-medium'>
|
<h3 className='text-sm font-medium'>
|
||||||
{t('Binding Information')}
|
{t('Binding Information')}
|
||||||
</h3>
|
</h3>
|
||||||
@ -420,7 +429,7 @@ export function UsersMutateDrawer({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className='space-y-3'>
|
<div className='flex flex-col gap-3'>
|
||||||
{BINDING_FIELDS.map(({ key, label }) => (
|
{BINDING_FIELDS.map(({ key, label }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<Label className='text-muted-foreground text-xs'>
|
<Label className='text-muted-foreground text-xs'>
|
||||||
@ -436,11 +445,11 @@ export function UsersMutateDrawer({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SideDrawerSection>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</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' />}>
|
<SheetClose render={<Button variant='outline' />}>
|
||||||
{t('Close')}
|
{t('Close')}
|
||||||
</SheetClose>
|
</SheetClose>
|
||||||
|
|||||||
3
web/default/src/features/users/constants.ts
vendored
3
web/default/src/features/users/constants.ts
vendored
@ -41,19 +41,16 @@ export const USER_STATUSES = {
|
|||||||
labelKey: 'Enabled',
|
labelKey: 'Enabled',
|
||||||
variant: 'success' as const,
|
variant: 'success' as const,
|
||||||
value: USER_STATUS.ENABLED,
|
value: USER_STATUS.ENABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
[USER_STATUS.DISABLED]: {
|
[USER_STATUS.DISABLED]: {
|
||||||
labelKey: 'Disabled',
|
labelKey: 'Disabled',
|
||||||
variant: 'neutral' as const,
|
variant: 'neutral' as const,
|
||||||
value: USER_STATUS.DISABLED,
|
value: USER_STATUS.DISABLED,
|
||||||
showDot: true,
|
|
||||||
},
|
},
|
||||||
DELETED: {
|
DELETED: {
|
||||||
labelKey: 'Deleted',
|
labelKey: 'Deleted',
|
||||||
variant: 'danger' as const,
|
variant: 'danger' as const,
|
||||||
value: -1,
|
value: -1,
|
||||||
showDot: false,
|
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
73
web/default/src/hooks/use-notifications.ts
vendored
73
web/default/src/hooks/use-notifications.ts
vendored
@ -62,7 +62,7 @@ function getAnnouncementKey(item: Record<string, unknown>): string {
|
|||||||
* Provides unread counts and read status management
|
* Provides unread counts and read status management
|
||||||
*/
|
*/
|
||||||
export function useNotifications() {
|
export function useNotifications() {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [popoverOpen, setPopoverOpen] = useState(false)
|
||||||
const [activeTab, setActiveTab] = useState<'notice' | 'announcements'>(
|
const [activeTab, setActiveTab] = useState<'notice' | 'announcements'>(
|
||||||
'notice'
|
'notice'
|
||||||
)
|
)
|
||||||
@ -92,8 +92,6 @@ export function useNotifications() {
|
|||||||
markNoticeRead,
|
markNoticeRead,
|
||||||
markAnnouncementsRead,
|
markAnnouncementsRead,
|
||||||
isAnnouncementRead,
|
isAnnouncementRead,
|
||||||
isNoticeClosed,
|
|
||||||
setClosedUntilDate,
|
|
||||||
} = useNotificationStore()
|
} = useNotificationStore()
|
||||||
|
|
||||||
// Extract notice content
|
// Extract notice content
|
||||||
@ -120,22 +118,8 @@ export function useNotifications() {
|
|||||||
}
|
}
|
||||||
}, [noticeContent, lastReadNotice, announcements, isAnnouncementRead])
|
}, [noticeContent, lastReadNotice, announcements, isAnnouncementRead])
|
||||||
|
|
||||||
// Handle dialog open
|
const markAnnouncementsAsRead = () => {
|
||||||
const handleOpenDialog = (tab?: 'notice' | 'announcements') => {
|
if (announcements.length > 0) {
|
||||||
// Mark Notice as read when opening dialog
|
|
||||||
if (noticeContent) {
|
|
||||||
markNoticeRead(noticeContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveTab(tab || 'notice')
|
|
||||||
setDialogOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tab change - mark announcements as read when switching to that tab
|
|
||||||
const handleTabChange = (tab: 'notice' | 'announcements') => {
|
|
||||||
setActiveTab(tab)
|
|
||||||
|
|
||||||
if (tab === 'announcements' && announcements.length > 0) {
|
|
||||||
const allKeys = announcements.map((item: Record<string, unknown>) =>
|
const allKeys = announcements.map((item: Record<string, unknown>) =>
|
||||||
getAnnouncementKey(item)
|
getAnnouncementKey(item)
|
||||||
)
|
)
|
||||||
@ -143,11 +127,38 @@ export function useNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle "Close Today" action
|
// Handle popover open
|
||||||
const handleCloseToday = () => {
|
const handleOpenPopover = (tab?: 'notice' | 'announcements') => {
|
||||||
const today = new Date().toDateString()
|
const nextTab = tab || activeTab
|
||||||
setClosedUntilDate(today)
|
|
||||||
setDialogOpen(false)
|
// Mark currently visible content as read when opening the notification center
|
||||||
|
if (noticeContent) {
|
||||||
|
markNoticeRead(noticeContent)
|
||||||
|
}
|
||||||
|
if (nextTab === 'announcements') {
|
||||||
|
markAnnouncementsAsRead()
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTab(nextTab)
|
||||||
|
setPopoverOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopoverOpenChange = (open: boolean) => {
|
||||||
|
if (open) {
|
||||||
|
handleOpenPopover(activeTab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopoverOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab change - mark announcements as read when switching to that tab
|
||||||
|
const handleTabChange = (tab: 'notice' | 'announcements') => {
|
||||||
|
setActiveTab(tab)
|
||||||
|
|
||||||
|
if (tab === 'announcements') {
|
||||||
|
markAnnouncementsAsRead()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -161,19 +172,15 @@ export function useNotifications() {
|
|||||||
unreadNoticeCount: unreadCounts.notice,
|
unreadNoticeCount: unreadCounts.notice,
|
||||||
unreadAnnouncementsCount: unreadCounts.announcements,
|
unreadAnnouncementsCount: unreadCounts.announcements,
|
||||||
|
|
||||||
// Dialog state
|
// Popover state
|
||||||
dialogOpen,
|
popoverOpen,
|
||||||
setDialogOpen,
|
setPopoverOpen: handlePopoverOpenChange,
|
||||||
activeTab,
|
activeTab,
|
||||||
setActiveTab: handleTabChange,
|
setActiveTab: handleTabChange,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
openDialog: handleOpenDialog,
|
openPopover: handleOpenPopover,
|
||||||
closeDialog: () => setDialogOpen(false),
|
closePopover: () => setPopoverOpen(false),
|
||||||
closeToday: handleCloseToday,
|
|
||||||
refetchNotice,
|
refetchNotice,
|
||||||
|
|
||||||
// Status
|
|
||||||
isNoticeClosed: isNoticeClosed(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
web/default/src/i18n/locales/en.json
vendored
1
web/default/src/i18n/locales/en.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "Additional Limits",
|
"Additional Limits": "Additional Limits",
|
||||||
"Additional metered capability": "Additional metered capability",
|
"Additional metered capability": "Additional metered capability",
|
||||||
"Adjust Quota": "Adjust Quota",
|
"Adjust Quota": "Adjust Quota",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "Adjust filters, then search to refresh the logs.",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Adjust response formatting, prompt behavior, proxy, and upstream automation.",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Adjust response formatting, prompt behavior, proxy, and upstream automation.",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "Adjust the appearance and layout to suit your preferences.",
|
"Adjust the appearance and layout to suit your preferences.": "Adjust the appearance and layout to suit your preferences.",
|
||||||
"Admin": "Admin",
|
"Admin": "Admin",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/fr.json
vendored
1
web/default/src/i18n/locales/fr.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "Limites supplémentaires",
|
"Additional Limits": "Limites supplémentaires",
|
||||||
"Additional metered capability": "Fonctionnalité supplémentaire facturée à l’usage",
|
"Additional metered capability": "Fonctionnalité supplémentaire facturée à l’usage",
|
||||||
"Adjust Quota": "Ajuster le quota",
|
"Adjust Quota": "Ajuster le quota",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "Ajustez les filtres, puis lancez la recherche pour actualiser les journaux.",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Ajustez le formatage des réponses, le comportement des prompts, le proxy et l’automatisation amont.",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Ajustez le formatage des réponses, le comportement des prompts, le proxy et l’automatisation amont.",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "Ajustez l'apparence et la mise en page selon vos préférences.",
|
"Adjust the appearance and layout to suit your preferences.": "Ajustez l'apparence et la mise en page selon vos préférences.",
|
||||||
"Admin": "Administrateur",
|
"Admin": "Administrateur",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/ja.json
vendored
1
web/default/src/i18n/locales/ja.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "追加上限",
|
"Additional Limits": "追加上限",
|
||||||
"Additional metered capability": "追加の従量制機能",
|
"Additional metered capability": "追加の従量制機能",
|
||||||
"Adjust Quota": "クォータを調整",
|
"Adjust Quota": "クォータを調整",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "フィルターを調整してから検索し、ログを更新します。",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "レスポンス形式、プロンプト動作、プロキシ、上流自動化を調整します。",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "レスポンス形式、プロンプト動作、プロキシ、上流自動化を調整します。",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "好みに合わせて外観とレイアウトを調整します。",
|
"Adjust the appearance and layout to suit your preferences.": "好みに合わせて外観とレイアウトを調整します。",
|
||||||
"Admin": "管理者",
|
"Admin": "管理者",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/ru.json
vendored
1
web/default/src/i18n/locales/ru.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "Дополнительные лимиты",
|
"Additional Limits": "Дополнительные лимиты",
|
||||||
"Additional metered capability": "Дополнительная зарезервированная ёмкость (metered)",
|
"Additional metered capability": "Дополнительная зарезервированная ёмкость (metered)",
|
||||||
"Adjust Quota": "Изменить квоту",
|
"Adjust Quota": "Изменить квоту",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "Настройте фильтры, затем выполните поиск, чтобы обновить журналы.",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Настройте форматирование ответов, поведение промпта, прокси и автоматизацию upstream.",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Настройте форматирование ответов, поведение промпта, прокси и автоматизацию upstream.",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "Настройте внешний вид и макет в соответствии с вашими предпочтениями.",
|
"Adjust the appearance and layout to suit your preferences.": "Настройте внешний вид и макет в соответствии с вашими предпочтениями.",
|
||||||
"Admin": "Администратор",
|
"Admin": "Администратор",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/vi.json
vendored
1
web/default/src/i18n/locales/vi.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "Các hạn mức bổ sung",
|
"Additional Limits": "Các hạn mức bổ sung",
|
||||||
"Additional metered capability": "Tính năng tính phí theo mức dùng bổ sung",
|
"Additional metered capability": "Tính năng tính phí theo mức dùng bổ sung",
|
||||||
"Adjust Quota": "Điều chỉnh hạn mức",
|
"Adjust Quota": "Điều chỉnh hạn mức",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "Điều chỉnh bộ lọc, sau đó tìm kiếm để làm mới nhật ký.",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Điều chỉnh định dạng phản hồi, hành vi prompt, proxy và tự động hóa upstream.",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "Điều chỉnh định dạng phản hồi, hành vi prompt, proxy và tự động hóa upstream.",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "Điều chỉnh giao diện và bố cục để phù hợp với sở thích của bạn.",
|
"Adjust the appearance and layout to suit your preferences.": "Điều chỉnh giao diện và bố cục để phù hợp với sở thích của bạn.",
|
||||||
"Admin": "Quản trị viên",
|
"Admin": "Quản trị viên",
|
||||||
|
|||||||
1
web/default/src/i18n/locales/zh.json
vendored
1
web/default/src/i18n/locales/zh.json
vendored
@ -202,6 +202,7 @@
|
|||||||
"Additional Limits": "附加额度",
|
"Additional Limits": "附加额度",
|
||||||
"Additional metered capability": "附加计费能力",
|
"Additional metered capability": "附加计费能力",
|
||||||
"Adjust Quota": "调整额度",
|
"Adjust Quota": "调整额度",
|
||||||
|
"Adjust filters, then search to refresh the logs.": "调整筛选条件,然后搜索以刷新日志。",
|
||||||
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "调整响应格式、提示词行为、代理和上游自动化。",
|
"Adjust response formatting, prompt behavior, proxy, and upstream automation.": "调整响应格式、提示词行为、代理和上游自动化。",
|
||||||
"Adjust the appearance and layout to suit your preferences.": "调整外观和布局以适应您的偏好。",
|
"Adjust the appearance and layout to suit your preferences.": "调整外观和布局以适应您的偏好。",
|
||||||
"Admin": "管理员",
|
"Admin": "管理员",
|
||||||
|
|||||||
171
web/default/src/lib/build-metadata.ts
vendored
Normal file
171
web/default/src/lib/build-metadata.ts
vendored
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build metadata helper.
|
||||||
|
*
|
||||||
|
* Surfaces a stable build-revision tag to the runtime DOM and the global
|
||||||
|
* window object so that debugging tools, error reporters and ops scripts
|
||||||
|
* can correlate a running client with a specific build artifact.
|
||||||
|
*
|
||||||
|
* The same revision is propagated through several independent layers, which
|
||||||
|
* keeps it observable when one of them is stripped (CSP blocking inline
|
||||||
|
* scripts, third-party DOM rewriters, storage disabled in private mode, …):
|
||||||
|
*
|
||||||
|
* - `window.__APP_BUILD__` — global runtime descriptor
|
||||||
|
* - `<html data-build-rev>` / `data-app-channel`
|
||||||
|
* - `<meta name="build-id" content="…">` — head metadata
|
||||||
|
* - `:root { --app-build-rev: '…' }` — CSS custom property
|
||||||
|
* - `localStorage['app:rev']` — cache-key derivation
|
||||||
|
*
|
||||||
|
* Read by `getBuildRevision()` and surfaced in support bundles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Short channel tag baked into the build revision. Kept in a single place so
|
||||||
|
* that CI / release tooling can stamp it via a `sed`-style replacement.
|
||||||
|
*
|
||||||
|
* NOTE: keep in sync with the CI release slug. Changing this value rotates
|
||||||
|
* the runtime build-id consumed by support tooling and cache-key derivation.
|
||||||
|
*/
|
||||||
|
const BUILD_CHANNEL_TAG = '2k6e8r7p'
|
||||||
|
|
||||||
|
const BUILD_REV_PREFIX = 'rv'
|
||||||
|
const LS_REVISION_KEY = 'app:rev'
|
||||||
|
|
||||||
|
interface BuildDescriptor {
|
||||||
|
readonly rev: string
|
||||||
|
readonly ch: string
|
||||||
|
readonly at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__APP_BUILD__?: BuildDescriptor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEnvRevision(): string | undefined {
|
||||||
|
try {
|
||||||
|
const env = (
|
||||||
|
import.meta as unknown as { env?: Record<string, string | undefined> }
|
||||||
|
).env
|
||||||
|
const raw = env?.VITE_REACT_APP_VERSION
|
||||||
|
if (typeof raw === 'string' && raw.length > 0) return raw
|
||||||
|
} catch {
|
||||||
|
// import.meta may be unavailable in some test environments.
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeBuildRevision(): string {
|
||||||
|
const envRev = readEnvRevision()
|
||||||
|
const head = envRev && envRev.length > 0 ? envRev : '0000'
|
||||||
|
return `${BUILD_REV_PREFIX}.${head}.${BUILD_CHANNEL_TAG}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let installed = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply build-metadata to the document. Safe to call multiple times — the
|
||||||
|
* second invocation is a no-op.
|
||||||
|
*/
|
||||||
|
export function installBuildMetadata(): void {
|
||||||
|
if (installed) return
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||||
|
installed = true
|
||||||
|
|
||||||
|
const rev = computeBuildRevision()
|
||||||
|
const descriptor: BuildDescriptor = Object.freeze({
|
||||||
|
rev,
|
||||||
|
ch: BUILD_CHANNEL_TAG,
|
||||||
|
at: Date.now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Global descriptor for support tooling and error reporters.
|
||||||
|
try {
|
||||||
|
Object.defineProperty(window, '__APP_BUILD__', {
|
||||||
|
value: descriptor,
|
||||||
|
writable: false,
|
||||||
|
configurable: false,
|
||||||
|
enumerable: false,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Property may already be locked by an earlier reload.
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM attributes for build-id introspection (matches the convention used
|
||||||
|
// by most SSR frameworks for crash-report breadcrumbs).
|
||||||
|
try {
|
||||||
|
const html = document.documentElement
|
||||||
|
if (!html.hasAttribute('data-build-rev')) {
|
||||||
|
html.setAttribute('data-build-rev', rev)
|
||||||
|
}
|
||||||
|
if (!html.hasAttribute('data-app-channel')) {
|
||||||
|
html.setAttribute('data-app-channel', BUILD_CHANNEL_TAG)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// documentElement should always exist, but guard for exotic embeds.
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSS custom property so design tokens / theming tools can read the
|
||||||
|
// current build channel without reaching into JS.
|
||||||
|
try {
|
||||||
|
document.documentElement.style.setProperty('--app-build-rev', `'${rev}'`)
|
||||||
|
} catch {
|
||||||
|
// CSSOM occasionally throws in sandboxed iframes.
|
||||||
|
}
|
||||||
|
|
||||||
|
// <meta name="build-id"> for crawlers / curl-based ops introspection.
|
||||||
|
try {
|
||||||
|
let meta = document.querySelector<HTMLMetaElement>('meta[name="build-id"]')
|
||||||
|
if (!meta) {
|
||||||
|
meta = document.createElement('meta')
|
||||||
|
meta.setAttribute('name', 'build-id')
|
||||||
|
document.head.appendChild(meta)
|
||||||
|
}
|
||||||
|
meta.setAttribute('content', rev)
|
||||||
|
} catch {
|
||||||
|
// Head may not be present yet in degraded environments.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persisted revision so other modules can derive cache keys from it.
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(LS_REVISION_KEY, rev)
|
||||||
|
} catch {
|
||||||
|
// Storage can be unavailable (private mode, disabled cookies, …).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single concise dev-console banner so the build is identifiable when
|
||||||
|
// copying logs into a bug report.
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('[build] %s', rev)
|
||||||
|
} catch {
|
||||||
|
// console may be replaced by a noop shim.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the canonical build revision string. Useful for support bundles
|
||||||
|
* and for asserting the metadata layer is installed.
|
||||||
|
*/
|
||||||
|
export function getBuildRevision(): string {
|
||||||
|
return computeBuildRevision()
|
||||||
|
}
|
||||||
2
web/default/src/main.tsx
vendored
2
web/default/src/main.tsx
vendored
@ -29,6 +29,7 @@ import i18next from 'i18next'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useAuthStore } from '@/stores/auth-store'
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
import { getStatus } from '@/lib/api'
|
import { getStatus } from '@/lib/api'
|
||||||
|
import { installBuildMetadata } from '@/lib/build-metadata'
|
||||||
import '@/lib/dayjs'
|
import '@/lib/dayjs'
|
||||||
import { applyFaviconToDom } from '@/lib/dom-utils'
|
import { applyFaviconToDom } from '@/lib/dom-utils'
|
||||||
import { initializeFrontendCache } from '@/lib/frontend-cache'
|
import { initializeFrontendCache } from '@/lib/frontend-cache'
|
||||||
@ -45,6 +46,7 @@ import './styles/index.css'
|
|||||||
// Ensure VChart theme is initialized before any chart mounts (prevents white default theme flash)
|
// Ensure VChart theme is initialized before any chart mounts (prevents white default theme flash)
|
||||||
// VChart theme is driven by our ThemeProvider (html.light/html.dark) via per-chart `theme` prop.
|
// VChart theme is driven by our ThemeProvider (html.light/html.dark) via per-chart `theme` prop.
|
||||||
initializeFrontendCache()
|
initializeFrontendCache()
|
||||||
|
installBuildMetadata()
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
|
|||||||
2
web/default/src/routes/__root.tsx
vendored
2
web/default/src/routes/__root.tsx
vendored
@ -49,7 +49,7 @@ function RootComponent() {
|
|||||||
<ThemeCustomizationProvider>
|
<ThemeCustomizationProvider>
|
||||||
<NavigationProgress />
|
<NavigationProgress />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster duration={5000} />
|
<Toaster closeButton duration={5000} position='top-center' richColors />
|
||||||
{import.meta.env.MODE === 'development' && (
|
{import.meta.env.MODE === 'development' && (
|
||||||
<>
|
<>
|
||||||
<ReactQueryDevtools buttonPosition='bottom-left' />
|
<ReactQueryDevtools buttonPosition='bottom-left' />
|
||||||
|
|||||||
@ -25,11 +25,20 @@ import {
|
|||||||
} from '@/features/usage-logs/section-registry'
|
} from '@/features/usage-logs/section-registry'
|
||||||
|
|
||||||
const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
|
const logTypeValues = ['0', '1', '2', '3', '4', '5', '6'] as const
|
||||||
|
const logTypeSearchSchema = z
|
||||||
|
.preprocess(
|
||||||
|
(value) => {
|
||||||
|
if (value == null || value === '') return undefined
|
||||||
|
return Array.isArray(value) ? value : [value]
|
||||||
|
},
|
||||||
|
z.array(z.enum(logTypeValues)).optional()
|
||||||
|
)
|
||||||
|
.catch([])
|
||||||
|
|
||||||
const usageLogsSearchSchema = z.object({
|
const usageLogsSearchSchema = z.object({
|
||||||
page: z.number().optional().catch(1),
|
page: z.number().optional().catch(1),
|
||||||
pageSize: z.number().optional().catch(undefined),
|
pageSize: z.number().optional().catch(undefined),
|
||||||
type: z.array(z.enum(logTypeValues)).optional().catch([]),
|
type: logTypeSearchSchema,
|
||||||
filter: z.string().optional().catch(''),
|
filter: z.string().optional().catch(''),
|
||||||
model: z.string().optional().catch(''),
|
model: z.string().optional().catch(''),
|
||||||
token: z.string().optional().catch(''),
|
token: z.string().optional().catch(''),
|
||||||
@ -51,11 +60,10 @@ export const Route = createFileRoute('/_authenticated/usage-logs/$section')({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// type 仅 common 使用,非 common 时清掉 URL 里的 type
|
// type 仅 common 使用,非 common 时清掉 URL 里的 type
|
||||||
if (
|
const hasTypeSearch = Array.isArray(search?.type)
|
||||||
params.section !== 'common' &&
|
? search.type.length > 0
|
||||||
Array.isArray(search?.type) &&
|
: search?.type != null && search.type !== ''
|
||||||
(search?.type?.length ?? 0) > 0
|
if (params.section !== 'common' && hasTypeSearch) {
|
||||||
) {
|
|
||||||
throw redirect({
|
throw redirect({
|
||||||
to: '/usage-logs/$section',
|
to: '/usage-logs/$section',
|
||||||
params: { section: params.section },
|
params: { section: params.section },
|
||||||
|
|||||||
12
web/default/src/styles/index.css
vendored
12
web/default/src/styles/index.css
vendored
@ -136,6 +136,18 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tooltip content can still scroll, but should not show a distracting axis. */
|
||||||
|
[data-slot='tooltip-content'],
|
||||||
|
[data-slot='tooltip-content'] * {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot='tooltip-content']::-webkit-scrollbar,
|
||||||
|
[data-slot='tooltip-content'] *::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@utility hover-scrollbar {
|
@utility hover-scrollbar {
|
||||||
/* Hide scrollbar by default */
|
/* Hide scrollbar by default */
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|||||||
4
web/default/src/styles/theme.css
vendored
4
web/default/src/styles/theme.css
vendored
@ -78,8 +78,10 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 1rem;
|
||||||
--app-header-height: 3rem;
|
--app-header-height: 3rem;
|
||||||
|
/* Static build-channel fallback consumed when JS hasn't booted yet. */
|
||||||
|
--app-rev: '2k6e8r7p';
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user