♻️ refactor(web): consolidate design-system primitives and responsive data views

This commit is contained in:
t0ng7u
2026-07-11 05:13:16 +08:00
parent 262ab93123
commit 0918bdb49a
451 changed files with 7295 additions and 7693 deletions
@@ -25,7 +25,7 @@ import {
IconLinuxDo,
IconWeChat,
} from '@/assets/brand-icons'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { cn } from '@/lib/utils'
import { useOAuthLogin } from '../hooks/use-oauth-login'
@@ -159,7 +159,8 @@ export function OAuthProviders({
type='button'
disabled={disabled || isLoading || extraDisabled}
onClick={onClick}
className='h-11 w-full justify-center gap-2 rounded-lg'
size='xl'
className='w-full justify-center rounded-lg'
>
{icon}
{label}
@@ -24,8 +24,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Turnstile } from '@/components/turnstile'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -34,7 +35,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { sendPasswordResetEmail } from '@/features/auth/api'
import {
forgotPasswordFormSchema,
@@ -113,6 +113,7 @@ export function ForgotPasswordForm({
<Button
type='submit'
size='xl'
className='mt-2'
disabled={isLoading || isActive || !turnstileReady}
>
+5 -6
View File
@@ -24,7 +24,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Form,
FormControl,
@@ -34,7 +35,6 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
InputOTP,
InputOTPGroup,
@@ -204,6 +204,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
<Button
type='submit'
size='xl'
className='mt-2 w-full'
disabled={!isFormValid || isLoading}
>
@@ -215,8 +216,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
className='text-primary h-auto p-0 sm:h-auto'
onClick={handleToggleMode}
>
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
@@ -225,8 +225,7 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
<Button
type='button'
variant='link'
size='sm'
className='text-primary h-auto p-0'
className='text-primary h-auto p-0 sm:h-auto'
onClick={handleBackToLogin}
>
{t('Back to login')}
@@ -22,9 +22,9 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useCountdown } from '@/hooks/use-countdown'
import { api } from '@/lib/api'
@@ -169,6 +169,7 @@ export function ResetPasswordConfirm({
)}
<Button
size='xl'
className='w-full'
onClick={
newPassword
@@ -20,10 +20,15 @@ import { ShieldCheck, KeyRound, Loader2 } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/design-system/tabs'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type {
SecureVerificationState,
@@ -25,10 +25,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -37,7 +38,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { login, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
@@ -292,7 +292,8 @@ export function UserAuthForm({
variant='outline'
disabled={passkeyButtonDisabled}
onClick={handlePasskeyLogin}
className='h-11 w-full justify-center gap-2 rounded-lg'
size='xl'
className='w-full justify-center rounded-lg'
>
{isPasskeyLoading ? (
<Loader2 className='h-4 w-4 animate-spin' />
@@ -375,7 +376,8 @@ export function UserAuthForm({
{/* Submit Button */}
<Button
type='submit'
className='mt-2 w-full justify-center gap-2'
size='xl'
className='mt-2 w-full justify-center'
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
@@ -24,10 +24,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import type { z } from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { PasswordInput } from '@/components/password-input'
import { Turnstile } from '@/components/turnstile'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -36,7 +37,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { register, wechatLoginByCode } from '@/features/auth/api'
import { LegalConsent } from '@/features/auth/components/legal-consent'
@@ -355,7 +355,8 @@ export function SignUpForm({
{/* Submit Button */}
<Button
type='submit'
className='mt-2 w-full justify-center gap-2'
size='xl'
className='mt-2 w-full justify-center'
disabled={
isLoading ||
(requiresLegalConsent && !agreedToLegal) ||
+1 -1
View File
@@ -376,7 +376,7 @@ export async function manageMultiKeys(
export async function getMultiKeyStatus(
channelId: number,
page = 1,
pageSize = 50,
pageSize = 20,
status?: number
): Promise<MultiKeyStatusResponse> {
return manageMultiKeys({
+100 -103
View File
@@ -17,19 +17,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { GroupBadge } from '@/components/group-badge'
import { cn } from '@/lib/utils'
import {
DataTableCardDetails,
DataTableCardField,
} from '@/components/data-table'
import { CHANNEL_STATUS } from '../constants'
import { isTagAggregateRow, parseGroupsList } from '../lib'
import { isTagAggregateRow } from '../lib'
import type { Channel } from '../types'
import { ChannelRowActionsLayoutContext } from './channel-row-actions-context'
import { useChannels } from './channels-provider'
const SENSITIVE_MASK = '••••'
/**
* Bespoke channel card for the card view. Reuses every column's existing cell
@@ -37,7 +34,7 @@ const SENSITIVE_MASK = '••••'
* preserved: row selection, provider/multi-key/IO.NET type badge, id,
* name/remark + warning icons, status (with tooltips), groups, inline
* priority/weight spinners, balance refresh, response/test times, tag
* expand-collapse, and the per-row (or per-tag) actions menu.
* expand-collapse, models, and the per-row (or per-tag) actions menu.
*/
function ChannelCardComponent({
row,
@@ -47,46 +44,40 @@ function ChannelCardComponent({
isSelected: boolean
}) {
const { t } = useTranslation()
const { sensitiveVisible } = useChannels()
const isTagRow = isTagAggregateRow(row.original)
const cells = row.getAllCells()
const cells = row.getVisibleCells()
const visibleColumnIds = new Set(cells.map((cell) => cell.column.id))
const renderCell = (id: string) => {
const cell = cells.find((c) => c.column.id === id)
const cell = cells.find((candidate) => candidate.column.id === id)
if (!cell || !cell.column.columnDef.cell) {
return null
}
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
const fieldLabels: Record<string, string> = {
balance: t('Used / Remaining'),
response_time: t('Response'),
test_time: t('Last Tested'),
}
const groups = parseGroupsList(row.original.group ?? '')
const selectCell = renderCell('select')
const typeCell = renderCell('type')
const idCell = renderCell('id')
const nameCell = renderCell('name')
const statusCell = renderCell('status')
const actionsCell = renderCell('actions')
const priorityCell = renderCell('priority')
const weightCell = renderCell('weight')
const balanceCell = renderCell('balance')
const modelsCell = renderCell('models')
const groupsCell = renderCell('group')
const tagCell = renderCell('tag')
const responseCell = renderCell('response_time')
const testCell = renderCell('test_time')
const labelClass = 'text-muted-foreground text-xs font-medium select-none'
// In card view the enable/disable state is already conveyed by the inline
// power toggle, so the plain "Enabled"/"Disabled" badge is redundant. Keep
// only the informative states (e.g. auto-disabled, unknown) and tag rows.
const showStatusBadge =
isTagRow ||
(row.original.status !== CHANNEL_STATUS.ENABLED &&
row.original.status !== CHANNEL_STATUS.MANUAL_DISABLED)
const emptyValue = <span className='text-muted-foreground'>-</span>
const detailsCount = [
visibleColumnIds.has('group'),
!isTagRow && visibleColumnIds.has('tag'),
visibleColumnIds.has('priority'),
visibleColumnIds.has('weight'),
].filter(Boolean).length
return (
<ChannelRowActionsLayoutContext.Provider value='card'>
@@ -94,93 +85,99 @@ function ChannelCardComponent({
data-state={isSelected ? 'selected' : undefined}
className='flex flex-col gap-3'
>
{/* Row 1: selection + type, with status badge + actions menu */}
<div className='flex items-center justify-between gap-2'>
{/* Provider identity, status, selection, and every row action remain
immediately available. The wrapping layout avoids mobile clipping. */}
<div className='flex flex-wrap items-start justify-between gap-2'>
<div className='flex min-w-0 flex-1 items-center gap-2'>
{!isTagRow && selectCell && (
<span className='shrink-0'>{selectCell}</span>
)}
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
{visibleColumnIds.has('type') && (
<div className='min-w-0 flex-1'>{typeCell}</div>
)}
</div>
<div className='flex shrink-0 items-center gap-1.5'>
{showStatusBadge && statusCell}
<div className='flex flex-wrap items-center justify-end gap-1.5'>
{visibleColumnIds.has('status') && statusCell}
{actionsCell}
</div>
</div>
{/* Body: left column (id/name + balance) paired with a right-aligned
column (priority/weight + response/test time). */}
<div className='flex items-start justify-between gap-3'>
{/* Left column */}
<div className='flex min-w-0 flex-1 flex-col gap-3 overflow-hidden'>
<div className='min-w-0 text-sm'>
{!isTagRow && (
<div className={labelClass}>
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
</div>
)}
{nameCell}
</div>
<div className='min-w-0'>
<div className={cn('mb-1', labelClass)}>
{fieldLabels.balance}
</div>
<div className='min-w-0 overflow-hidden text-sm'>
{balanceCell ?? (
<span className='text-muted-foreground'>-</span>
)}
</div>
</div>
</div>
{/* Right column (sits on the right, content left-aligned). A single
grid with content-sized columns keeps Priority/Weight and
Response/Last Tested aligned without wasting horizontal space. */}
<div className='grid shrink-0 grid-cols-[auto_auto] items-center gap-x-3 gap-y-1'>
<span className={labelClass}>{t('Priority')}</span>
<span className={labelClass}>{t('Weight')}</span>
<div className='flex justify-start'>{priorityCell}</div>
<div className='flex justify-start'>{weightCell}</div>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.response_time}
</span>
<span className={cn('mt-2', labelClass)}>
{fieldLabels.test_time}
</span>
<div className='overflow-hidden text-sm'>
{responseCell ?? <span className='text-muted-foreground'>-</span>}
</div>
<div className='overflow-hidden text-sm'>
{testCell ?? <span className='text-muted-foreground'>-</span>}
</div>
</div>
</div>
{/* Last row: groups span the full width, showing every group (no label) */}
<div className='min-w-0'>
{groups.length > 0 ? (
<div className='flex flex-wrap gap-1'>
{groups.map((g) => (
<GroupBadge
key={g}
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
/>
))}
</div>
) : (
<span className='text-muted-foreground text-sm'>-</span>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{visibleColumnIds.has('name') && (
<DataTableCardField
label={isTagRow ? t('Tag') : t('Name')}
span={2}
contentMode='wrap'
>
{nameCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('id') && (
<DataTableCardField label={t('ID')} contentMode='full'>
{idCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('balance') && (
<DataTableCardField
label={t('Used / Remaining')}
span={2}
contentMode='full'
>
{balanceCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('models') && (
<DataTableCardField
label={t('Models')}
span={2}
contentMode='summary'
>
{modelsCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('response_time') && (
<DataTableCardField label={t('Response')} contentMode='full'>
{responseCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('test_time') && (
<DataTableCardField label={t('Last Tested')} contentMode='full'>
{testCell ?? emptyValue}
</DataTableCardField>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('group') && (
<DataTableCardField
label={t('Groups')}
span={2}
contentMode='summary'
>
{groupsCell ?? emptyValue}
</DataTableCardField>
)}
{!isTagRow && visibleColumnIds.has('tag') && (
<DataTableCardField label={t('Tag')} span={2} contentMode='wrap'>
{tagCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('priority') && (
<DataTableCardField label={t('Priority')} contentMode='full'>
{priorityCell ?? emptyValue}
</DataTableCardField>
)}
{visibleColumnIds.has('weight') && (
<DataTableCardField label={t('Weight')} contentMode='full'>
{weightCell ?? emptyValue}
</DataTableCardField>
)}
</DataTableCardDetails>
)}
</div>
</ChannelRowActionsLayoutContext.Provider>
)
}
/**
* Memoized so each card only re-renders when its own react-table row reference
* changes, instead of every card re-rendering whenever the parent table state
* (filters, pagination, sensitive toggle, etc.) updates.
*/
export const ChannelCard = memo(ChannelCardComponent)
export const ChannelCard = ChannelCardComponent
@@ -33,12 +33,12 @@ import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { BadgeListCell } from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { TruncatedText } from '@/components/truncated-text'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Tooltip,
@@ -128,10 +128,9 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
<div className='flex items-center gap-0.5'>
{addCount > 0 && (
<StatusBadge
label={`+${addCount}`}
variant='success'
size='sm'
copyable={false}
render={<button type='button' />}
className='cursor-pointer'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
@@ -143,14 +142,15 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
'add'
)
}}
/>
>
{`+${addCount}`}
</StatusBadge>
)}
{removeCount > 0 && (
<StatusBadge
label={`-${removeCount}`}
variant='danger'
variant='destructive'
size='sm'
copyable={false}
render={<button type='button' />}
className='cursor-pointer'
onClick={(e: React.MouseEvent) => {
e.stopPropagation()
@@ -162,7 +162,9 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
'remove'
)
}}
/>
>
{`-${removeCount}`}
</StatusBadge>
)}
</div>
)
@@ -362,18 +364,11 @@ function BalanceCell({ channel }: { channel: Channel }) {
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={
sensitiveVisible
? `${t('Used:')} ${usedDisplay}`
: maskedUsedLabel
}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='cursor-help'
/>
<StatusBadge variant='neutral' size='sm' className='cursor-help'>
{sensitiveVisible
? `${t('Used:')} ${usedDisplay}`
: maskedUsedLabel}
</StatusBadge>
}
/>
<TooltipContent>
@@ -435,18 +430,19 @@ function BalanceCell({ channel }: { channel: Channel }) {
return (
<TooltipProvider>
<div className='flex items-center gap-1'>
<div
className={
layout === 'card'
? 'flex flex-wrap items-center gap-1'
: 'flex items-center gap-1'
}
>
<Tooltip>
<TooltipTrigger
render={
<StatusBadge
label={sensitiveVisible ? usedDisplay : SENSITIVE_MASK}
variant='neutral'
size='sm'
copyable={false}
showDot={false}
className='cursor-help'
/>
<StatusBadge variant='neutral' size='sm' className='cursor-help'>
{sensitiveVisible ? usedDisplay : SENSITIVE_MASK}
</StatusBadge>
}
/>
<TooltipContent>
@@ -457,14 +453,14 @@ function BalanceCell({ channel }: { channel: Channel }) {
<TooltipTrigger
render={
<StatusBadge
label={remainingBadgeLabel}
variant={remainingBadgeVariant}
size='sm'
copyable={false}
showDot={false}
render={<button type='button' />}
className='cursor-pointer'
onClick={handleClickUpdate}
/>
>
{remainingBadgeLabel}
</StatusBadge>
}
/>
<TooltipContent>
@@ -570,7 +566,11 @@ export function useChannelsColumns(
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
meta: {
cardRole: 'primary',
cardOrder: 20,
contentMode: 'full',
},
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={sensitiveVisible ? id : SENSITIVE_MASK} />
@@ -581,7 +581,11 @@ export function useChannelsColumns(
{
accessorKey: 'name',
header: t('Name'),
meta: { mobileTitle: true },
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const name = row.getValue('name') as string
@@ -596,8 +600,7 @@ export function useChannelsColumns(
<div className='flex items-center gap-2'>
<Button
variant='ghost'
size='sm'
className='h-6 w-6 p-0'
size='icon-sm'
onClick={row.getToggleExpandedHandler()}
>
{row.getIsExpanded() ? (
@@ -608,12 +611,9 @@ export function useChannelsColumns(
</Button>
<div className='flex items-center gap-1.5'>
<span className='font-semibold'>Tag{tag}</span>
<StatusBadge
label={`${childrenCount} channels`}
variant='blue'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{`${childrenCount} channels`}
</StatusBadge>
</div>
</div>
)
@@ -693,17 +693,20 @@ export function useChannelsColumns(
{
accessorKey: 'type',
header: t('Type'),
meta: {
cardRole: 'primary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
if (isTagRow) {
return (
<StatusBadge
label={t('Tag Aggregate')}
variant='blue'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{t('Tag Aggregate')}
</StatusBadge>
)
}
@@ -755,11 +758,7 @@ export function useChannelsColumns(
>
<ProviderBadge
iconKey={`${iconName}.Color`}
iconSize={18}
label={typeName}
colorText={false}
copyable={false}
showDot={false}
className='max-w-full min-w-0 overflow-hidden'
/>
</TooltipTrigger>
@@ -771,8 +770,11 @@ export function useChannelsColumns(
<Tooltip>
<TooltipTrigger
render={
<span
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
<StatusBadge
variant='neutral'
size='sm'
render={<button type='button' />}
className='cursor-pointer'
onClick={(e) => {
e.stopPropagation()
if (!deploymentId) {
@@ -784,13 +786,7 @@ export function useChannelsColumns(
/>
}
>
<StatusBadge
label='IO.NET'
variant='purple'
size='sm'
copyable={false}
className='cursor-pointer'
/>
IO.NET
</TooltipTrigger>
<TooltipContent side='top'>
<div className='max-w-xs space-y-1'>
@@ -827,7 +823,7 @@ export function useChannelsColumns(
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
meta: { cardRole: 'badge', contentMode: 'wrap' },
cell: ({ row }) => {
const isTagRow = isTagAggregateRow(row.original)
const status = row.getValue('status') as number
@@ -840,21 +836,15 @@ export function useChannelsColumns(
if (hasEnabled) {
return (
<StatusBadge
label={`Active (${childrenCount})`}
variant='success'
size='sm'
copyable={false}
/>
<StatusBadge variant='success' size='sm'>
{`Active (${childrenCount})`}
</StatusBadge>
)
} else {
return (
<StatusBadge
label={`Inactive (${childrenCount})`}
variant='neutral'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{`Inactive (${childrenCount})`}
</StatusBadge>
)
}
}
@@ -899,12 +889,9 @@ export function useChannelsColumns(
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger render={<span />}>
<StatusBadge
label={label}
variant={config.variant}
size='sm'
copyable={false}
/>
<StatusBadge variant={config.variant} size='sm'>
{label}
</StatusBadge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='space-y-1 text-xs'>
@@ -927,12 +914,9 @@ export function useChannelsColumns(
}
return (
<StatusBadge
label={label}
variant={config.variant}
size='sm'
copyable={false}
/>
<StatusBadge variant={config.variant} size='sm'>
{label}
</StatusBadge>
)
},
filterFn: (row, id, value) => {
@@ -956,7 +940,12 @@ export function useChannelsColumns(
{
accessorKey: 'models',
header: t('Models'),
meta: { mobileHidden: true },
meta: {
cardRole: 'primary',
cardOrder: 40,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const models = row.getValue('models') as string
const modelArray = parseModelsList(models)
@@ -965,11 +954,12 @@ export function useChannelsColumns(
items={modelArray.map((model) => (
<StatusBadge
key={model}
label={model}
autoColor={model}
variant='neutral'
size='sm'
className='font-mono'
/>
title={model}
>
{model}
</StatusBadge>
))}
/>
)
@@ -982,7 +972,12 @@ export function useChannelsColumns(
{
accessorKey: 'group',
header: t('Groups'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const group = row.getValue('group') as string
const groupArray = parseGroupsList(group)
@@ -994,6 +989,7 @@ export function useChannelsColumns(
group={g}
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
size='sm'
title={sensitiveVisible ? g : undefined}
/>
))}
/>
@@ -1015,14 +1011,23 @@ export function useChannelsColumns(
{
accessorKey: 'tag',
header: t('Tag'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ row }) => {
const tag = row.getValue('tag') as string | null
if (!tag) {
return <span className='text-muted-foreground text-xs'>-</span>
}
return <StatusBadge label={tag} autoColor={tag} size='sm' />
return (
<StatusBadge variant='neutral' size='sm' title={tag}>
{tag}
</StatusBadge>
)
},
size: 120,
enableSorting: false,
@@ -1032,7 +1037,11 @@ export function useChannelsColumns(
{
accessorKey: 'priority',
header: t('Priority'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 30,
contentMode: 'full',
},
cell: ({ row }) => <PriorityCell channel={row.original} />,
size: 100,
},
@@ -1041,7 +1050,11 @@ export function useChannelsColumns(
{
accessorKey: 'weight',
header: t('Weight'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 40,
contentMode: 'full',
},
cell: ({ row }) => <WeightCell channel={row.original} />,
size: 90,
enableSorting: false,
@@ -1051,6 +1064,12 @@ export function useChannelsColumns(
{
accessorKey: 'balance',
header: t('Used / Remaining'),
meta: {
cardRole: 'primary',
cardOrder: 30,
cardSpan: 2,
contentMode: 'full',
},
cell: ({ row }) => <BalanceCell channel={row.original} />,
size: 180,
},
@@ -1059,18 +1078,19 @@ export function useChannelsColumns(
{
accessorKey: 'response_time',
header: t('Response'),
meta: { mobileHidden: true },
meta: {
cardRole: 'primary',
cardOrder: 50,
contentMode: 'full',
},
cell: ({ row }) => {
const responseTime = row.getValue('response_time') as number
const config = getResponseTimeConfig(responseTime)
return (
<StatusBadge
label={formatResponseTime(responseTime, t)}
variant={config.variant}
size='sm'
copyable={false}
/>
<StatusBadge variant={config.variant} size='sm'>
{formatResponseTime(responseTime, t)}
</StatusBadge>
)
},
size: 110,
@@ -1080,7 +1100,11 @@ export function useChannelsColumns(
{
accessorKey: 'test_time',
header: t('Last Tested'),
meta: { mobileHidden: true },
meta: {
cardRole: 'primary',
cardOrder: 60,
contentMode: 'full',
},
cell: ({ row }) => {
const testTime = row.getValue('test_time') as number
@@ -1099,12 +1123,12 @@ export function useChannelsColumns(
<TooltipTrigger
render={
<StatusBadge
label={timeText}
variant='neutral'
size='sm'
copyable={false}
className='cursor-pointer'
/>
>
{timeText}
</StatusBadge>
}
/>
<TooltipContent side='top'>
@@ -34,7 +34,7 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -156,7 +156,6 @@ export function ChannelsPrimaryButtons() {
setCurrentRow(null)
setOpen('create-channel')
}}
size='sm'
disabled={!canEditSensitive}
>
<Plus className='h-4 w-4' />
@@ -173,7 +172,7 @@ export function ChannelsPrimaryButtons() {
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
<DropdownMenuTrigger render={<Button variant='outline' />}>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
@@ -35,8 +35,8 @@ import {
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Tooltip,
TooltipContent,
@@ -114,7 +114,8 @@ export function ChannelsTable() {
navigate: route.useNavigate(),
pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeStorageKey: 'channels:page-size:v1',
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
@@ -312,10 +313,6 @@ export function ChannelsTable() {
columns,
totalCount,
sorting,
initialColumnVisibility: {
models: false,
tag: false,
},
columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY,
columnSizingStorageKey: isMobile
? false
@@ -409,6 +406,7 @@ export function ChannelsTable() {
<DataTablePage
table={table}
columns={columns}
tableLabel={t('Channels')}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Channels Found')}
@@ -426,12 +424,14 @@ export function ChannelsTable() {
toolbarProps={{
searchPlaceholder: t('Filter by name, ID, or key...'),
searchDebounceMs: 500,
hasAdditionalFilters: Boolean(modelFilterInput.trim()),
onReset: () => {
resetModelFilterInput()
},
additionalSearch: (
<Input
placeholder={t('Filter by model...')}
aria-label={t('Filter by model...')}
value={modelFilterInput}
onChange={onModelFilterInputChange}
onCompositionStart={onModelFilterCompositionStart}
@@ -468,7 +468,7 @@ export function ChannelsTable() {
size='icon'
onClick={() => setSensitiveVisible(!sensitiveVisible)}
aria-label={sensitiveVisible ? t('Hide') : t('Show')}
className='text-muted-foreground hover:text-foreground size-8'
className='text-muted-foreground hover:text-foreground'
/>
}
>
@@ -23,9 +23,9 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Tooltip,
@@ -116,7 +116,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={handleEnableAll}
className='size-8'
aria-label={t('Enable selected channels')}
title={t('Enable selected channels')}
/>
@@ -137,7 +136,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={handleDisableAll}
className='size-8'
aria-label={t('Disable selected channels')}
title={t('Disable selected channels')}
/>
@@ -158,7 +156,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={() => setShowTagDialog(true)}
className='size-8'
aria-label={t('Set tag for selected channels')}
title={t('Set tag for selected channels')}
/>
@@ -186,7 +183,6 @@ export function DataTableBulkActions<TData>({
}}
aria-disabled={!canEditSensitive}
className={cn(
'size-8',
!canEditSensitive && 'cursor-not-allowed opacity-50'
)}
aria-label={t('Delete selected channels')}
@@ -38,7 +38,7 @@ import { useContext, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -255,8 +255,9 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
<DropdownMenuTrigger
render={
<Button
size='icon-sm'
variant='ghost'
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
className='data-popup-open:bg-muted'
/>
}
>
@@ -22,7 +22,7 @@ import { Power, PowerOff, Pencil, Edit } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -21,10 +21,8 @@ import { type ReactNode, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
@@ -32,7 +30,9 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import {
@@ -316,7 +316,6 @@ export function AdvancedCustomEditorDialog({
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
@@ -324,7 +323,6 @@ export function AdvancedCustomEditorDialog({
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
@@ -343,7 +341,7 @@ export function AdvancedCustomEditorDialog({
)
}
>
<SelectTrigger className='h-8 max-w-full min-w-[260px] flex-1 sm:w-[320px]'>
<SelectTrigger className='max-w-full min-w-[260px] flex-1 sm:w-[320px]'>
<SelectValue className='min-w-0 truncate'>
{t(templateLabel)}
</SelectValue>
@@ -370,7 +368,6 @@ export function AdvancedCustomEditorDialog({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => applyTemplate('fill')}
>
{t('Fill Template')}
@@ -378,7 +375,6 @@ export function AdvancedCustomEditorDialog({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => applyTemplate('append')}
>
{t('Append Template')}
@@ -389,12 +385,7 @@ export function AdvancedCustomEditorDialog({
{editMode === 'visual' ? (
<div className='flex flex-col gap-4 p-4 lg:gap-3'>
<div className='flex justify-end border-y py-4 lg:py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={addRoute}
>
<Button type='button' variant='outline' onClick={addRoute}>
<Plus className='mr-2 h-4 w-4' />
{t('Add route')}
</Button>
@@ -443,12 +434,7 @@ export function AdvancedCustomEditorDialog({
) : (
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
<Button type='button' variant='outline' onClick={formatJson}>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
@@ -22,8 +22,8 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
@@ -50,6 +50,16 @@ import {
DataTableView,
useDataTable,
} from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
@@ -58,18 +68,8 @@ import {
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
@@ -353,7 +353,7 @@ function ChannelTestDialogContent({
useState<FailureDetailsState | null>(null)
const [pagination, setPagination] = useState({
pageIndex: 0,
pageSize: 30,
pageSize: 20,
})
const endpointSelectItems = useMemo(
() =>
@@ -412,7 +412,7 @@ function ChannelTestDialogContent({
setIsDeleteFailedDialogOpen(false)
setIsDeletingFailed(false)
setFailureDetails(null)
setPagination({ pageIndex: 0, pageSize: 30 })
setPagination({ pageIndex: 0, pageSize: 20 })
}, [])
const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
@@ -876,12 +876,9 @@ function ChannelTestDialogContent({
{model}
</span>
{isDefault && (
<StatusBadge
label={t('Default')}
variant='info'
size='sm'
copyable={false}
/>
<StatusBadge variant='info' size='sm'>
{t('Default')}
</StatusBadge>
)}
</div>
)
@@ -1062,7 +1059,6 @@ function ChannelTestDialogContent({
{isBatchTesting ? (
<Button
variant='outline'
size='sm'
onClick={handleStopBatchTest}
disabled={isBatchStopRequested}
>
@@ -1073,7 +1069,6 @@ function ChannelTestDialogContent({
) : (
<>
<Button
size='sm'
onClick={() => handleBatchTest(filteredModels)}
disabled={isAnyTesting || filteredModels.length === 0}
>
@@ -1082,7 +1077,6 @@ function ChannelTestDialogContent({
{successModels.length > 0 && (
<Button
variant='outline'
size='sm'
onClick={handleSelectSuccessfulModels}
>
<CheckCircle2 data-icon='inline-start' />
@@ -1094,7 +1088,6 @@ function ChannelTestDialogContent({
{failedModels.length > 0 && (
<Button
variant='outline'
size='sm'
onClick={() => setIsDeleteFailedDialogOpen(true)}
>
<Trash2 data-icon='inline-start' />
@@ -1190,14 +1183,12 @@ function TestStatusCell({ result }: { result?: TestResult }) {
const { t } = useTranslation()
if (!result || result.status === 'idle') {
return (
<StatusBadge label={t('Not tested')} variant='neutral' copyable={false} />
)
return <StatusBadge variant='neutral'>{t('Not tested')}</StatusBadge>
}
if (result.status === 'testing') {
return (
<StatusBadge variant='info' copyable={false}>
<StatusBadge variant='info'>
<Loader2 className='size-3.5 shrink-0 animate-spin' />
<span className='min-w-0 truncate leading-normal'>
{t('Testing...')}
@@ -1207,12 +1198,10 @@ function TestStatusCell({ result }: { result?: TestResult }) {
}
if (result.status === 'success') {
return (
<StatusBadge label={t('Success')} variant='success' copyable={false} />
)
return <StatusBadge variant='success'>{t('Success')}</StatusBadge>
}
return <StatusBadge label={t('Failed')} variant='danger' copyable={false} />
return <StatusBadge variant='destructive'>{t('Failed')}</StatusBadge>
}
function TestResultCell({
@@ -1290,7 +1279,6 @@ function FailureResultContent({
<Button
variant='outline'
size='sm'
className='h-7 w-fit px-2 text-xs'
onClick={() =>
window.open('/system-settings/billing/model-pricing', '_blank')
}
@@ -1303,7 +1291,6 @@ function FailureResultContent({
<Button
variant='ghost'
size='sm'
className='h-7 w-fit px-2 text-xs'
aria-haspopup='dialog'
onClick={() => onOpenDetails({ model, summary, details })}
>
@@ -1407,7 +1394,6 @@ function TestModelsBulkActions({ table }: { table: TanStackTable<ModelRow> }) {
<TooltipTrigger
render={
<Button
size='sm'
onClick={handleCopySelected}
disabled={selectedModels.length === 0}
/>
@@ -29,14 +29,10 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import {
StatusBadge,
textColorMap,
type StatusBadgeProps,
} from '@/components/status-badge'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Card,
CardAction,
@@ -332,8 +328,8 @@ const PLAN_TYPE_BADGE: Record<
> = {
enterprise: { label: 'Enterprise', variant: 'success' },
team: { label: 'Team', variant: 'info' },
pro: { label: 'Pro', variant: 'blue' },
plus: { label: 'Plus', variant: 'purple' },
pro: { label: 'Pro', variant: 'neutral' },
plus: { label: 'Plus', variant: 'neutral' },
free: { label: 'Free', variant: 'warning' },
}
@@ -376,7 +372,7 @@ function windowLabel(windowData?: CodexRateLimitWindow | null) {
const percent = clampPercent(windowData?.used_percent)
let variant: StatusBadgeProps['variant'] = 'info'
if (percent >= 95) {
variant = 'danger'
variant = 'destructive'
} else if (percent >= 80) {
variant = 'warning'
}
@@ -388,27 +384,28 @@ function getUsageStatusBadge(
t: (key: string) => string
) {
if (!rateLimit || Object.keys(rateLimit).length === 0) {
return (
<StatusBadge label={t('Pending')} variant='neutral' copyable={false} />
)
return <StatusBadge variant='neutral'>{t('Pending')}</StatusBadge>
}
if (rateLimit.allowed && !rateLimit.limit_reached) {
return (
<StatusBadge label={t('Available')} variant='success' copyable={false} />
)
return <StatusBadge variant='success'>{t('Available')}</StatusBadge>
}
return <StatusBadge label={t('Limited')} variant='danger' copyable={false} />
return <StatusBadge variant='destructive'>{t('Limited')}</StatusBadge>
}
function formatLabelValue(label: string, value: string) {
return label.endsWith('') ? `${label}${value}` : `${label} ${value}`
}
// Single source of truth for variant → text color lives in status-badge.
const percentTextClassName: Record<
NonNullable<StatusBadgeProps['variant']>,
string
> = textColorMap
> = {
neutral: 'text-muted-foreground',
info: 'text-status-info',
success: 'text-status-success',
warning: 'text-status-warning',
destructive: 'text-status-destructive',
}
type RateLimitWindowProps = {
title: string
@@ -648,11 +645,9 @@ function ResetCreditItem(props: { credit: CodexResetCredit; index: number }) {
<div className='min-w-0 text-sm font-medium break-words'>
{title}
</div>
<StatusBadge
label={t(statusBadge.label)}
variant={statusBadge.variant}
copyable={false}
/>
<StatusBadge variant={statusBadge.variant}>
{t(statusBadge.label)}
</StatusBadge>
</div>
{props.credit.description ? (
<div className='text-muted-foreground mt-1 text-xs leading-5'>
@@ -801,7 +796,6 @@ function ResetCreditsPanel(props: {
<Button
type='button'
variant='outline'
size='sm'
onClick={props.onRefresh}
disabled={props.isLoading}
>
@@ -822,7 +816,6 @@ function ResetCreditsPanel(props: {
<Button
type='button'
variant={canReset ? 'destructive' : 'outline'}
size='sm'
onClick={props.onRequestReset}
disabled={!canReset || props.isLoading || props.isResetting}
className='shrink-0'
@@ -1079,7 +1072,6 @@ export function CodexUsageDialog({
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
@@ -1091,35 +1083,25 @@ export function CodexUsageDialog({
</CardHeader>
<CardContent className='p-4 pt-0'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
<StatusBadge variant={accountBadge.variant}>
{accountBadge.label}
</StatusBadge>
{getUsageStatusBadge(rateLimit, t)}
<StatusBadge
label={`HTTP ${response?.upstream_status ?? '-'}`}
variant='neutral'
copyable={false}
/>
<StatusBadge
label={formatLabelValue(t('Reset count:'), resetCreditsText)}
variant={Number(resetCredits) > 0 ? 'blue' : 'neutral'}
copyable={false}
/>
<StatusBadge variant='neutral'>
{`HTTP ${response?.upstream_status ?? '-'}`}
</StatusBadge>
<StatusBadge variant='neutral'>
{formatLabelValue(t('Reset count:'), resetCreditsText)}
</StatusBadge>
{payload?.credits?.overage_limit_reached ? (
<StatusBadge
label={t('Overage limited')}
variant='danger'
copyable={false}
/>
<StatusBadge variant='destructive'>
{t('Overage limited')}
</StatusBadge>
) : null}
{payload?.spend_control?.reached ? (
<StatusBadge
label={t('Spend limited')}
variant='danger'
copyable={false}
/>
<StatusBadge variant='destructive'>
{t('Spend limited')}
</StatusBadge>
) : null}
</div>
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2'>
@@ -1158,11 +1140,9 @@ export function CodexUsageDialog({
<div className='text-sm font-semibold'>
{t('Reset Credits')}
</div>
<StatusBadge
label={`${t('Available')} ${resetCreditsText}`}
variant={Number(resetCredits) > 0 ? 'blue' : 'neutral'}
copyable={false}
/>
<StatusBadge variant='neutral'>
{`${t('Available')} ${resetCreditsText}`}
</StatusBadge>
</div>
<div className='text-muted-foreground mt-1 text-xs leading-5'>
{t('View issued reset credits, grant dates, and expiration.')}
@@ -1262,7 +1242,6 @@ export function CodexUsageDialog({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
@@ -21,10 +21,10 @@ import { Loader2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { handleCopyChannel } from '../../lib'
@@ -17,18 +17,13 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { Loader2, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
@@ -36,7 +31,12 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
@@ -285,15 +285,16 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
<Badge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
variant='secondary'
render={<button type='button' />}
onClick={() => handleRemoveModel(model)}
aria-label={`${t('Remove')}: ${model}`}
>
{model} ×
</StatusBadge>
{model}
<X data-icon='inline-end' aria-hidden='true' />
</Badge>
))
) : (
<span className='text-muted-foreground text-sm'>
@@ -304,12 +305,10 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
items={availableModels.map((model) => ({
value: model,
label: model,
}))}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
@@ -382,7 +381,6 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
@@ -398,7 +396,6 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
@@ -406,7 +403,6 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
@@ -22,17 +22,22 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/design-system/tabs'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
@@ -27,8 +27,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@/components/design-system/alert-dialog'
import { Button } from '@/components/design-system/button'
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
@@ -24,9 +24,7 @@ import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Select,
SelectContent,
@@ -34,7 +32,9 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Separator } from '@/components/ui/separator'
import {
ADMIN_PERMISSION_ACTIONS,
@@ -88,7 +88,7 @@ export function MultiKeyManageDialog({
const [isLoading, setIsLoading] = useState(false)
const [keys, setKeys] = useState<KeyStatus[]>([])
const [currentPage, setCurrentPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [pageSize, setPageSize] = useState(20)
const [total, setTotal] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [enabledCount, setEnabledCount] = useState(0)
@@ -131,7 +131,7 @@ export function MultiKeyManageDialog({
setKeys(response.data.keys || [])
setTotal(response.data.total || 0)
setCurrentPage(response.data.page || 1)
setPageSize(response.data.page_size || 10)
setPageSize(response.data.page_size || 20)
setTotalPages(response.data.total_pages || 0)
setEnabledCount(response.data.enabled_count || 0)
setManualDisabledCount(response.data.manual_disabled_count || 0)
@@ -218,14 +218,7 @@ export function MultiKeyManageDialog({
const renderStatusBadge = (status: number) => {
const config = getMultiKeyStatusConfig(status)
return (
<StatusBadge
label={t(config.label)}
variant={config.variant}
showDot
copyable={false}
/>
)
return <StatusBadge variant={config.variant}>{t(config.label)}</StatusBadge>
}
const formatKeyTimestamp = (timestamp?: number) => {
@@ -243,21 +236,13 @@ export function MultiKeyManageDialog({
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>{currentRow.name}</StatusBadge>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>
{currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')}
</StatusBadge>
)}
</>
}
@@ -294,12 +279,10 @@ export function MultiKeyManageDialog({
{/* Toolbar */}
<div className='flex shrink-0 items-center justify-between'>
<Select
items={[
...MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
})),
]}
items={MULTI_KEY_FILTER_OPTIONS.map((option) => ({
value: option.value,
label: t(option.label),
}))}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
@@ -320,7 +303,6 @@ export function MultiKeyManageDialog({
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
@@ -330,7 +312,6 @@ export function MultiKeyManageDialog({
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
@@ -341,7 +322,6 @@ export function MultiKeyManageDialog({
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
@@ -352,7 +332,6 @@ export function MultiKeyManageDialog({
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => {
if (!canEditSensitive) return
setConfirmAction({ type: 'delete-disabled' })
@@ -378,15 +357,17 @@ export function MultiKeyManageDialog({
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
{isLoading && (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
)}
{!isLoading && keys.length === 0 && (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
)}
{!isLoading && keys.length > 0 && (
<StaticDataTable
className='rounded-none border-0'
tableClassName='min-w-[800px]'
@@ -450,7 +431,6 @@ export function MultiKeyManageDialog({
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
@@ -458,7 +438,6 @@ export function MultiKeyManageDialog({
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import type { MultiKeyConfirmAction } from '../../types'
@@ -22,7 +22,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import {
AlertDialog,
AlertDialogAction,
@@ -32,10 +31,11 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
} from '@/components/design-system/alert-dialog'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
@@ -484,14 +484,13 @@ export function OllamaModelsDialog({
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
<Button variant='outline' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
<Button variant='outline' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
@@ -499,7 +498,6 @@ export function OllamaModelsDialog({
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
@@ -547,7 +545,8 @@ export function OllamaModelsDialog({
<Button
variant='ghost'
size='sm'
size='icon-sm'
aria-label={t('Delete')}
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
@@ -36,16 +36,8 @@ import {
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
@@ -53,7 +45,15 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { Badge } from '@/components/ui/badge'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
@@ -1725,7 +1725,6 @@ export function ParamOverrideEditorDialog(
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
@@ -1733,7 +1732,6 @@ export function ParamOverrideEditorDialog(
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
@@ -1756,7 +1754,7 @@ export function ParamOverrideEditorDialog(
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectTrigger className='w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -1772,7 +1770,6 @@ export function ParamOverrideEditorDialog(
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
@@ -1780,17 +1777,11 @@ export function ParamOverrideEditorDialog(
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
<Button type='button' variant='ghost' onClick={resetEditorState}>
{t('Reset')}
</Button>
</div>
@@ -1830,7 +1821,7 @@ export function ParamOverrideEditorDialog(
<Button
type='button'
variant='ghost'
size='sm'
size='icon-sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
@@ -1860,7 +1851,7 @@ export function ParamOverrideEditorDialog(
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
className='pl-8 text-xs'
/>
</div>
</div>
@@ -2012,12 +2003,7 @@ export function ParamOverrideEditorDialog(
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
<Button type='button' variant='outline' onClick={formatJson}>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
@@ -2121,7 +2107,6 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => ruleEditorProps.duplicateOperation(operation.id)}
>
<Copy className='mr-1 h-3.5 w-3.5' />
@@ -2130,7 +2115,6 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => ruleEditorProps.removeOperation(operation.id)}
>
@@ -2159,7 +2143,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
})
}
>
<SelectTrigger className='h-9'>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -2186,7 +2170,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
})
}
placeholder={getModePathPlaceholder(mode)}
className='h-9'
className=''
/>
</div>
)}
@@ -2220,7 +2204,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
'e.g. Clean tool parameters to avoid upstream validation errors'
)}
maxLength={180}
className='h-9'
className=''
/>
</div>
@@ -2251,8 +2235,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs'
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs sm:h-auto'
onClick={() => {
try {
const parsed = JSON.parse(operation.value_text)
@@ -2322,7 +2305,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
})
}
placeholder={getModeFromPlaceholder(mode)}
className='h-9'
className=''
/>
</div>
)}
@@ -2339,7 +2322,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
})
}
placeholder={getModeToPlaceholder(mode)}
className='h-9'
className=''
/>
</div>
)}
@@ -2364,7 +2347,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
})
}
>
<SelectTrigger className='h-7 w-[120px] text-xs'>
<SelectTrigger className='w-[120px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -2381,8 +2364,6 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={ruleEditorProps.expandAllConditions}
>
<ChevronDown className='mr-1 h-3 w-3' />
@@ -2391,8 +2372,6 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 text-xs'
onClick={ruleEditorProps.collapseAllConditions}
>
<ChevronUp className='mr-1 h-3 w-3' />
@@ -2403,8 +2382,6 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => ruleEditorProps.addCondition(operation.id)}
>
<Plus className='mr-1 h-3 w-3' />
@@ -2498,8 +2475,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-7 text-xs'
className='text-destructive hover:text-destructive'
onClick={() =>
conditionEditorProps.removeCondition(
conditionEditorProps.operationId,
@@ -2524,7 +2500,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
)
}
placeholder='model'
className='h-8 text-xs'
className='text-xs'
/>
</div>
<div className='space-y-1'>
@@ -2546,7 +2522,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
)
}
>
<SelectTrigger className='h-8 text-xs'>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -2574,7 +2550,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
)
}
placeholder='gpt'
className='h-8 text-xs'
className='text-xs'
/>
</div>
</div>
@@ -2641,8 +2617,6 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
<Button
type='button'
variant={draft.simpleMode ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
@@ -2655,8 +2629,6 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
<Button
type='button'
variant={draft.simpleMode ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
@@ -2707,7 +2679,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
)
}
placeholder='400'
className='h-8 text-xs'
className='text-xs'
/>
</div>
<div className='space-y-1'>
@@ -2723,7 +2695,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
)
}
placeholder='forced_bad_request'
className='h-8 text-xs'
className='text-xs'
/>
</div>
<div className='space-y-1'>
@@ -2739,7 +2711,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
)
}
placeholder='invalid_request_error'
className='h-8 text-xs'
className='text-xs'
/>
</div>
</div>
@@ -2750,8 +2722,6 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
<Button
type='button'
variant={draft.skipRetry ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
@@ -2764,8 +2734,6 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
<Button
type='button'
variant={draft.skipRetry ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
@@ -2801,8 +2769,6 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
key={preset.code}
type='button'
variant='outline'
size='sm'
className='h-6 text-xs'
onClick={() =>
returnErrorEditorProps.updateDraft(
returnErrorEditorProps.operationId,
@@ -2859,8 +2825,6 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant={draft.simpleMode ? 'default' : 'outline'}
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
@@ -2873,8 +2837,6 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant={draft.simpleMode ? 'outline' : 'default'}
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
@@ -2898,7 +2860,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
)
}
placeholder='redacted_thinking'
className='h-8 text-xs'
className='text-xs'
/>
</div>
@@ -2924,7 +2886,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
)
}
>
<SelectTrigger className='h-8 text-xs'>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -2945,8 +2907,6 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant={draft.recursive ? 'default' : 'outline'}
size='sm'
className='h-8 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
@@ -2959,8 +2919,6 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant={draft.recursive ? 'outline' : 'default'}
size='sm'
className='h-8 text-xs'
onClick={() =>
pruneObjectsEditorProps.updateDraft(
pruneObjectsEditorProps.operationId,
@@ -2982,8 +2940,6 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
pruneObjectsEditorProps.addRule(
pruneObjectsEditorProps.operationId
@@ -3014,8 +2970,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
<Button
type='button'
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive h-6 text-xs'
className='text-destructive hover:text-destructive'
onClick={() =>
pruneObjectsEditorProps.removeRule(
pruneObjectsEditorProps.operationId,
@@ -3042,7 +2997,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
)
}
placeholder='type'
className='h-7 text-xs'
className='text-xs'
/>
</div>
<div className='space-y-0.5'>
@@ -3066,7 +3021,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
)
}
>
<SelectTrigger className='h-7 text-xs'>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -3094,7 +3049,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
)
}
placeholder='redacted_thinking'
className='h-7 text-xs'
className='text-xs'
/>
</div>
</div>
@@ -3181,7 +3136,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
)
}
>
<SelectTrigger className='h-8 w-[110px] text-xs'>
<SelectTrigger className='w-[110px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -3208,7 +3163,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
)
}
placeholder='session_id'
className='h-8 text-xs'
className='text-xs'
/>
</div>
</div>
@@ -3236,7 +3191,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
)
}
>
<SelectTrigger className='h-8 w-[110px] text-xs'>
<SelectTrigger className='w-[110px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -3263,7 +3218,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
)
}
placeholder='prompt_cache_key'
className='h-8 text-xs'
className='text-xs'
/>
</div>
</div>
@@ -3285,8 +3240,6 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
key={preset.label}
type='button'
variant='outline'
size='sm'
className='h-6 text-xs'
onClick={() =>
syncFieldsEditorProps.updateOperation(
syncFieldsEditorProps.operationId,
@@ -20,10 +20,10 @@ import { AlertTriangle } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface StatusCodeRiskDialogProps {
@@ -22,11 +22,11 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
@@ -21,13 +21,18 @@ import { useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/design-system/tabs'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface UpstreamUpdateDialogProps {
open: boolean
@@ -97,8 +102,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
const handleConfirm = () => {
const hasAdd = props.addModels.length > 0
const hasRemove = props.removeModels.length > 0
const selectedAddArr = Array.from(selectedAdd)
const selectedRemoveArr = Array.from(selectedRemove)
const selectedAddArr = [...selectedAdd]
const selectedRemoveArr = [...selectedRemove]
const anyAdd = selectedAddArr.length > 0
const anyRemove = selectedRemoveArr.length > 0
@@ -152,13 +157,13 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
<StatusBadge variant='neutral' className='ml-1'>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
<StatusBadge variant='neutral' className='ml-1'>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
@@ -282,8 +287,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
handleConfirm={() => {
setPartialConfirmOpen(false)
props.onConfirm({
addModels: Array.from(selectedAdd),
removeModels: Array.from(selectedRemove),
addModels: [...selectedAdd],
removeModels: [...selectedRemove],
})
}}
/>
@@ -55,6 +55,17 @@ import { type SubmitErrorHandler, useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Combobox } from '@/components/design-system/combobox'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/design-system/select'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -67,8 +78,6 @@ import { JsonEditor } from '@/components/json-editor'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Combobox } from '@/components/ui/combobox'
import {
Form,
FormControl,
@@ -78,15 +87,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import {
Sheet,
@@ -1847,7 +1847,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='shrink-0'
onClick={pasteConnectionInfoFromClipboard}
>
@@ -1878,7 +1877,6 @@ export function ChannelMutateDrawer({
<span className='flex shrink-0 gap-2'>
<Button
type='button'
size='sm'
onClick={() => applyConnectionInfo(clipboardConnectionInfo)}
>
{t('Fill in')}
@@ -1886,7 +1884,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => setClipboardConnectionInfo(null)}
>
{t('Ignore')}
@@ -2806,7 +2803,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setAdvancedCustomEditorOpen(true)
}
@@ -2843,10 +2839,7 @@ export function ChannelMutateDrawer({
value={field.value}
>
<FormControl>
<SelectTrigger
size='sm'
className='w-full sm:w-56'
>
<SelectTrigger className='w-full sm:w-56'>
<SelectValue />
</SelectTrigger>
</FormControl>
@@ -2960,7 +2953,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleDeduplicateKeys}
className='w-fit'
>
@@ -2987,7 +2979,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleRevealKey}
disabled={
isChannelKeyLoading ||
@@ -3005,7 +2996,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='ghost'
size='sm'
onClick={async () => {
if (channelKey) {
await copyToClipboard(
@@ -3049,7 +3039,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleRefreshCodexCredential}
disabled={
sensitiveLocked ||
@@ -3258,7 +3247,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
const hiddenTargets = new Set(
modelMappingGuardrail.exposedTargetModels
@@ -3298,7 +3286,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFillRelatedModels}
disabled={!basicModels.length}
>
@@ -3311,7 +3298,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFillAllModels}
disabled={!allModelsList.length}
>
@@ -3326,7 +3312,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleFetchModels}
disabled={!isEditing && !canEditSensitive}
>
@@ -3348,7 +3333,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={handleCopyModels}
disabled={currentModelsArray.length === 0}
>
@@ -3361,7 +3345,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='ghost'
size='sm'
onClick={handleClearModels}
disabled={currentModelsArray.length === 0}
>
@@ -3382,7 +3365,6 @@ export function ChannelMutateDrawer({
key={group.id}
type='button'
variant='secondary'
size='sm'
onClick={() =>
handleAddPrefillGroup(group)
}
@@ -3515,7 +3497,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
updateModels([
...currentModelsArray,
@@ -3825,7 +3806,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setParamOverrideEditorOpen(true)
}
@@ -3836,7 +3816,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
field.onChange(
JSON.stringify(
@@ -3869,7 +3848,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => field.onChange('')}
>
{t('Clear')}
@@ -3913,7 +3891,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
field.onChange(
JSON.stringify(
@@ -3936,7 +3913,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
field.onChange(
JSON.stringify(
@@ -3952,7 +3928,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
onClick={() => {
try {
const parsed = JSON.parse(
@@ -3971,7 +3946,6 @@ export function ChannelMutateDrawer({
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => field.onChange('')}
>
{t('Clear')}
@@ -20,10 +20,15 @@ import { Code, Plus, Table, Trash2 } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/design-system/tabs'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
@@ -237,8 +242,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
<Button
type='button'
variant='link'
size='sm'
className='h-auto p-0'
className='h-auto p-0 sm:h-auto'
onClick={handleFillTemplate}
disabled={props.disabled}
>
@@ -299,7 +303,6 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
size='icon'
onClick={() => handleDeleteRow(row.id)}
disabled={props.disabled}
className='h-10 w-10'
aria-label={t('Delete mapping')}
>
<Trash2 className='h-4 w-4' aria-hidden='true' />
@@ -317,7 +320,6 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
<Button
type='button'
variant='outline'
size='sm'
onClick={handleAddRow}
disabled={props.disabled}
className='w-full'
+3 -3
View File
@@ -138,7 +138,7 @@ export const CHANNEL_STATUS_CONFIG = {
label: 'Enabled',
},
[CHANNEL_STATUS.MANUAL_DISABLED]: {
variant: 'danger' as const,
variant: 'destructive' as const,
label: 'Disabled',
},
[CHANNEL_STATUS.AUTO_DISABLED]: {
@@ -173,7 +173,7 @@ export const MULTI_KEY_STATUS_CONFIG = {
label: 'Manual Disabled',
},
[MULTI_KEY_STATUS.AUTO_DISABLED]: {
variant: 'danger' as const,
variant: 'destructive' as const,
label: 'Auto Disabled',
},
}
@@ -324,7 +324,7 @@ export const RESPONSE_TIME_CONFIG = {
EXCELLENT: { variant: 'success' as const, label: 'Excellent' },
GOOD: { variant: 'success' as const, label: 'Good' },
FAIR: { variant: 'warning' as const, label: 'Fair' },
POOR: { variant: 'danger' as const, label: 'Poor' },
POOR: { variant: 'destructive' as const, label: 'Poor' },
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
} as const
+2 -2
View File
@@ -340,12 +340,12 @@ export function formatBalance(balance: number | null | undefined): string {
*/
export function getBalanceVariant(
balance: number
): 'success' | 'warning' | 'danger' | 'neutral' {
): 'success' | 'warning' | 'destructive' | 'neutral' {
if (balance === 0) {
return 'neutral'
}
if (balance < 1) {
return 'danger'
return 'destructive'
}
if (balance < 10) {
return 'warning'
@@ -41,6 +41,8 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import { Toggle } from '@/components/design-system/toggle'
import { MultiSelect } from '@/components/multi-select'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
@@ -51,8 +53,6 @@ import {
EmptyTitle,
} from '@/components/ui/empty'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Toggle } from '@/components/ui/toggle'
import {
Tooltip,
TooltipContent,
@@ -20,8 +20,7 @@ import { Filter, X } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Command,
CommandEmpty,
@@ -30,7 +29,8 @@ import {
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
} from '@/components/design-system/command'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
@@ -108,7 +108,6 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
<Button
type='button'
variant='outline'
size='sm'
aria-label={t('Filter by node')}
/>
}
@@ -116,9 +115,7 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
<Filter data-icon='inline-start' aria-hidden='true' />
{selectedCount > 0 ? t('Selected nodes') : t('All nodes')}
{selectedCount > 0 && (
<Badge variant='secondary' className='rounded-sm px-1'>
{selectedCount}
</Badge>
<Badge variant='secondary'>{selectedCount}</Badge>
)}
</PopoverTrigger>
<PopoverContent
@@ -201,14 +198,14 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
<Badge
key={flowNodeFilterKey(option)}
variant='secondary'
className='max-w-[14rem] rounded-sm pr-1'
className='max-w-[14rem] pr-1'
>
<span className='truncate'>
{t(props.stageLabels[option.kind])}: {option.label}
</span>
<button
type='button'
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
className='hover:bg-muted-foreground/15 focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded-sm outline-none focus-visible:ring-2'
aria-label={t('Remove node filter')}
onClick={() =>
props.onRemoveNode({ kind: option.kind, id: option.id })
@@ -20,9 +20,7 @@ import { Save, Settings2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/design-system/button'
import {
Select,
SelectContent,
@@ -30,7 +28,9 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { Label } from '@/components/ui/label'
import {
CONSUMPTION_DISTRIBUTION_CHART_OPTIONS,
MODEL_ANALYTICS_CHART_OPTIONS,
@@ -71,7 +71,7 @@ export function ModelsChartPreferences(props: ModelsChartPreferencesProps) {
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Button variant='outline'>
<Settings2 className='mr-2 h-4 w-4' />
{t('Preferences')}
</Button>
@@ -21,11 +21,8 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { DateTimePicker } from '@/components/datetime-picker'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
@@ -33,7 +30,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
TIME_GRANULARITY_OPTIONS,
TIME_RANGE_PRESETS,
@@ -171,7 +171,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
open={open}
onOpenChange={handleOpenChange}
trigger={
<Button variant='outline' size='sm'>
<Button variant='outline'>
<Filter className='mr-2 h-4 w-4' />
{t('Filter')}
</Button>
@@ -210,7 +210,6 @@ export function ModelsFilter(props: ModelsFilterProps) {
<Button
key={range.days}
type='button'
size='sm'
variant={selectedRange === range.days ? 'default' : 'outline'}
onClick={() => handleQuickRange(range.days)}
className={cn(
@@ -21,13 +21,14 @@ import { Gauge, HeartPulse, Timer } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
import { Skeleton } from '@/components/ui/skeleton'
import { getPerfMetricsSummary } from '@/features/performance-metrics/api'
import {
formatLatency,
formatThroughput,
formatUptimePct,
getSuccessRateDotClass,
getSuccessRateLevel,
getSuccessRateTextClass,
} from '@/features/performance-metrics/lib/format'
import type { PerfModelSummary } from '@/features/performance-metrics/types'
@@ -35,6 +36,11 @@ import { cn } from '@/lib/utils'
const PERFORMANCE_WINDOW_HOURS = 24
const TOP_MODEL_LIMIT = 6
const METRIC_SKELETON_KEYS = [
'success-rate-skeleton',
'latency-skeleton',
'throughput-skeleton',
]
type WeightedMetric = 'avg_latency_ms' | 'avg_tps' | 'success_rate'
@@ -60,7 +66,7 @@ function simpleAverage(
count++
}
return count > 0 ? total / count : NaN
return count > 0 ? total / count : Number.NaN
}
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
@@ -128,8 +134,8 @@ export function PerformanceOverview() {
{/* 3 KPI inline metrics */}
{loading ? (
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className='flex items-center gap-1.5'>
{METRIC_SKELETON_KEYS.map((key) => (
<div key={key} className='flex items-center gap-1.5'>
<Skeleton className='h-3 w-14' />
<Skeleton className='h-4 w-16' />
</div>
@@ -201,27 +207,22 @@ function InlineMetric(props: {
function ModelBadge(props: { model: PerfModelSummary }) {
const model = props.model
const level = getSuccessRateLevel(model.success_rate)
let variant: StatusVariant = 'neutral'
if (level === 'excellent' || level === 'good') {
variant = 'success'
} else if (level === 'warning') {
variant = 'warning'
} else if (level === 'critical') {
variant = 'destructive'
}
return (
<span className='bg-muted/50 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1'>
<span className='max-w-[10rem] truncate font-mono text-xs'>
{model.model_name}
</span>
<span
className={cn(
'size-1.5 rounded-full',
getSuccessRateDotClass(model.success_rate)
)}
aria-hidden='true'
/>
<span
className={cn(
'text-xs font-semibold tabular-nums',
getSuccessRateTextClass(model.success_rate)
)}
>
<StatusBadge variant={variant}>
<span className='mr-1 max-w-[10rem] truncate'>{model.model_name}</span>
<span className='tabular-nums'>
{formatUptimePct(model.success_rate)}
</span>
</span>
</StatusBadge>
)
}
@@ -20,12 +20,9 @@ import { Zap, ExternalLink, Gauge } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { CopyButton } from '@/components/copy-button'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
getLatencyColorClass,
openExternalSpeedTest,
} from '@/features/dashboard/lib/api-info'
import { Button } from '@/components/design-system/button'
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
import { openExternalSpeedTest } from '@/features/dashboard/lib/api-info'
import type { ApiInfoItem, PingStatus } from '@/features/dashboard/types'
import { getBgColorClass } from '@/lib/colors'
import { cn } from '@/lib/utils'
@@ -40,6 +37,12 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
const { t } = useTranslation()
const item = props.item
const status = props.status
let latencyVariant: StatusVariant = 'success'
if (status.latency !== null && status.latency >= 500) {
latencyVariant = 'destructive'
} else if (status.latency !== null && status.latency >= 200) {
latencyVariant = 'warning'
}
return (
<div className='group hover:bg-muted/40 flex items-center justify-between gap-2 px-3 py-2.5 transition-colors sm:gap-3 sm:px-5 sm:py-3'>
@@ -69,36 +72,25 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center'>
{status.testing && (
<StatusBadge
label={t('Testing...')}
variant='warning'
className='animate-pulse'
copyable={false}
/>
<StatusBadge variant='warning'>{t('Testing...')}</StatusBadge>
)}
{status.latency !== null && !status.testing && (
<StatusBadge
variant='success'
label={`${status.latency}${t('ms')}`}
className={cn(
'font-mono font-medium',
getLatencyColorClass(status.latency)
)}
copyable={false}
/>
<StatusBadge variant={latencyVariant}>
{status.latency}
{t('ms')}
</StatusBadge>
)}
{status.error && (
<StatusBadge label={t('N/A')} variant='neutral' copyable={false} />
<StatusBadge variant='neutral'>{t('N/A')}</StatusBadge>
)}
</div>
<div className='flex items-center gap-0.5'>
<Button
variant='ghost'
size='sm'
size='icon-sm'
onClick={() => props.onTest(item.url)}
disabled={status.testing}
className='size-7 p-0'
title={t('Test Latency')}
>
<Zap
@@ -108,9 +100,9 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
<Button
variant='ghost'
size='sm'
size='icon-sm'
onClick={() => openExternalSpeedTest(item.url)}
className='hidden size-7 p-0 sm:inline-flex'
className='hidden sm:inline-flex'
title={t('External Speed Test')}
>
<Gauge className='size-3.5' />
@@ -119,8 +111,7 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
<CopyButton
value={item.url}
variant='ghost'
size='sm'
className='size-7 p-0'
size='icon-sm'
iconClassName='size-3.5'
tooltip={t('Copy URL')}
aria-label={t('Copy URL')}
@@ -128,8 +119,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
<Button
variant='ghost'
size='sm'
className='hidden size-7 p-0 sm:inline-flex'
size='icon-sm'
className='hidden sm:inline-flex'
title={t('Open in New Tab')}
render={<a href={item.url} target='_blank' rel='noreferrer' />}
>
@@ -16,47 +16,15 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import {
ArrowRight,
BookOpen,
Check,
ChevronDown,
ChevronUp,
Circle,
Copy,
CreditCard,
FileText,
KeyRound,
ListChecks,
RadioTower,
ShieldCheck,
TerminalSquare,
Timer,
type LucideIcon,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import {
CardStaggerContainer,
CardStaggerItem,
} from '@/components/page-transition'
import { Button } from '@/components/ui/button'
import { fetchTokenKey, getApiKeys } from '@/features/keys/api'
import type { ApiKey } from '@/features/keys/types'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { getUserModels } from '@/lib/api'
import { ROLE } from '@/lib/roles'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/stores/auth-store'
import {
useApiInfo,
useDashboardContentVisibility,
} from '../../hooks/use-status-data'
import { useDashboardContentVisibility } from '../../hooks/use-status-data'
import { AnnouncementsPanel } from './announcements-panel'
import { ApiInfoPanel } from './api-info-panel'
import { FAQPanel } from './faq-panel'
@@ -64,670 +32,22 @@ import { PerformanceHealthPanel } from './performance-health-panel'
import { SummaryCards } from './summary-cards'
import { UptimePanel } from './uptime-panel'
const SETUP_GUIDE_VISIBILITY_STORAGE_KEY =
'dashboard_overview_setup_guide_expanded'
const SETUP_GUIDE_CODE_PATTERN = [
'const request = await client.responses.create({',
" model: 'gpt-4.1-mini',",
" input: 'Start routing traffic',",
'})',
'',
'if (request.output_text) {',
' console.log(request.output_text)',
'}',
].join('\n')
type DashboardActionPath =
| '/keys'
| '/wallet'
| '/playground'
| '/channels'
| '/usage-logs'
| '/pricing'
interface StartStep {
title: string
description: string
to: DashboardActionPath
icon: LucideIcon
completed: boolean
}
interface QuickAction {
title: string
description: string
to: DashboardActionPath
icon: LucideIcon
adminOnly?: boolean
}
interface RequestExample {
endpoint: string
model: string
keyName: string
keyId?: number
displayKey: string
ready: boolean
}
interface HeroSignal {
label: string
value: string
icon: LucideIcon
}
function getSavedSetupGuideExpanded(): boolean | null {
if (typeof window === 'undefined') return null
const saved = window.localStorage.getItem(SETUP_GUIDE_VISIBILITY_STORAGE_KEY)
if (saved === 'expanded') return true
if (saved === 'collapsed') return false
return null
}
function saveSetupGuideExpanded(expanded: boolean): void {
if (typeof window === 'undefined') return
window.localStorage.setItem(
SETUP_GUIDE_VISIBILITY_STORAGE_KEY,
expanded ? 'expanded' : 'collapsed'
)
}
function getCurrentOrigin(): string {
if (typeof window === 'undefined') return ''
return window.location.origin
}
function normalizeEndpoint(sourceUrl?: string): string {
const fallback = `${getCurrentOrigin()}/v1/chat/completions`
const trimmed = sourceUrl?.trim()
if (!trimmed) return fallback
const withoutTrailingSlash = trimmed.replace(/\/+$/, '')
if (withoutTrailingSlash.endsWith('/v1/chat/completions')) {
return withoutTrailingSlash
}
if (withoutTrailingSlash.endsWith('/v1')) {
return `${withoutTrailingSlash}/chat/completions`
}
return `${withoutTrailingSlash}/v1/chat/completions`
}
function getPreferredKey(keys: ApiKey[]): ApiKey | null {
return keys.find((item) => item.status === 1) ?? keys[0] ?? null
}
function formatDisplayKey(key?: string): string {
if (!key) return 'sk-...'
if (key.length <= 14) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
function buildCurlCommand(args: {
endpoint: string
apiKey: string
model: string
}): string {
return [
`curl ${args.endpoint} \\`,
' -H "Content-Type: application/json" \\',
` -H "Authorization: Bearer ${args.apiKey}" \\`,
` -d '{"model":"${args.model}","messages":[{"role":"user","content":"Say hello in one sentence."}]}'`,
].join('\n')
}
function SetupGuideBackdrop(props: { compact?: boolean }) {
return (
<>
<div
className={cn(
'pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_48%_120%_at_78%_0%,color-mix(in_oklch,var(--primary)_8%,transparent)_0%,transparent_62%),linear-gradient(112deg,color-mix(in_oklch,var(--card)_98%,var(--primary)_2%)_0%,color-mix(in_oklch,var(--card)_94%,var(--muted)_6%)_48%,color-mix(in_oklch,var(--background)_92%,var(--accent)_8%)_100%)] dark:opacity-65',
props.compact
? '[mask-image:linear-gradient(90deg,black_0%,black_48%,transparent_74%)] opacity-55'
: 'opacity-85'
)}
aria-hidden='true'
/>
<div
className={cn(
'text-foreground/5 dark:text-foreground/8 pointer-events-none absolute inset-y-0 right-0 hidden overflow-hidden font-mono sm:block',
props.compact ? 'w-1/2 opacity-45' : 'w-[58%] opacity-75'
)}
aria-hidden='true'
>
<pre
className={cn(
'absolute right-3 [mask-image:linear-gradient(90deg,transparent_0%,black_30%,black_82%,transparent_100%)] text-right tracking-[0.38em] whitespace-pre',
props.compact
? '-top-6 text-[9px] leading-4'
: 'top-1 text-xs leading-5'
)}
>
{SETUP_GUIDE_CODE_PATTERN}
</pre>
</div>
<div
className='from-background/35 to-background/70 dark:from-background/20 dark:to-background/80 pointer-events-none absolute inset-0 bg-linear-to-b via-transparent'
aria-hidden='true'
/>
</>
)
}
function StartStepItem(props: {
step: StartStep
index: number
isLast: boolean
}) {
const Icon = props.step.icon
const StatusIcon = props.step.completed ? Check : Circle
return (
<li className='relative flex gap-3 pb-2.5 last:pb-0'>
{!props.isLast && (
<span
className='bg-border absolute top-9 bottom-0 left-4 w-px'
aria-hidden='true'
/>
)}
<span
className={cn(
'bg-background relative z-10 flex size-8 shrink-0 items-center justify-center rounded-lg border shadow-xs',
props.step.completed && 'border-success/30 bg-success/10'
)}
>
<StatusIcon
className={props.step.completed ? 'text-success size-4' : 'size-4'}
aria-hidden='true'
/>
</span>
<Link
to={props.step.to}
className='bg-background/70 hover:bg-muted/50 focus-visible:ring-ring flex min-w-0 flex-1 items-center justify-between gap-3 rounded-xl border px-3 py-2.5 text-left shadow-xs transition-colors outline-none focus-visible:ring-2'
>
<span className='flex min-w-0 items-start gap-2.5'>
<span className='bg-muted mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg'>
<Icon className='size-3.5' aria-hidden='true' />
</span>
<span className='flex min-w-0 flex-col gap-0.5'>
<span className='flex items-center gap-2 text-sm font-medium'>
<span className='text-muted-foreground text-xs tabular-nums'>
{props.index + 1}.
</span>
<span className='truncate'>{props.step.title}</span>
</span>
<span className='text-muted-foreground line-clamp-1 text-xs'>
{props.step.description}
</span>
</span>
</span>
<ArrowRight
className='text-muted-foreground size-4 shrink-0'
aria-hidden='true'
/>
</Link>
</li>
)
}
function RequestPreview(props: {
example: RequestExample
signals: HeroSignal[]
}) {
const { t } = useTranslation()
const [isCopying, setIsCopying] = useState(false)
const { copyToClipboard } = useCopyToClipboard({ notify: false })
const previewCurl = buildCurlCommand({
endpoint: props.example.endpoint,
apiKey: props.example.displayKey,
model: props.example.model,
})
const previewLines = previewCurl.split('\n')
const handleCopyRequest = async () => {
if (!props.example.keyId || isCopying) return
setIsCopying(true)
try {
const result = await fetchTokenKey(props.example.keyId)
const key = result.success && result.data?.key ? result.data.key : ''
if (!key) {
toast.error(result.message || t('Failed to copy to clipboard'))
return
}
const realCurl = buildCurlCommand({
endpoint: props.example.endpoint,
apiKey: `sk-${key}`,
model: props.example.model,
})
const copied = await copyToClipboard(realCurl)
if (copied) {
toast.success(t('Copied to clipboard'))
} else {
toast.error(t('Failed to copy to clipboard'))
}
} finally {
setIsCopying(false)
}
}
return (
<div className='bg-background relative overflow-hidden rounded-2xl border p-3'>
<div className='flex items-center justify-between gap-3 border-b pb-3'>
<div className='flex min-w-0 items-center gap-2'>
<span className='bg-muted flex size-8 shrink-0 items-center justify-center rounded-lg'>
<TerminalSquare className='size-4' aria-hidden='true' />
</span>
<div className='min-w-0'>
<div className='truncate text-sm font-medium'>
{t('First API request')}
</div>
<div className='text-muted-foreground truncate text-xs'>
{props.example.ready
? props.example.keyName
: t('Create an API key to unlock the real request')}
</div>
</div>
</div>
{props.example.ready ? (
<Button
variant='outline'
size='sm'
className='h-7 gap-1.5 px-2 text-xs'
disabled={isCopying}
onClick={handleCopyRequest}
aria-label={t('Copy ready-to-run curl')}
>
<Copy data-icon='inline-start' />
{isCopying ? t('Loading') : t('Copy')}
</Button>
) : (
<Button size='sm' variant='outline' render={<Link to='/keys' />}>
{t('Create API Key')}
</Button>
)}
</div>
<div className='bg-foreground/[0.035] my-3 rounded-xl p-3 font-mono text-xs'>
<div className='mb-2 flex items-center gap-1.5'>
<span className='bg-destructive size-2 rounded-full' />
<span className='bg-warning size-2 rounded-full' />
<span className='bg-success size-2 rounded-full' />
</div>
<div className='flex flex-col gap-1 overflow-hidden'>
{previewLines.map((line, index) => (
<code
key={`${line}-${index}`}
className='text-muted-foreground truncate'
title={line}
>
{line}
</code>
))}
</div>
</div>
<div className='grid gap-2'>
{props.signals.map((signal) => {
const Icon = signal.icon
return (
<div
key={signal.label}
className='bg-muted/40 flex items-center justify-between gap-3 rounded-xl px-3 py-2'
>
<span className='flex min-w-0 items-center gap-2'>
<Icon
className='text-muted-foreground size-3.5 shrink-0'
aria-hidden='true'
/>
<span className='truncate text-xs font-medium'>
{signal.label}
</span>
</span>
<span className='text-muted-foreground shrink-0 text-xs'>
{signal.value}
</span>
</div>
)
})}
</div>
</div>
)
}
function QuickActionItem(props: { action: QuickAction }) {
const Icon = props.action.icon
return (
<Button
variant='outline'
className='h-auto justify-start rounded-xl px-3 py-3 text-left'
render={<Link to={props.action.to} />}
>
<span className='bg-muted flex size-9 shrink-0 items-center justify-center rounded-lg'>
<Icon className='size-4' aria-hidden='true' />
</span>
<span className='flex min-w-0 flex-1 flex-col gap-0.5'>
<span className='truncate text-sm font-medium'>
{props.action.title}
</span>
<span className='text-muted-foreground line-clamp-2 text-xs leading-relaxed'>
{props.action.description}
</span>
</span>
</Button>
)
}
function CompactQuickAction(props: { action: QuickAction }) {
const Icon = props.action.icon
return (
<Button
variant='outline'
size='sm'
className='bg-background/70 h-8 min-w-24 gap-1.5 px-2.5'
render={<Link to={props.action.to} />}
>
<Icon data-icon='inline-start' />
<span>{props.action.title}</span>
</Button>
)
}
export function OverviewDashboard() {
const { t } = useTranslation()
const user = useAuthStore((state) => state.auth.user)
const { items: apiInfoItems } = useApiInfo()
const {
apiInfo: showApiInfoPanel,
announcements: showAnnouncementsPanel,
faq: showFAQPanel,
uptimeKuma: showUptimePanel,
} = useDashboardContentVisibility()
const [manualSetupGuideExpanded, setManualSetupGuideExpanded] = useState<
boolean | null
>(() => getSavedSetupGuideExpanded())
const requestCount = Number(user?.request_count ?? 0)
const remainQuota = Number(user?.quota ?? 0)
const usedQuota = Number(user?.used_quota ?? 0)
const isAdmin = Boolean(user?.role && user.role >= ROLE.ADMIN)
const apiKeysQuery = useQuery({
queryKey: ['dashboard', 'overview', 'api-keys'],
queryFn: async () => {
const result = await getApiKeys({ p: 1, size: 10 })
return result.success ? (result.data?.items ?? []) : []
},
staleTime: 60 * 1000,
})
const modelsQuery = useQuery({
queryKey: ['dashboard', 'overview', 'user-models'],
queryFn: async () => {
const result = await getUserModels()
return result.success ? (result.data ?? []) : []
},
staleTime: 5 * 60 * 1000,
})
const preferredKey = useMemo(
() => getPreferredKey(apiKeysQuery.data ?? []),
[apiKeysQuery.data]
)
const startSteps = useMemo<StartStep[]>(
() => [
{
title: t('Create API Key'),
description: t('Create a key for your app or service'),
to: '/keys',
icon: KeyRound,
completed: Boolean(preferredKey),
},
{
title: t('Add credits'),
description: t('Keep enough balance before production traffic'),
to: '/wallet',
icon: CreditCard,
completed: remainQuota > 0 || usedQuota > 0,
},
{
title: t('Send a request'),
description: t('Verify routing with Playground or your client'),
to: '/playground',
icon: TerminalSquare,
completed: requestCount > 0,
},
],
[preferredKey, remainQuota, requestCount, t, usedQuota]
)
const quickActions = useMemo<QuickAction[]>(
() => [
{
title: t('API Keys'),
description: t('Create a key for your app or service'),
to: '/keys',
icon: KeyRound,
},
{
title: t('Channels'),
description: t('Configure upstream providers and routing.'),
to: '/channels',
icon: RadioTower,
adminOnly: true,
},
{
title: t('Usage Logs'),
description: t('Inspect requests, errors, and billing details'),
to: '/usage-logs',
icon: FileText,
},
{
title: t('Pricing'),
description: t('Review model rates before scaling traffic'),
to: '/pricing',
icon: BookOpen,
},
],
[t]
)
const visibleQuickActions = useMemo(
() => quickActions.filter((action) => !action.adminOnly || isAdmin),
[isAdmin, quickActions]
)
const heroSignals = useMemo<HeroSignal[]>(
() => [
{
label: t('Route active'),
value: apiInfoItems.length > 0 ? t('Online') : t('Current domain'),
icon: RadioTower,
},
{
label: t('Auth configured'),
value: preferredKey ? t('Secured') : t('Needs API key'),
icon: ShieldCheck,
},
{
label: t('Model selected'),
value: modelsQuery.data?.[0] ?? t('Loading'),
icon: Timer,
},
],
[apiInfoItems.length, modelsQuery.data, preferredKey, t]
)
const requestExample = useMemo<RequestExample>(() => {
const endpoint = normalizeEndpoint(apiInfoItems[0]?.url)
const model = modelsQuery.data?.[0] ?? 'gpt-4o-mini'
const keyName = preferredKey?.name ?? t('No API key yet')
const ready = Boolean(preferredKey?.id && model)
return {
endpoint,
model,
keyName,
keyId: preferredKey?.id,
displayKey: preferredKey
? formatDisplayKey(`sk-${preferredKey.key}`)
: 'sk-...',
ready,
}
}, [apiInfoItems, modelsQuery.data, preferredKey, t])
const completedStepCount = startSteps.filter((step) => step.completed).length
const setupComplete = completedStepCount === startSteps.length
const setupStatusReady = apiKeysQuery.isFetched && Boolean(user)
const setupGuideExpanded =
manualSetupGuideExpanded ?? (setupStatusReady && !setupComplete)
const showLeftContentPanels =
isAdmin || showApiInfoPanel || showAnnouncementsPanel || showFAQPanel
const showContentPanels = showLeftContentPanels || showUptimePanel
const handleSetupGuideToggle = () => {
const nextExpanded = !setupGuideExpanded
setManualSetupGuideExpanded(nextExpanded)
saveSetupGuideExpanded(nextExpanded)
}
return (
<div className='flex flex-col gap-4'>
{setupGuideExpanded ? (
<CardStaggerContainer className='grid items-stretch gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]'>
<CardStaggerItem className='bg-card h-full overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative h-full overflow-hidden p-4 sm:p-5'>
<SetupGuideBackdrop />
<div className='relative grid gap-5 lg:grid-cols-[minmax(0,1fr)_21rem]'>
<div className='flex min-w-0 flex-col gap-5'>
<div className='flex flex-wrap items-start justify-between gap-3'>
<div className='flex max-w-2xl flex-col gap-1'>
<div className='text-muted-foreground flex items-center gap-2 text-xs font-medium tracking-wider uppercase'>
<ListChecks className='size-3.5' aria-hidden='true' />
{t('Get started')}
</div>
<h3 className='text-xl font-semibold tracking-tight sm:text-2xl'>
{t('Build on your API gateway in minutes')}
</h3>
<p className='text-muted-foreground max-w-xl text-sm leading-relaxed'>
{t(
'A focused home for keys, balance, routing, and service health.'
)}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={handleSetupGuideToggle}
>
<ChevronUp data-icon='inline-start' />
{t('Hide setup guide')}
</Button>
<Button size='sm' render={<Link to='/keys' />}>
<KeyRound data-icon='inline-start' />
{t('Create API Key')}
</Button>
</div>
</div>
<ol className='bg-background/45 rounded-2xl border p-2 backdrop-blur'>
{startSteps.map((step, index) => (
<StartStepItem
key={step.title}
step={step}
index={index}
isLast={index === startSteps.length - 1}
/>
))}
</ol>
</div>
<RequestPreview
example={requestExample}
signals={heroSignals}
/>
</div>
</div>
</CardStaggerItem>
<CardStaggerItem className='bg-card h-full rounded-2xl border p-4 shadow-xs sm:p-5'>
<div className='flex h-full flex-col gap-4'>
<div className='flex flex-col gap-1'>
<div className='text-muted-foreground text-xs font-medium tracking-wider uppercase'>
{t('Recommended actions')}
</div>
<h3 className='text-lg font-semibold tracking-tight'>
{t('Keep the platform ready')}
</h3>
</div>
<div className='grid gap-2'>
{visibleQuickActions.map((action) => (
<QuickActionItem key={action.title} action={action} />
))}
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
) : (
<CardStaggerContainer>
<CardStaggerItem className='bg-card overflow-hidden rounded-2xl border shadow-xs'>
<div className='relative overflow-hidden px-4 py-3 sm:px-5'>
<SetupGuideBackdrop compact />
<div className='relative flex flex-wrap items-center justify-between gap-3'>
<div className='flex min-w-0 items-center gap-3'>
<span className='bg-background/70 flex size-9 shrink-0 items-center justify-center rounded-xl border shadow-xs'>
<Check className='text-success size-4' aria-hidden='true' />
</span>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<h3 className='truncate text-sm font-semibold'>
{setupComplete
? t('Setup guide complete')
: t('Setup guide')}
</h3>
<span className='text-muted-foreground bg-background/60 rounded-md border px-2 py-0.5 text-xs'>
{t('Setup progress: {{completed}}/{{total}}', {
completed: completedStepCount,
total: startSteps.length,
})}
</span>
</div>
<p className='text-muted-foreground line-clamp-1 text-xs'>
{setupComplete
? t(
'Your setup guide is collapsed so usage stays in focus.'
)
: t('Setup guide is collapsed. Expand it anytime.')}
</p>
</div>
</div>
<div className='flex flex-wrap items-center gap-2'>
{visibleQuickActions.map((action) => (
<CompactQuickAction key={action.title} action={action} />
))}
<Button
variant='outline'
size='sm'
className='bg-background/70 h-8 min-w-28'
onClick={handleSetupGuideToggle}
>
<ChevronDown data-icon='inline-start' />
{t('Show setup guide')}
</Button>
</div>
</div>
</div>
</CardStaggerItem>
</CardStaggerContainer>
)}
<SummaryCards />
{showContentPanels && (
@@ -22,8 +22,8 @@ import { ArrowRight, Flame, ShieldCheck, TrendingDown } from 'lucide-react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { StaggerContainer, StaggerItem } from '@/components/page-transition'
import { Button } from '@/components/ui/button'
import { getUserQuotaDates } from '@/features/dashboard/api'
import { useSummaryCardsConfig } from '@/features/dashboard/hooks/use-dashboard-config'
import type { QuotaDataItem } from '@/features/dashboard/types'
@@ -20,7 +20,7 @@ import { Activity, RotateCw } from 'lucide-react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { getUptimeStatus } from '@/features/dashboard/api'
import type {
@@ -110,10 +110,9 @@ export function UptimePanel() {
headerActions={
<Button
variant='ghost'
size='sm'
size='icon-sm'
onClick={handleRefresh}
disabled={refreshing}
className='size-7 p-0'
>
<RotateCw
className={cn('size-3.5', refreshing && 'animate-spin')}
@@ -22,8 +22,8 @@ import { Users, Loader2 } from 'lucide-react'
import { useEffect, useMemo, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useTheme } from '@/context/theme-provider'
import { getUserQuotaDataByUsers } from '@/features/dashboard/api'
import {
+4 -4
View File
@@ -21,11 +21,11 @@ import { Eye, EyeOff } from 'lucide-react'
import { useState, useCallback, useMemo, lazy, Suspense } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import { SectionPageLayout } from '@/components/layout'
import { FadeIn } from '@/components/page-transition'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Tooltip,
TooltipContent,
@@ -265,7 +265,7 @@ export function Dashboard() {
? t('Hide sensitive data')
: t('Show sensitive data')
}
className='text-muted-foreground hover:text-foreground size-8'
className='text-muted-foreground hover:text-foreground'
/>
}
>
@@ -298,7 +298,7 @@ export function Dashboard() {
<div className='flex flex-wrap items-center justify-between gap-1.5 sm:gap-2'>
{showSectionTabs ? (
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'>
{visibleSections.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
@@ -1,56 +1,13 @@
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { FlowUserFilterOption } from '../types'
import {
compactFlowSelectionLabel,
flowDisplayState,
requireSuccessfulFlowRows,
visibleFlowUsers,
} from './flow-selection'
const users: FlowUserFilterOption[] = [
{
value: 'user:1',
label: 'dry',
valueLabel: '100',
valueRaw: 100,
color: '#1664ff',
},
{
value: 'user:2',
label: 'jrc',
valueLabel: '70',
valueRaw: 70,
color: '#1ac6ff',
},
]
describe('dashboard flow selection helpers', () => {
test('limits user chips to currently visible users', () => {
assert.deepEqual(
visibleFlowUsers(users, []).map((user) => user.value),
['user:1', 'user:2']
)
assert.deepEqual(
visibleFlowUsers(users, ['user:2']).map((user) => user.value),
['user:2']
)
})
test('filters visible users without mutating the source options', () => {
const visible = visibleFlowUsers(users, ['user:1'])
assert.deepEqual(
visible.map((user) => user.value),
['user:1']
)
assert.deepEqual(
users.map((user) => user.value),
['user:1', 'user:2']
)
})
test('formats compact selected counts for flow multiselect summaries', () => {
assert.equal(compactFlowSelectionLabel(0), '*')
assert.equal(compactFlowSelectionLabel(1), '1')
+1 -13
View File
@@ -16,10 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type {
FlowQuotaDataItem,
FlowUserFilterOption,
} from '@/features/dashboard/types'
import type { FlowQuotaDataItem } from '@/features/dashboard/types'
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
@@ -55,12 +52,3 @@ export function flowDisplayState(options: {
export function compactFlowSelectionLabel(count: number): string {
return count > 0 ? String(count) : '*'
}
export function visibleFlowUsers(
users: FlowUserFilterOption[],
selectedUsers: string[]
): FlowUserFilterOption[] {
if (selectedUsers.length === 0) return users
const selected = new Set(selectedUsers)
return users.filter((user) => selected.has(user.value))
}
+4 -6
View File
@@ -2,11 +2,7 @@ import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import type { FlowQuotaDataItem } from '../types'
import {
buildDashboardFlowData,
buildFlowFilterOptions,
buildFlowSankeySpec,
} from './flow'
import { buildDashboardFlowData, buildFlowSankeySpec } from './flow'
const rows: FlowQuotaDataItem[] = [
{
@@ -276,7 +272,9 @@ describe('dashboard flow data', () => {
})
test('builds user filter options with stable values', () => {
const options = buildFlowFilterOptions(rows, 'quota')
const options = buildDashboardFlowData(rows, 'quota', {
role: 'admin',
}).filterOptions
assert.deepEqual(
options.users.map((user) => [user.value, user.label, user.valueLabel]),
-22
View File
@@ -947,28 +947,6 @@ function buildNodeFilterOptions(
)
}
export function buildFlowFilterOptions(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
palette?: readonly string[],
role: FlowRole = DEFAULT_FLOW_ROLE,
visibleStages?: FlowNodeKind[],
selectedNodes?: readonly FlowNodeFilter[]
): FlowFilterOptions {
return {
users: buildUserFilterOptions(rows, metric, palette),
nodes: buildNodeFilterOptions(
rows,
metric,
role,
visibleStages,
palette,
EMPTY_FLOW_PATH_CONTEXT,
selectedNodes
),
}
}
export function buildDashboardFlowData(
rows: FlowQuotaDataItem[],
metric: FlowMetric = 'quota',
+1 -1
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useNavigate, useRouter } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
export function ForbiddenError() {
const { t } = useTranslation()
+1 -1
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useNavigate, useRouter } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { cn } from '@/lib/utils'
const FEEDBACK_URL = 'https://github.com/QuantumNous/new-api/issues'
+1 -1
View File
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
export function MaintenanceError() {
const { t } = useTranslation()
+1 -1
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useNavigate, useRouter } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
export function NotFoundError() {
const { t } = useTranslation()
+1 -1
View File
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useNavigate, useRouter } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
export function UnauthorisedError() {
const { t } = useTranslation()
+1 -1
View File
@@ -20,7 +20,7 @@ import { Link } from '@tanstack/react-router'
import { ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
interface HeroButtonsProps {
isAuthenticated: boolean
+1 -1
View File
@@ -21,7 +21,7 @@ import { ArrowRight } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { AnimateInView } from '@/components/animate-in-view'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
interface CTAProps {
className?: string
+11 -6
View File
@@ -21,7 +21,7 @@ import { Link } from '@tanstack/react-router'
import { ArrowRight, BookOpen } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { useStatus } from '@/hooks/use-status'
import { HeroTerminalDemo } from '../hero-terminal-demo'
@@ -57,7 +57,8 @@ export function Hero(props: HeroProps) {
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
size='xl'
className='group border-border/50 hover:border-border hover:bg-muted/50 gap-1.5 rounded-lg text-sm'
render={
<a href={docsUrl} target='_blank' rel='noopener noreferrer' />
}
@@ -70,7 +71,8 @@ export function Hero(props: HeroProps) {
return (
<Button
variant='outline'
className='group border-border/50 hover:border-border hover:bg-muted/50 inline-flex h-11 items-center gap-1.5 rounded-lg px-5 text-sm font-medium'
size='xl'
className='group border-border/50 hover:border-border hover:bg-muted/50 gap-1.5 rounded-lg text-sm'
render={<Link to={docsUrl} />}
>
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
@@ -140,7 +142,8 @@ export function Hero(props: HeroProps) {
{props.isAuthenticated ? (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
size='xl'
className='group rounded-lg text-sm'
render={<Link to='/dashboard' />}
>
{t('Go to Dashboard')}
@@ -151,7 +154,8 @@ export function Hero(props: HeroProps) {
) : (
<>
<Button
className='group h-11 rounded-lg px-5 text-sm font-medium'
size='xl'
className='group rounded-lg text-sm'
render={<Link to='/sign-up' />}
>
{t('Get Started')}
@@ -159,7 +163,8 @@ export function Hero(props: HeroProps) {
</Button>
<Button
variant='outline'
className='border-border/50 hover:border-border hover:bg-muted/50 h-11 rounded-lg px-5 text-sm font-medium'
size='xl'
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg text-sm'
render={<Link to='/pricing' />}
>
{t('View Pricing')}
+1 -1
View File
@@ -35,7 +35,7 @@ import type {
export async function getApiKeys(
params: GetApiKeysParams = {}
): Promise<GetApiKeysResponse> {
const { p = 1, size = 10 } = params
const { p = 1, size = 20 } = params
const res = await api.get(`/api/token/?p=${p}&size=${size}`)
return res.data
}
@@ -0,0 +1,189 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { flexRender, type Row } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
import {
DataTableCardDetails,
DataTableCardField,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import { formatQuota } from '@/lib/format'
import type { ApiKey } from '../types'
function renderApiKeyCell(row: Row<ApiKey>, columnId: string) {
const cell = row
.getVisibleCells()
.find((candidate) => candidate.column.id === columnId)
if (!cell) return null
return flexRender(cell.column.columnDef.cell, cell.getContext())
}
function ApiKeyModels(props: { apiKey: ApiKey }) {
const { t } = useTranslation()
if (!props.apiKey.model_limits_enabled || !props.apiKey.model_limits) {
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const models = props.apiKey.model_limits
.split(',')
.map((model) => model.trim())
.filter(Boolean)
return (
<span className='font-mono text-xs whitespace-pre-wrap'>
{models.join(', ')}
</span>
)
}
function ApiKeyIpRestrictions(props: { apiKey: ApiKey }) {
const { t } = useTranslation()
const allowIps = props.apiKey.allow_ips?.trim()
if (!allowIps) {
return <StatusBadge variant='neutral'>{t('No restriction')}</StatusBadge>
}
const ips = allowIps
.split('\n')
.map((ip) => ip.trim())
.filter(Boolean)
return (
<span className='font-mono text-xs whitespace-pre-wrap'>
{ips.join('\n')}
</span>
)
}
export function ApiKeyCard(props: { row: Row<ApiKey> }) {
const { t } = useTranslation()
const apiKey = props.row.original
const totalQuota = apiKey.used_quota + apiKey.remain_quota
const visibleColumnIds = new Set(
props.row.getVisibleCells().map((cell) => cell.column.id)
)
const detailsCount = [
'status',
'group',
'model_limits',
'allow_ips',
'created_time',
'accessed_time',
'expired_time',
'actions',
].filter((columnId) => visibleColumnIds.has(columnId)).length
return (
<>
<div className='grid grid-cols-2 gap-x-3 gap-y-2'>
{visibleColumnIds.has('name') && (
<DataTableCardField
label={t('Name')}
span={2}
contentMode='wrap'
valueClassName='font-medium'
>
{renderApiKeyCell(props.row, 'name')}
</DataTableCardField>
)}
{visibleColumnIds.has('key') && (
<DataTableCardField label={t('API Key')} span={2} contentMode='full'>
{renderApiKeyCell(props.row, 'key')}
</DataTableCardField>
)}
{visibleColumnIds.has('quota') && (
<DataTableCardField label={t('Quota')} span={2} contentMode='full'>
{apiKey.unlimited_quota ? (
<StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(totalQuota)}
</span>
</span>
)}
</DataTableCardField>
)}
</div>
{detailsCount > 0 && (
<DataTableCardDetails count={detailsCount}>
{visibleColumnIds.has('status') && (
<DataTableCardField label={t('Status')} contentMode='full'>
{renderApiKeyCell(props.row, 'status')}
</DataTableCardField>
)}
{visibleColumnIds.has('group') && (
<DataTableCardField label={t('Group')} contentMode='full'>
{renderApiKeyCell(props.row, 'group')}
</DataTableCardField>
)}
{visibleColumnIds.has('model_limits') && (
<DataTableCardField label={t('Models')} span={2} contentMode='full'>
<ApiKeyModels apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('allow_ips') && (
<DataTableCardField
label={t('IP Restriction')}
span={2}
contentMode='full'
>
<ApiKeyIpRestrictions apiKey={apiKey} />
</DataTableCardField>
)}
{visibleColumnIds.has('created_time') && (
<DataTableCardField label={t('Created')} contentMode='full'>
{renderApiKeyCell(props.row, 'created_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('accessed_time') && (
<DataTableCardField label={t('Last Used')} contentMode='full'>
{renderApiKeyCell(props.row, 'accessed_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('expired_time') && (
<DataTableCardField
label={t('Expires')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'expired_time')}
</DataTableCardField>
)}
{visibleColumnIds.has('actions') && (
<DataTableCardField
label={t('Operations')}
span={2}
contentMode='full'
>
{renderApiKeyCell(props.row, 'actions')}
</DataTableCardField>
)}
</DataTableCardDetails>
)}
</>
)
}
@@ -20,8 +20,7 @@ import { Check, ChevronsUpDown } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Command,
CommandEmpty,
@@ -29,7 +28,8 @@ import {
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
} from '@/components/design-system/command'
import { Badge } from '@/components/ui/badge'
import {
Popover,
PopoverContent,
@@ -139,7 +139,7 @@ export function ApiKeyGroupCombobox({
role='combobox'
aria-expanded={open}
disabled={disabled}
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
className='border-input bg-muted/40 hover:bg-muted/55 hover:text-foreground active:bg-background data-popup-open:border-ring data-popup-open:bg-background data-popup-open:ring-ring/20 h-auto min-h-14 w-full justify-between gap-2 rounded-lg px-3 py-2 text-start shadow-none transition-[background-color,border-color,box-shadow] duration-150 data-popup-open:ring-[3px] sm:h-auto sm:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
/>
}
>
+12 -24
View File
@@ -22,8 +22,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { BadgeCell } from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
@@ -99,7 +99,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
<Button
variant='ghost'
size='sm'
className='text-muted-foreground h-7 max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
className='text-muted-foreground max-w-full min-w-0 justify-start truncate px-0 font-mono text-xs hover:bg-transparent aria-expanded:bg-transparent'
/>
}
>
@@ -135,8 +135,8 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
render={
<Button
variant='ghost'
size='icon'
className='size-7 shrink-0'
size='icon-sm'
className='shrink-0'
onClick={handleCopy}
onFocus={() => {
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
@@ -160,9 +160,7 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
const { t } = useTranslation()
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
return (
<StatusBadge label={t('Unlimited')} variant='neutral' copyable={false} />
)
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const models = apiKey.model_limits.split(',').filter(Boolean)
@@ -170,11 +168,9 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} model(s)', { count: models.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>
{t('{{count}} model(s)', { count: models.length })}
</StatusBadge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
@@ -194,13 +190,7 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
const allowIps = apiKey.allow_ips?.trim()
if (!allowIps) {
return (
<StatusBadge
label={t('No restriction')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('No restriction')}</StatusBadge>
}
const ips = allowIps
@@ -211,11 +201,9 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
return (
<Tooltip>
<TooltipTrigger render={<BadgeCell />}>
<StatusBadge
label={t('{{count}} IP(s)', { count: ips.length })}
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>
{t('{{count}} IP(s)', { count: ips.length })}
</StatusBadge>
</TooltipTrigger>
<TooltipContent side='top' className='max-w-xs'>
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
+72 -56
View File
@@ -16,7 +16,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import type { ColumnDef } from '@tanstack/react-table'
import { useTranslation } from 'react-i18next'
@@ -30,7 +29,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { getUserGroups } from '@/lib/api'
import { useGroupRatios } from '@/hooks/use-group-ratios'
import { formatQuota, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
@@ -44,32 +43,13 @@ import {
import { DataTableRowActions } from './data-table-row-actions'
function getQuotaProgressColor(percentage: number): string {
if (percentage <= 10)
if (percentage <= 10) {
return '[&_[data-slot=progress-indicator]]:bg-destructive'
}
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-warning'
return '[&_[data-slot=progress-indicator]]:bg-success'
}
function useGroupRatios(): Record<string, number> {
const { data } = useQuery({
queryKey: ['user-groups'],
queryFn: getUserGroups,
staleTime: 0,
select: (res) => {
if (!res.success || !res.data) return {}
const ratios: Record<string, number> = {}
for (const [group, info] of Object.entries(res.data)) {
if (typeof info.ratio === 'number') {
ratios[group] = info.ratio
}
}
return ratios
},
})
return data ?? {}
}
export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const { t } = useTranslation()
const groupRatios = useGroupRatios()
@@ -96,6 +76,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
enableSorting: false,
enableHiding: false,
size: 40,
meta: { cardRole: 'hidden' },
},
{
accessorKey: 'name',
@@ -104,7 +85,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
<span className='font-medium'>{row.getValue('name')}</span>
),
size: 180,
meta: { mobileTitle: true },
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
},
},
{
accessorKey: 'status',
@@ -113,16 +98,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
if (!statusConfig) return null
return (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
<StatusBadge variant={statusConfig.variant}>
{t(statusConfig.label)}
</StatusBadge>
)
},
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
size: 120,
meta: { mobileBadge: true },
meta: {
cardRole: 'secondary',
cardOrder: 10,
contentMode: 'full',
},
},
{
id: 'key',
@@ -131,6 +118,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <ApiKeyCell apiKey={row.original} />,
enableSorting: false,
size: 260,
meta: {
cardRole: 'primary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'quota',
@@ -139,13 +132,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const apiKey = row.original
if (apiKey.unlimited_quota) {
return (
<StatusBadge
label={t('Unlimited')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
}
const used = apiKey.used_quota
@@ -187,6 +174,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 170,
meta: {
cardRole: 'primary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'full',
},
},
{
accessorKey: 'group',
@@ -204,11 +197,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
>
<GroupBadge group='auto' />
{apiKey.cross_group_retry && (
<StatusBadge
label={t('Cross-group')}
variant='info'
copyable={false}
/>
<StatusBadge variant='info'>{t('Cross-group')}</StatusBadge>
)}
</TooltipTrigger>
<TooltipContent>
@@ -231,7 +220,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'model_limits',
@@ -240,7 +234,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 30,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'allow_ips',
@@ -249,7 +248,12 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
enableSorting: false,
size: 160,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 40,
cardSpan: 2,
contentMode: 'full',
},
},
{
accessorKey: 'created_time',
@@ -260,7 +264,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
</span>
),
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 50,
contentMode: 'full',
},
},
{
accessorKey: 'accessed_time',
@@ -277,7 +285,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 60,
contentMode: 'full',
},
},
{
accessorKey: 'expired_time',
@@ -285,13 +297,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
cell: ({ row }) => {
const expiredTime = row.getValue('expired_time') as number
if (expiredTime === -1) {
return (
<StatusBadge
label={t('Never')}
variant='neutral'
copyable={false}
/>
)
return <StatusBadge variant='neutral'>{t('Never')}</StatusBadge>
}
const isExpired = expiredTime * 1000 < Date.now()
return (
@@ -306,13 +312,23 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
)
},
size: 180,
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 70,
contentMode: 'full',
},
},
{
id: 'actions',
header: () => t('Actions'),
cell: ({ row }) => <DataTableRowActions row={row} />,
meta: { pinned: 'right' as const },
meta: {
pinned: 'right' as const,
cardRole: 'secondary',
cardOrder: 80,
cardSpan: 2,
contentMode: 'full',
},
},
]
}
@@ -29,7 +29,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
} from '@/components/design-system/alert-dialog'
import { deleteApiKey } from '../api'
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
@@ -25,6 +25,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DateTimePicker } from '@/components/datetime-picker'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
SideDrawerSection,
SideDrawerSectionHeader,
@@ -35,7 +37,6 @@ import {
sideDrawerSwitchItemClassName,
} from '@/components/drawer-layout'
import { MultiSelect } from '@/components/multi-select'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
@@ -50,7 +51,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Sheet,
SheetClose,
@@ -364,8 +364,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 0)}
>
{t('Never')}
@@ -373,8 +371,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(1, 0, 0)}
>
{t('1 Month')}
@@ -382,8 +378,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 1, 0)}
>
{t('1 Day')}
@@ -391,8 +385,6 @@ export function ApiKeysMutateDrawer({
<Button
type='button'
variant='outline'
size='sm'
className='px-2 text-xs sm:px-3 sm:text-sm'
onClick={() => handleSetExpiry(0, 0, 1)}
>
{t('1 Hour')}
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { useApiKeys } from './api-keys-provider'
@@ -28,7 +28,7 @@ export function ApiKeysPrimaryButtons() {
const { setOpen } = useApiKeys()
return (
<div className='flex gap-2'>
<Button size='sm' onClick={() => setOpen('create')}>
<Button onClick={() => setOpen('create')}>
<Plus className='h-4 w-4' />
{t('Create API Key')}
</Button>
+26 -136
View File
@@ -18,8 +18,6 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { getRouteApi } from '@tanstack/react-router'
import { type Table as TanstackTable } from '@tanstack/react-table'
import { Database } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -27,36 +25,24 @@ import {
DISABLED_ROW_DESKTOP,
DISABLED_ROW_MOBILE,
DataTablePage,
MobileCardList,
useDebouncedColumnFilter,
useDataTable,
} from '@/components/data-table'
import { StatusBadge } from '@/components/status-badge'
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/design-system/input'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { formatQuota } from '@/lib/format'
import { cn } from '@/lib/utils'
import { getApiKeys, searchApiKeys } from '../api'
import {
API_KEY_STATUS,
API_KEY_STATUS_OPTIONS,
API_KEY_STATUSES,
ERROR_MESSAGES,
} from '../constants'
import { type ApiKey } from '../types'
import { ApiKeyCell } from './api-keys-cells'
import type { ApiKey } from '../types'
import { ApiKeyCard } from './api-key-card'
import { useApiKeysColumns } from './api-keys-columns'
import { useApiKeys } from './api-keys-provider'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { DataTableRowActions } from './data-table-row-actions'
const route = getRouteApi('/_authenticated/keys/')
const API_KEYS_COLUMN_VISIBILITY_STORAGE_KEY = 'api-keys:column-visibility'
@@ -65,122 +51,6 @@ function isDisabledApiKeyRow(apiKey: ApiKey) {
return apiKey.status !== API_KEY_STATUS.ENABLED
}
function ApiKeysMobileSkeleton() {
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{Array.from({ length: 5 }).map((_, index) => (
<div
key={index}
className='space-y-2 border-b px-3 py-2.5 last:border-b-0'
>
<div className='flex items-center justify-between'>
<Skeleton className='h-4 w-32' />
<Skeleton className='h-5 w-16 rounded-md' />
</div>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-7 w-44' />
<Skeleton className='h-8 w-16' />
</div>
<Skeleton className='h-3 w-28' />
</div>
))}
</div>
)
}
function ApiKeysMobileList({
table,
isLoading,
}: {
table: TanstackTable<ApiKey>
isLoading: boolean
}) {
const { t } = useTranslation()
const rows = table.getRowModel().rows
if (isLoading) return <ApiKeysMobileSkeleton />
if (!rows.length) {
return (
<div className='rounded-lg border p-8'>
<Empty className='border-none p-0'>
<EmptyHeader>
<EmptyMedia variant='icon'>
<Database className='size-6' />
</EmptyMedia>
<EmptyTitle>{t('No API Keys Found')}</EmptyTitle>
<EmptyDescription>
{t(
'No API keys available. Create your first API key to get started.'
)}
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)
}
return (
<div className='divide-border overflow-hidden rounded-lg border'>
{rows.map((row) => {
const apiKey = row.original
const statusConfig = API_KEY_STATUSES[apiKey.status]
const total = apiKey.used_quota + apiKey.remain_quota
return (
<div
key={row.id}
className={cn(
'bg-card space-y-2.5 border-b px-3 py-2.5 last:border-b-0',
isDisabledApiKeyRow(apiKey) && DISABLED_ROW_MOBILE
)}
>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0'>
<div className='truncate text-sm font-semibold'>
{apiKey.name}
</div>
<div className='text-muted-foreground text-xs'>
{t('API Key')}
</div>
</div>
{statusConfig && (
<StatusBadge
label={t(statusConfig.label)}
variant={statusConfig.variant}
copyable={false}
/>
)}
</div>
<div className='flex min-w-0 items-center justify-between gap-2'>
<div className='min-w-0 flex-1 [&_button:first-child]:max-w-full [&_button:first-child]:truncate [&_button:first-child]:px-0'>
<ApiKeyCell apiKey={apiKey} />
</div>
<DataTableRowActions row={row} />
</div>
<div className='flex items-center justify-between gap-2 text-xs'>
<span className='text-muted-foreground'>{t('Quota')}</span>
{apiKey.unlimited_quota ? (
<span className='font-medium'>{t('Unlimited')}</span>
) : (
<span className='font-medium tabular-nums'>
{formatQuota(apiKey.remain_quota)}
<span className='text-muted-foreground font-normal'>
{' / '}
{formatQuota(total)}
</span>
</span>
)}
</div>
</div>
)
})}
</div>
)
}
export function ApiKeysTable() {
const { t } = useTranslation()
const { refreshTrigger } = useApiKeys()
@@ -197,7 +67,11 @@ export function ApiKeysTable() {
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 20 },
pagination: {
defaultPage: 1,
defaultPageSize: 20,
pageSizeStorageKey: 'api-keys:page-size:v1',
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
@@ -283,6 +157,7 @@ export function ApiKeysTable() {
<DataTablePage
table={table}
columns={columns}
tableLabel={t('API Keys')}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No API Keys Found')}
@@ -293,6 +168,8 @@ export function ApiKeysTable() {
applyHeaderSize
toolbarProps={{
searchPlaceholder: t('Filter by name...'),
hasAdditionalFilters: Boolean(tokenFilterInput.trim()),
onReset: () => setTokenFilterInput(''),
additionalSearch: (
<Input
placeholder={t('Filter by API key...')}
@@ -311,7 +188,20 @@ export function ApiKeysTable() {
},
],
}}
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
mobile={
<MobileCardList
table={table}
isLoading={isLoading}
emptyTitle={t('No API Keys Found')}
emptyDescription={t(
'No API keys available. Create your first API key to get started.'
)}
renderCard={(row) => <ApiKeyCard row={row} />}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_MOBILE : undefined
}
/>
}
getRowClassName={(row) =>
isDisabledApiKeyRow(row.original) ? DISABLED_ROW_DESKTOP : undefined
}
@@ -23,7 +23,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Tooltip,
TooltipContent,
@@ -89,7 +89,6 @@ export function DataTableBulkActions<TData>({
<Button
variant='outline'
size='icon'
className='size-8'
onClick={handleBatchCopy}
disabled={isCopying}
aria-label={t('Copy selected keys')}
@@ -114,7 +113,6 @@ export function DataTableBulkActions<TData>({
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label={t('Delete selected API keys')}
/>
}
@@ -33,7 +33,7 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -21,9 +21,9 @@ import { useState, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { ComboboxInput } from '@/components/design-system/combobox-input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { ComboboxInput } from '@/components/ui/combobox-input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { getUserModels } from '@/lib/api'
+2 -2
View File
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type StatusBadgeProps } from '@/components/status-badge'
import type { StatusBadgeProps } from '@/components/status-badge'
// ============================================================================
// API Key Status Configuration
@@ -54,7 +54,7 @@ export const API_KEY_STATUSES: Record<
},
[API_KEY_STATUS.EXHAUSTED]: {
label: 'Exhausted',
variant: 'danger',
variant: 'destructive',
value: API_KEY_STATUS.EXHAUSTED,
},
} as const
+1 -1
View File
@@ -20,9 +20,9 @@ import { useQuery } from '@tanstack/react-query'
import { FileWarning } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { PublicLayout } from '@/components/layout'
import { RichContent } from '@/components/rich-content'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { isHttpUrl, isLikelyHtml } from '@/lib/content-format'
@@ -24,8 +24,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
@@ -103,7 +103,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={handleEnableAll}
className='size-8'
aria-label={t('Enable selected models')}
title={t('Enable selected models')}
/>
@@ -124,7 +123,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={handleDisableAll}
className='size-8'
aria-label={t('Disable selected models')}
title={t('Disable selected models')}
/>
@@ -145,7 +143,6 @@ export function DataTableBulkActions<TData>({
variant='outline'
size='icon'
onClick={handleCopyNames}
className='size-8'
aria-label={t('Copy model names')}
title={t('Copy model names')}
/>
@@ -166,7 +163,6 @@ export function DataTableBulkActions<TData>({
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label={t('Delete selected models')}
title={t('Delete selected models')}
/>
@@ -24,7 +24,7 @@ import { useTranslation } from 'react-i18next'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenuItem,
DropdownMenuShortcut,
@@ -29,8 +29,8 @@ import {
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type LoadingPhase = 'idle' | 'settings' | 'connection' | 'done'
@@ -21,9 +21,9 @@ import { Eye, Info, Pencil, Settings2, Timer, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { DataTableRowActionMenu } from '@/components/data-table/core/row-action-menu'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/design-system/button'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Button } from '@/components/ui/button'
import {
DropdownMenuItem,
DropdownMenuSeparator,
@@ -53,7 +53,12 @@ export function useDeploymentsColumns(opts: {
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'full',
},
cell: ({ row }) => {
const id = row.original.id
return <TableId value={id} />
@@ -65,17 +70,22 @@ export function useDeploymentsColumns(opts: {
accessorFn: (row) =>
row.container_name || row.deployment_name || row.name || '',
header: t('Name'),
meta: { mobileTitle: true },
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ getValue }) => {
const name = String(getValue() || '-') || '-'
return (
<StatusBadge
label={name}
<CopyableStatusBadge
value={name}
variant='neutral'
copyText={name}
size='sm'
className='font-mono'
/>
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{name}
</CopyableStatusBadge>
)
},
minSize: 220,
@@ -83,7 +93,7 @@ export function useDeploymentsColumns(opts: {
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
meta: { cardRole: 'badge', contentMode: 'wrap' },
cell: ({ row }) => {
const raw = row.original.status
const key = normalizeDeploymentStatus(raw)
@@ -93,12 +103,9 @@ export function useDeploymentsColumns(opts: {
variant: 'neutral' as const,
}
return (
<StatusBadge
label={config.label}
variant={config.variant}
size='sm'
copyable={false}
/>
<StatusBadge variant={config.variant} size='sm'>
{config.label}
</StatusBadge>
)
},
filterFn: (row, id, value) => {
@@ -125,15 +132,22 @@ export function useDeploymentsColumns(opts: {
}
return (
<StatusBadge
label={String(provider)}
autoColor={String(provider)}
variant='neutral'
size='sm'
copyable={false}
/>
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{String(provider)}
</StatusBadge>
)
},
size: 140,
enableSorting: false,
meta: {
cardRole: 'primary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'wrap',
},
},
{
accessorKey: 'time_remaining',
@@ -166,12 +180,9 @@ export function useDeploymentsColumns(opts: {
<div className='flex flex-wrap items-center gap-2'>
<span className='font-medium'>{remainingText}</span>
{status === 'running' && percentRemain !== null ? (
<StatusBadge
label={`${percentRemain}%`}
variant='info'
size='sm'
copyable={false}
/>
<StatusBadge variant='info' size='sm'>
{`${percentRemain}%`}
</StatusBadge>
) : null}
</div>
{remainingHuman ? (
@@ -184,11 +195,22 @@ export function useDeploymentsColumns(opts: {
},
minSize: 220,
enableSorting: false,
meta: {
cardRole: 'primary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'full',
},
},
{
id: 'hardware',
header: t('Hardware'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'wrap',
},
accessorFn: (row) =>
row.hardware_info || row.hardware_name || row.brand_name || '',
cell: ({ row }) => {
@@ -205,13 +227,15 @@ export function useDeploymentsColumns(opts: {
return <span className='text-muted-foreground text-xs'>-</span>
}
return (
<div className='flex max-w-full min-w-0 flex-nowrap items-center gap-2 overflow-hidden'>
<StatusBadge
label={String(hardware)}
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-2'>
<CopyableStatusBadge
value={String(hardware)}
variant='neutral'
copyText={String(hardware)}
size='sm'
/>
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{String(hardware)}
</CopyableStatusBadge>
{qty !== null ? (
<span className='text-muted-foreground text-xs'>×{qty}</span>
) : null}
@@ -224,7 +248,11 @@ export function useDeploymentsColumns(opts: {
{
accessorKey: 'created_at',
header: t('Created'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 30,
contentMode: 'full',
},
cell: ({ row }) => {
let ts: number | undefined
if (typeof row.original.created_at === 'number') {
@@ -32,8 +32,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useMediaQuery } from '@/hooks'
} from '@/components/design-system/alert-dialog'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
@@ -52,7 +51,6 @@ const route = getRouteApi('/_authenticated/models/$section')
export function DeploymentsTable() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const isMobile = useMediaQuery('(max-width: 640px)')
// URL state (use dedicated keys so it won't collide with metadata table)
const {
@@ -70,7 +68,8 @@ export function DeploymentsTable() {
pageKey: 'dPage',
pageSizeKey: 'dPageSize',
defaultPage: 1,
defaultPageSize: isMobile ? 8 : 10,
defaultPageSize: 20,
pageSizeStorageKey: 'model-deployments:page-size:v1',
},
globalFilter: { enabled: true, key: 'dFilter' },
columnFilters: [
@@ -220,6 +219,7 @@ export function DeploymentsTable() {
<DataTablePage
table={table}
columns={columns}
tableLabel={t('Deployments')}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Deployments Found')}
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import { useModels } from './models-provider'
@@ -41,11 +41,11 @@ export function DescriptionCell({
}
return (
<div className='max-w-[150px]'>
<div className='max-w-full sm:max-w-[150px]'>
<Button
variant='link'
onClick={handleClick}
className='text-muted-foreground hover:text-foreground block h-auto w-full cursor-pointer overflow-hidden p-0 text-left text-sm text-ellipsis whitespace-nowrap no-underline'
className='text-muted-foreground hover:text-foreground block h-auto w-full cursor-pointer p-0 text-left text-sm [overflow-wrap:anywhere] whitespace-normal no-underline sm:h-auto sm:overflow-hidden sm:text-ellipsis sm:whitespace-nowrap'
>
{description}
</Button>
@@ -24,6 +24,16 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/design-system/select'
import {
SideDrawerSection,
sideDrawerContentClassName,
@@ -32,7 +42,6 @@ import {
sideDrawerHeaderClassName,
} from '@/components/drawer-layout'
import { MultiSelect } from '@/components/multi-select'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -41,15 +50,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@@ -22,9 +22,9 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { estimatePrice, extendDeployment, getDeployment } from '../../api'
@@ -21,9 +21,10 @@ import { ChevronLeft, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { CopyableStatusBadge } from '@/components/status-badge'
import {
Empty,
EmptyDescription,
@@ -31,7 +32,6 @@ import {
EmptyMedia,
EmptyTitle,
} from '@/components/ui/empty'
import { Input } from '@/components/ui/input'
import { useIsMobile } from '@/hooks/use-mobile'
import { getMissingModels } from '../../api'
@@ -124,18 +124,20 @@ export function MissingModelsDialog({
bodyClassName='space-y-4'
initialFocus={!isMobile}
>
{isLoading ? (
{isLoading && (
<div className='flex items-center justify-center py-12'>
<Loader2 className='h-8 w-8 animate-spin' />
</div>
) : missingModels.length === 0 ? (
)}
{!isLoading && missingModels.length === 0 && (
<div className='text-muted-foreground py-12 text-center'>
<p>{t('No missing models found.')}</p>
<p className='text-sm'>
{t('All models in use are properly configured.')}
</p>
</div>
) : (
)}
{!isLoading && missingModels.length > 0 && (
<div className='flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto'>
<div className='flex flex-shrink-0 items-center justify-between gap-3'>
<div className='text-muted-foreground text-sm whitespace-nowrap'>
@@ -177,14 +179,11 @@ export function MissingModelsDialog({
className='flex items-center justify-between gap-3 p-3'
>
<div className='min-w-0 flex-1'>
<StatusBadge
label={modelName}
variant='neutral'
copyText={modelName}
/>
<CopyableStatusBadge value={modelName} variant='neutral'>
{modelName}
</CopyableStatusBadge>
</div>
<Button
size='sm'
className='flex-shrink-0 gap-1'
onClick={() => handleConfigureModel(modelName)}
>
@@ -207,7 +206,6 @@ export function MissingModelsDialog({
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.max(1, prev - 1))
}
@@ -219,7 +217,6 @@ export function MissingModelsDialog({
<Button
variant='outline'
size='icon'
className='h-8 w-8'
onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
@@ -32,11 +32,12 @@ import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { StaticDataTable } from '@/components/data-table/static/static-data-table'
import { StaticRowActions } from '@/components/data-table/static/static-row-actions'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
@@ -196,7 +197,7 @@ export function PrefillGroupManagementDialog({
<div className='space-y-2'>
<CardTitle className='flex flex-wrap items-center gap-2'>
{group.name}
<StatusBadge variant={meta.badge} size='sm' copyable={false}>
<StatusBadge variant={meta.badge} size='sm'>
{meta.label}
<span className='text-muted-foreground/30'>·</span>
<span className='text-muted-foreground font-mono'>
@@ -238,30 +239,21 @@ export function PrefillGroupManagementDialog({
<CardContent className='space-y-3'>
<div className='text-muted-foreground flex flex-wrap items-center gap-2 text-xs font-medium tracking-wide uppercase'>
<span>Items</span>
<StatusBadge
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
variant='neutral'
size='sm'
copyable={false}
/>
<Badge variant='outline'>
{`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
</Badge>
</div>
{parsedItems.length > 0 ? (
<div className='flex flex-wrap gap-2'>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
<StatusBadge key={item} variant='neutral' size='sm'>
{item}
</StatusBadge>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{`+${parsedItems.length - 6} more`}
</StatusBadge>
)}
</div>
) : (
@@ -310,12 +302,9 @@ export function PrefillGroupManagementDialog({
header: t('Type'),
cellClassName: 'align-top',
cell: ({ meta }) => (
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
<StatusBadge variant={meta.badge} size='sm'>
{meta.label}
</StatusBadge>
),
},
{
@@ -329,20 +318,14 @@ export function PrefillGroupManagementDialog({
{parsedItems.length > 0 ? (
<>
{parsedItems.slice(0, 6).map((item) => (
<StatusBadge
key={item}
label={item}
autoColor={item}
size='sm'
/>
<StatusBadge key={item} variant='neutral' size='sm'>
{item}
</StatusBadge>
))}
{parsedItems.length > 6 && (
<StatusBadge
label={`+${parsedItems.length - 6} more`}
variant='neutral'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{`+${parsedItems.length - 6} more`}
</StatusBadge>
)}
</>
) : (
@@ -408,12 +391,11 @@ export function PrefillGroupManagementDialog({
>
<div className='bg-muted/30 flex flex-wrap items-center justify-between gap-3 rounded-md border p-2 text-sm'>
<div className='flex flex-wrap items-center gap-2'>
<Button size='sm' onClick={onCreateGroup}>
<Button onClick={onCreateGroup}>
<Plus className='mr-2 h-4 w-4' />
{t('New Group')}
</Button>
<Button
size='sm'
variant='ghost'
onClick={() => refetchGroups()}
disabled={isFetching}
@@ -426,11 +408,9 @@ export function PrefillGroupManagementDialog({
{t('Refresh')}
</Button>
</div>
<StatusBadge
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
variant='neutral'
copyable={false}
/>
<Badge variant='outline'>
{`${groups.length} group${groups.length === 1 ? '' : 's'}`}
</Badge>
</div>
<div className='flex flex-col gap-3'>
@@ -22,9 +22,9 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { checkClusterNameAvailability, updateDeploymentName } from '../../api'
import { deploymentsQueryKeys } from '../../lib'
@@ -22,9 +22,9 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { useIsMobile } from '@/hooks/use-mobile'
@@ -188,11 +188,7 @@ export function SyncWizardDialog({
<div className='flex items-center gap-2'>
<span className='font-medium'>{option.label}</span>
{option.value === 'official' && (
<StatusBadge
label='Default'
variant='neutral'
copyable={false}
/>
<StatusBadge variant='neutral'>Default</StatusBadge>
)}
</div>
<p className='text-muted-foreground text-sm'>
@@ -25,8 +25,9 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { z } from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
@@ -40,7 +41,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getDeployment, updateDeployment } from '../../api'
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQueryClient } from '@tanstack/react-query'
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
import type { ColumnDef, RowSelectionState } from '@tanstack/react-table'
import {
Search,
Info,
@@ -30,16 +30,8 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { DataTableView, useDataTable } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
@@ -47,7 +39,15 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import { Checkbox } from '@/components/ui/checkbox'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useIsMobile } from '@/hooks/use-mobile'
import { applyUpstreamOverwrite } from '../../api'
@@ -119,7 +119,7 @@ export function UpstreamConflictDialog({
const [search, setSearch] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pageSize, setPageSize] = useState(10)
const [pageSize, setPageSize] = useState(20)
const [pageIndex, setPageIndex] = useState(0)
useEffect(() => {
@@ -213,12 +213,9 @@ export function UpstreamConflictDialog({
{row.original.fieldKey}
</span>
{isMobile ? (
<StatusBadge
label={row.original.fieldLabel}
variant='neutral'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{row.original.fieldLabel}
</StatusBadge>
) : null}
</div>
</div>
@@ -235,11 +232,7 @@ export function UpstreamConflictDialog({
<Popover>
<PopoverTrigger
render={
<Button
variant='ghost'
size='sm'
className={isMobile ? 'h-7 w-7 p-0' : 'h-7 gap-2 px-2 text-xs'}
/>
<Button variant='ghost' size={isMobile ? 'icon-sm' : 'sm'} />
}
>
<MousePointerClick className='h-3.5 w-3.5' />
@@ -247,25 +240,17 @@ export function UpstreamConflictDialog({
</PopoverTrigger>
<PopoverContent className='w-[min(90vw,24rem)] space-y-4 text-sm'>
<div>
<StatusBadge
label='Local'
variant='neutral'
size='sm'
copyable={false}
className='mb-1'
/>
<StatusBadge variant='neutral' size='sm' className='mb-1'>
Local
</StatusBadge>
<pre className='bg-muted rounded-md p-2 text-xs'>
{formatValue(row.original.localValue)}
</pre>
</div>
<div>
<StatusBadge
label='Upstream'
variant='info'
size='sm'
copyable={false}
className='mb-1'
/>
<StatusBadge variant='info' size='sm' className='mb-1'>
Upstream
</StatusBadge>
<pre className='bg-muted rounded-md p-2 text-xs'>
{formatValue(row.original.upstreamValue)}
</pre>
@@ -309,12 +294,9 @@ export function UpstreamConflictDialog({
accessorKey: 'fieldLabel',
header: 'Field',
cell: ({ row }) => (
<StatusBadge
label={row.original.fieldLabel}
variant='neutral'
size='sm'
copyable={false}
/>
<StatusBadge variant='neutral' size='sm'>
{row.original.fieldLabel}
</StatusBadge>
),
enableSorting: false,
size: 160,
@@ -396,7 +378,7 @@ export function UpstreamConflictDialog({
const payload: SyncOverwritePayload[] = Object.entries(groupedSelections)
.map(([modelName, fields]) => ({
model_name: modelName,
fields: Array.from(fields),
fields: [...fields],
}))
.filter((item) => item.fields.length > 0)
@@ -512,7 +494,6 @@ export function UpstreamConflictDialog({
</div>
<Button
variant='ghost'
size='sm'
onClick={clearSelections}
disabled={!hasSelection}
>
@@ -560,7 +541,7 @@ export function UpstreamConflictDialog({
setPageIndex(0)
}}
>
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
<SelectTrigger className='w-[70px] text-xs sm:w-[72px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -578,7 +559,6 @@ export function UpstreamConflictDialog({
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) => Math.max(0, prev - 1))
}
@@ -596,7 +576,6 @@ export function UpstreamConflictDialog({
<Button
variant='outline'
size='icon'
className='h-7 w-7 sm:h-8 sm:w-8'
onClick={() =>
setPageIndex((prev) =>
Math.min(totalPages - 1, prev + 1)
@@ -24,8 +24,9 @@ import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -35,7 +36,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { createVendor, updateVendor } from '../../api'
@@ -22,8 +22,8 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
@@ -145,13 +145,12 @@ export function ViewDetailsDialog({
<span className='font-mono'>{deploymentId}</span>
</div>
<div className='grid grid-cols-2 gap-2 sm:flex sm:items-center'>
<Button variant='outline' size='sm' onClick={handleCopyId}>
<Button variant='outline' onClick={handleCopyId}>
<Copy className='mr-2 h-4 w-4' />
{t('Copy')}
</Button>
<Button
variant='outline'
size='sm'
onClick={handleRefresh}
disabled={isFetchingDetails || isFetchingContainers}
>
@@ -257,7 +256,6 @@ export function ViewDetailsDialog({
{url ? (
<Button
variant='outline'
size='sm'
onClick={() => window.open(url, '_blank')}
>
<ExternalLink className='mr-2 h-4 w-4' />
@@ -21,8 +21,7 @@ For commercial licensing, please contact support@quantumnous.com
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Select,
SelectContent,
@@ -30,7 +29,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
} from '@/components/design-system/select'
import { Dialog } from '@/components/dialog'
import { Switch } from '@/components/ui/switch'
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
@@ -206,7 +206,6 @@ export function ViewLogsDialog({
<div className='grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center'>
<Button
variant='outline'
size='sm'
onClick={() => {
refetchContainers()
refetchLogs()
@@ -222,7 +221,6 @@ export function ViewLogsDialog({
</Button>
<Button
variant='outline'
size='sm'
onClick={handleDownload}
disabled={!logsText.trim()}
>
@@ -25,6 +25,16 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import * as z from 'zod'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/design-system/select'
import {
SideDrawerSection,
sideDrawerContentClassName,
@@ -35,7 +45,6 @@ import {
} from '@/components/drawer-layout'
import { JsonEditor } from '@/components/json-editor'
import { TagInput } from '@/components/tag-input'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
@@ -50,17 +59,8 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@@ -870,7 +870,7 @@ export function ModelMutateDrawer({
v !== null && handleFillEndpointTemplate(v)
}
>
<SelectTrigger size='sm' className='w-[200px]'>
<SelectTrigger className='w-[200px]'>
<SelectValue placeholder={t('Load template...')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
@@ -24,6 +24,16 @@ import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/design-system/button'
import { Input } from '@/components/design-system/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/design-system/select'
import {
SideDrawerSection,
sideDrawerContentClassName,
@@ -34,7 +44,6 @@ import {
import { JsonEditor } from '@/components/json-editor'
import { StatusBadge } from '@/components/status-badge'
import { TagInput } from '@/components/tag-input'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
@@ -44,15 +53,6 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetClose,
@@ -143,27 +143,28 @@ export function PrefillGroupFormDrawer({
const handleSubmit = async (values: PrefillGroupFormValues) => {
setIsSaving(true)
let items: string | string[]
if (values.type === 'endpoint') {
items = typeof values.items === 'string' ? values.items : ''
} else {
items = Array.isArray(values.items) ? values.items : []
}
const payload = {
name: values.name.trim(),
type: values.type,
description: values.description?.trim() || '',
items:
values.type === 'endpoint'
? typeof values.items === 'string'
? values.items
: ''
: Array.isArray(values.items)
? values.items
: [],
items,
}
try {
const response = isEdit
? await updatePrefillGroup({
id: currentGroup!.id,
...payload,
})
: await createPrefillGroup(payload)
const response =
isEdit && currentGroup
? await updatePrefillGroup({
id: currentGroup.id,
...payload,
})
: await createPrefillGroup(payload)
if (response.success) {
toast.success(
@@ -185,6 +186,12 @@ export function PrefillGroupFormDrawer({
const meta =
PREFILL_GROUP_TYPE_META[selectedType] || PREFILL_GROUP_TYPE_META.model
let submitLabel = t('Create')
if (isSaving) {
submitLabel = t('Saving...')
} else if (isEdit) {
submitLabel = t('Save changes')
}
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
@@ -277,22 +284,20 @@ export function PrefillGroupFormDrawer({
<FormItem>
<FormLabel>Group Type</FormLabel>
<Select
items={[
...PREFILL_GROUP_TYPES.map((type) => ({
value: type.value,
label: (
<div className='flex flex-col text-left'>
<span className='font-medium'>{type.label}</span>
<span
data-prefill-description
className='text-muted-foreground text-xs'
>
{type.description}
</span>
</div>
),
})),
]}
items={PREFILL_GROUP_TYPES.map((type) => ({
value: type.value,
label: (
<div className='flex flex-col text-left'>
<span className='font-medium'>{type.label}</span>
<span
data-prefill-description
className='text-muted-foreground text-xs'
>
{type.description}
</span>
</div>
),
}))}
value={field.value}
onValueChange={(value) =>
value !== null &&
@@ -335,12 +340,9 @@ export function PrefillGroupFormDrawer({
<div className='border-border/60 flex flex-col gap-3 border-y py-4'>
<div className='flex items-center gap-2'>
<h4 className='text-sm font-medium'>{t('Project')}</h4>
<StatusBadge
label={meta.label}
variant={meta.badge}
size='sm'
copyable={false}
/>
<StatusBadge variant={meta.badge} size='sm'>
{meta.label}
</StatusBadge>
</div>
<FormField
control={form.control}
@@ -399,11 +401,7 @@ export function PrefillGroupFormDrawer({
</SheetClose>
<Button type='submit' form='prefill-group-form' disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving
? t('Saving...')
: isEdit
? t('Save changes')
: t('Create')}
{submitLabel}
</Button>
</SheetFooter>
</SheetContent>
+114 -58
View File
@@ -22,7 +22,7 @@ import { useTranslation } from 'react-i18next'
import { BadgeCell, BadgeListCell } from '@/components/data-table'
import { GroupBadge } from '@/components/group-badge'
import { ProviderBadge } from '@/components/provider-badge'
import { StatusBadge } from '@/components/status-badge'
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
import { TableId } from '@/components/table-id'
import { Checkbox } from '@/components/ui/checkbox'
import {
@@ -94,7 +94,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'id',
header: t('ID'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 10,
cardSpan: 2,
contentMode: 'full',
},
cell: ({ row }) => {
const id = row.getValue('id') as number
return <TableId value={id} />
@@ -106,7 +111,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'icon',
header: t('Icon'),
meta: { mobileHidden: true },
meta: { cardRole: 'hidden' },
cell: ({ row }) => {
const model = row.original
const iconKey =
@@ -130,17 +135,22 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'model_name',
header: t('Model Name'),
meta: { mobileTitle: true },
meta: {
cardRole: 'title',
cardSpan: 2,
contentMode: 'wrap',
},
cell: ({ row }) => {
const name = row.getValue('model_name') as string
return (
<StatusBadge
label={name}
<CopyableStatusBadge
value={name}
variant='neutral'
copyText={name}
size='sm'
className='font-mono'
/>
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
>
{name}
</CopyableStatusBadge>
)
},
minSize: 200,
@@ -161,18 +171,9 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
}
const badge = (
<StatusBadge
label={label}
variant={
(config.color === 'error' ? 'danger' : config.color) as
| 'neutral'
| 'success'
| 'warning'
| 'danger'
| 'info'
}
size='sm'
/>
<StatusBadge variant={config.variant} size='sm'>
{label}
</StatusBadge>
)
// Show tooltip with matched models for non-exact rules
@@ -182,7 +183,9 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
model.matched_models.length > 0
) {
const matchedBadges = model.matched_models.map((m) => (
<StatusBadge key={m} label={m} autoColor={m} size='sm' />
<StatusBadge key={m} variant='neutral' size='sm'>
{m}
</StatusBadge>
))
return (
@@ -204,25 +207,27 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
},
size: 140,
enableSorting: false,
meta: {
cardRole: 'primary',
cardOrder: 10,
contentMode: 'wrap',
},
},
// Status column
{
accessorKey: 'status',
header: t('Status'),
meta: { mobileBadge: true },
meta: { cardRole: 'badge', contentMode: 'wrap' },
cell: ({ row }) => {
const status = row.getValue('status') as number
const config =
MODEL_STATUS_CONFIG[status as 0 | 1] || MODEL_STATUS_CONFIG[0]
return (
<StatusBadge
label={config.label}
variant={config.variant}
size='sm'
copyable={false}
/>
<StatusBadge variant={config.variant} size='sm'>
{config.label}
</StatusBadge>
)
},
filterFn: (row, id, value) => {
@@ -249,8 +254,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
}
return (
<BadgeCell>
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
<BadgeCell className='overflow-visible'>
<ProviderBadge
iconKey={vendor.icon}
label={vendor.name}
className='h-auto overflow-visible [overflow-wrap:anywhere] whitespace-normal [&_[data-slot=status-badge-label]]:overflow-visible [&_[data-slot=status-badge-label]]:text-clip [&_[data-slot=status-badge-label]]:whitespace-normal'
/>
</BadgeCell>
)
},
@@ -260,13 +269,24 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
},
size: 150,
enableSorting: false,
meta: {
cardRole: 'primary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'wrap',
},
},
// Description column
{
accessorKey: 'description',
header: t('Description'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 20,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const description = row.getValue('description') as string
const modelName = row.getValue('model_name') as string
@@ -283,14 +303,21 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'tags',
header: t('Tags'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 30,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const tags = row.getValue('tags') as string
const tagArray = parseModelTags(tags)
return (
<BadgeListCell
items={tagArray.map((tag) => (
<StatusBadge key={tag} label={tag} autoColor={tag} size='sm' />
<StatusBadge key={tag} variant='neutral' size='sm'>
{tag}
</StatusBadge>
))}
/>
)
@@ -303,14 +330,21 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'endpoints',
header: t('Endpoints'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 40,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const endpoints = row.getValue('endpoints') as string
const endpointArray = formatEndpointsDisplay(endpoints)
return (
<BadgeListCell
items={endpointArray.map((ep) => (
<StatusBadge key={ep} label={ep} autoColor={ep} size='sm' />
<StatusBadge key={ep} variant='neutral' size='sm'>
{ep}
</StatusBadge>
))}
/>
)
@@ -323,7 +357,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'bound_channels',
header: t('Bound Channels'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 50,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const channels = row.getValue('bound_channels') as Array<{
id: number
@@ -336,10 +375,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
items={(channels ?? []).map((c) => (
<StatusBadge
key={`${c.id}-${c.name}-${c.type ?? ''}`}
label={`${c.name} (${c.type})`}
autoColor={c.name}
variant='neutral'
size='sm'
/>
>
{`${c.name} (${c.type})`}
</StatusBadge>
))}
/>
)
@@ -352,7 +392,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'enable_groups',
header: t('Enable Groups'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 60,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const groups = row.getValue('enable_groups') as string[]
return (
@@ -371,7 +416,12 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'quota_types',
header: t('Quota Types'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 70,
cardSpan: 2,
contentMode: 'summary',
},
cell: ({ row }) => {
const quotaTypes = row.getValue('quota_types') as number[]
return (
@@ -381,17 +431,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
return (
<StatusBadge
key={qt}
label={config?.label || String(qt)}
variant={
(config?.color === 'error' ? 'danger' : config?.color) as
| 'neutral'
| 'success'
| 'warning'
| 'danger'
| 'info'
}
variant={config?.variant || 'neutral'}
size='sm'
/>
>
{config?.label || String(qt)}
</StatusBadge>
)
})}
/>
@@ -405,16 +449,20 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'sync_official',
header: t('Official Sync'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 80,
contentMode: 'wrap',
},
cell: ({ row }) => {
const syncOfficial = row.getValue('sync_official') as number
return (
<StatusBadge
label={syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
variant={syncOfficial === 1 ? 'success' : 'warning'}
size='sm'
copyable={false}
/>
>
{syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
</StatusBadge>
)
},
filterFn: (row, id, value) => {
@@ -432,7 +480,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'created_time',
header: t('Created'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 90,
contentMode: 'full',
},
cell: ({ row }) => {
const timestamp = row.getValue('created_time') as number
return (
@@ -448,7 +500,11 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
{
accessorKey: 'updated_time',
header: t('Updated'),
meta: { mobileHidden: true },
meta: {
cardRole: 'secondary',
cardOrder: 100,
contentMode: 'full',
},
cell: ({ row }) => {
const timestamp = row.getValue('updated_time') as number
return (
@@ -26,7 +26,7 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -66,14 +66,14 @@ export function ModelsPrimaryButtons() {
return (
<div className='flex items-center gap-2'>
{/* Create Model */}
<Button onClick={handleCreateModel} size='sm'>
<Button onClick={handleCreateModel}>
<Plus className='h-4 w-4' />
{t('Add Model')}
</Button>
{/* More Actions */}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
<DropdownMenuTrigger render={<Button variant='outline' />}>
<MoreHorizontal className='h-4 w-4' />
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
@@ -22,7 +22,6 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { DataTablePage, useDataTable } from '@/components/data-table'
import { useMediaQuery } from '@/hooks'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import { getModels, searchModels, getVendors } from '../api'
@@ -41,7 +40,6 @@ const route = getRouteApi('/_authenticated/models/$section')
export function ModelsTable() {
const { t } = useTranslation()
const { selectedVendor } = useModels()
const isMobile = useMediaQuery('(max-width: 640px)')
// URL state management
const {
@@ -57,7 +55,8 @@ export function ModelsTable() {
navigate: route.useNavigate(),
pagination: {
defaultPage: 1,
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeStorageKey: 'models:page-size:v1',
},
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
@@ -201,6 +200,7 @@ export function ModelsTable() {
<DataTablePage
table={table}
columns={columns}
tableLabel={t('Models')}
isLoading={isLoading}
isFetching={isFetching}
emptyTitle={t('No Models Found')}
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { StatusBadgeProps } from '@/components/status-badge'
import { type PrefillGroup, type PrefillGroupFormValues } from '../types'
import type { PrefillGroup, PrefillGroupFormValues } from '../types'
export type PrefillGroupType = PrefillGroup['type']
@@ -27,19 +27,19 @@ export const PREFILL_GROUP_TYPES = [
value: 'model' as PrefillGroupType,
label: 'Model Group',
description: 'Reusable sets of models you can attach to channels.',
badge: 'blue' as StatusBadgeProps['variant'],
badge: 'neutral' as StatusBadgeProps['variant'],
},
{
value: 'tag' as PrefillGroupType,
label: 'Tag Group',
description: 'Collections of metadata tags for bulk operations.',
badge: 'purple' as StatusBadgeProps['variant'],
badge: 'neutral' as StatusBadgeProps['variant'],
},
{
value: 'endpoint' as PrefillGroupType,
label: 'Endpoint Group',
description: 'HTTP endpoint mappings shared across providers.',
badge: 'cyan' as StatusBadgeProps['variant'],
badge: 'neutral' as StatusBadgeProps['variant'],
},
] as const
@@ -98,13 +98,11 @@ export function parseEndpointKeys(items: PrefillGroup['items']): string[] {
typeof items === 'string' ? JSON.parse(items || '{}') : (items as unknown)
if (Array.isArray(parsed)) {
return parsed
.map((item) =>
typeof item === 'string'
? item
: typeof item?.name === 'string'
? item.name
: ''
)
.map((item) => {
if (typeof item === 'string') return item
if (typeof item?.name === 'string') return item.name
return ''
})
.filter(Boolean)
}
if (parsed && typeof parsed === 'object') {
+26 -16
View File
@@ -16,7 +16,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { type TFunction } from 'i18next'
import type { TFunction } from 'i18next'
import type { StatusBadgeProps } from '@/components/status-badge'
import type { NameRule, ModelStatus, SyncSource } from './types'
@@ -39,28 +41,33 @@ export function getNameRuleOptions(t: TFunction) {
] as const
}
export function getNameRuleConfig(
t: TFunction
): Record<NameRule, { label: string; color: string; description: string }> {
export function getNameRuleConfig(t: TFunction): Record<
NameRule,
{
label: string
variant: StatusBadgeProps['variant']
description: string
}
> {
return {
0: {
label: t('Exact'),
color: 'green',
variant: 'neutral',
description: t('Match model name exactly'),
},
1: {
label: t('Prefix'),
color: 'blue',
variant: 'neutral',
description: t('Match models starting with this name'),
},
2: {
label: t('Contains'),
color: 'orange',
variant: 'neutral',
description: t('Match models containing this name'),
},
3: {
label: t('Suffix'),
color: 'purple',
variant: 'neutral',
description: t('Match models ending with this name'),
},
}
@@ -80,7 +87,10 @@ export function getModelStatusOptions(t: TFunction) {
export function getModelStatusConfig(
t: TFunction
): Record<ModelStatus, { label: string; variant: 'success' | 'neutral' }> {
): Record<
ModelStatus,
{ label: string; variant: StatusBadgeProps['variant'] }
> {
return {
1: { label: t('Enabled'), variant: 'success' },
0: { label: t('Disabled'), variant: 'neutral' },
@@ -119,15 +129,15 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
string,
{
label: string
variant: 'success' | 'neutral' | 'warning' | 'danger'
variant: StatusBadgeProps['variant']
}
> {
return {
running: { label: t('Running'), variant: 'success' },
completed: { label: t('Completed'), variant: 'success' },
failed: { label: t('Failed'), variant: 'danger' },
error: { label: t('Failed'), variant: 'danger' },
destroyed: { label: t('Destroyed'), variant: 'danger' },
failed: { label: t('Failed'), variant: 'destructive' },
error: { label: t('Failed'), variant: 'destructive' },
destroyed: { label: t('Destroyed'), variant: 'destructive' },
'deployment requested': {
label: t('Deployment requested'),
variant: 'warning',
@@ -145,10 +155,10 @@ export function getDeploymentStatusConfig(t: TFunction): Record<
export function getQuotaTypeConfig(
t: TFunction
): Record<number, { label: string; color: string }> {
): Record<number, { label: string; variant: StatusBadgeProps['variant'] }> {
return {
0: { label: t('Usage-based'), color: 'violet' },
1: { label: t('Per-call'), color: 'teal' },
0: { label: t('Usage-based'), variant: 'neutral' },
1: { label: t('Per-call'), variant: 'neutral' },
}
}
+5 -5
View File
@@ -22,9 +22,9 @@ import { Plus } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/design-system/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/design-system/tabs'
import { SectionPageLayout } from '@/components/layout'
import { Button } from '@/components/ui/button'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { listDeployments } from './api'
import { DeploymentAccessGuard } from './components/deployment-access-guard'
@@ -91,7 +91,7 @@ function ModelsContent() {
{activeSection === 'metadata' ? (
<ModelsPrimaryButtons />
) : (
<Button onClick={() => setCreateDeploymentOpen(true)} size='sm'>
<Button onClick={() => setCreateDeploymentOpen(true)}>
<Plus className='h-4 w-4' />
{t('Create deployment')}
</Button>
@@ -100,7 +100,7 @@ function ModelsContent() {
<SectionPageLayout.Content>
<div className='flex h-full min-h-0 flex-col gap-4'>
<Tabs value={activeSection} onValueChange={handleSectionChange}>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-auto'>
{MODELS_SECTION_IDS.map((section) => (
<TabsTrigger key={section} value={section}>
{t(SECTION_META[section].titleKey)}
@@ -143,7 +143,7 @@ function DeploymentsSection() {
// Prefetch deployments list while connection check is in progress.
useEffect(() => {
if (isIoNetEnabled && loadingPhase === 'connection') {
const defaultParams = { p: 1, page_size: 10 }
const defaultParams = { p: 1, page_size: 20 }
queryClient.prefetchQuery({
queryKey: deploymentsQueryKeys.list(defaultParams),
queryFn: () => listDeployments(defaultParams),
@@ -25,7 +25,7 @@ import {
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
type PlaygroundEmptyStateProps = {
onSelectPrompt: (prompt: string) => void
@@ -67,7 +67,7 @@ export function PlaygroundEmptyState({
return (
<Button
className='h-auto min-h-11 justify-start gap-2 px-3 py-2.5 text-left whitespace-normal'
className='h-auto min-h-11 justify-start gap-2 px-3 py-2.5 text-left whitespace-normal sm:h-auto'
key={text}
onClick={() => onSelectPrompt(prompt)}
variant='outline'
@@ -20,8 +20,8 @@ import { SlidersHorizontalIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { PromptInputButton } from '@/components/ai-elements/prompt-input'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Input } from '@/components/design-system/input'
import { StatusBadge } from '@/components/status-badge'
import {
Popover,
PopoverContent,
@@ -123,12 +123,12 @@ function PlaygroundParameterContent({
>
{t(control.labelKey)}
</label>
<Badge
className='h-5 max-w-24 shrink-0 px-1.5 font-mono text-xs'
variant='outline'
<StatusBadge
appearance='outline'
className='max-w-24 shrink-0'
>
{t(getParameterControlValueText(control.key, value))}
</Badge>
</StatusBadge>
</div>
<p className='text-muted-foreground text-xs leading-4'>
{t(control.descriptionKey)}
@@ -249,7 +249,6 @@ export function PlaygroundParameterPanel(props: PlaygroundParameterPanelProps) {
<PopoverContent
align='start'
className='w-[22rem] max-w-[calc(100vw-2rem)] gap-3 p-3'
collisionPadding={8}
side='top'
sideOffset={8}
>
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
import type { LucideIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
Tooltip,
TooltipContent,
@@ -29,7 +29,7 @@ import {
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/design-system/button'
import {
DropdownMenu,
DropdownMenuContent,
@@ -185,7 +185,7 @@ export function MessageActions({
render={
<Button
aria-label={t('Open menu')}
className='data-popup-open:bg-muted text-muted-foreground hover:text-foreground size-11'
className='data-popup-open:bg-muted text-muted-foreground hover:text-foreground'
size='icon'
variant='ghost'
/>

Some files were not shown because too many files have changed in this diff Show More