revert: undo t0ng7u UI design-system refactor
Revert the following commits: -308e3e347feat(web): polish themed data views and add task log details -b2a890e75fix: Fontsource asset resolution across workspace layouts -9d1ca545erefactor(web): refine data-table cards and pricing page layout -0918bdb49refactor(web): consolidate design-system primitives and responsive data views -262ab9312style(web): unify design system across default frontend
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
IconLinuxDo,
|
||||
IconWeChat,
|
||||
} from '@/assets/brand-icons'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useOAuthLogin } from '../hooks/use-oauth-login'
|
||||
@@ -159,8 +159,7 @@ export function OAuthProviders({
|
||||
type='button'
|
||||
disabled={disabled || isLoading || extraDisabled}
|
||||
onClick={onClick}
|
||||
size='xl'
|
||||
className='w-full justify-center rounded-lg'
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
|
||||
+2
-3
@@ -24,9 +24,8 @@ 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,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { sendPasswordResetEmail } from '@/features/auth/api'
|
||||
import {
|
||||
forgotPasswordFormSchema,
|
||||
@@ -113,7 +113,6 @@ export function ForgotPasswordForm({
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='xl'
|
||||
className='mt-2'
|
||||
disabled={isLoading || isActive || !turnstileReady}
|
||||
>
|
||||
|
||||
@@ -24,8 +24,7 @@ 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 { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
@@ -204,7 +204,6 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
|
||||
<Button
|
||||
type='submit'
|
||||
size='xl'
|
||||
className='mt-2 w-full'
|
||||
disabled={!isFormValid || isLoading}
|
||||
>
|
||||
@@ -216,7 +215,8 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
className='text-primary h-auto p-0 sm:h-auto'
|
||||
size='sm'
|
||||
className='text-primary h-auto p-0'
|
||||
onClick={handleToggleMode}
|
||||
>
|
||||
{useBackupCode ? t('Use authenticator code') : t('Use backup code')}
|
||||
@@ -225,7 +225,8 @@ export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
className='text-primary h-auto p-0 sm:h-auto'
|
||||
size='sm'
|
||||
className='text-primary h-auto p-0'
|
||||
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,7 +169,6 @@ export function ResetPasswordConfirm({
|
||||
)}
|
||||
|
||||
<Button
|
||||
size='xl'
|
||||
className='w-full'
|
||||
onClick={
|
||||
newPassword
|
||||
|
||||
Vendored
+3
-8
@@ -20,15 +20,10 @@ 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,11 +25,10 @@ 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,
|
||||
@@ -38,6 +37,7 @@ 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,8 +292,7 @@ export function UserAuthForm({
|
||||
variant='outline'
|
||||
disabled={passkeyButtonDisabled}
|
||||
onClick={handlePasskeyLogin}
|
||||
size='xl'
|
||||
className='w-full justify-center rounded-lg'
|
||||
className='h-11 w-full justify-center gap-2 rounded-lg'
|
||||
>
|
||||
{isPasskeyLoading ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
@@ -376,8 +375,7 @@ export function UserAuthForm({
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
size='xl'
|
||||
className='mt-2 w-full justify-center'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={isLoading || (requiresLegalConsent && !agreedToLegal)}
|
||||
>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
@@ -414,7 +412,7 @@ export function UserAuthForm({
|
||||
description={t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
contentClassName='sm:max-w-sm'
|
||||
contentClassName='max-w-sm'
|
||||
headerClassName='text-left'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
|
||||
@@ -24,11 +24,10 @@ 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,6 +36,7 @@ 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,8 +355,7 @@ export function SignUpForm({
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type='submit'
|
||||
size='xl'
|
||||
className='mt-2 w-full justify-center'
|
||||
className='mt-2 w-full justify-center gap-2'
|
||||
disabled={
|
||||
isLoading ||
|
||||
(requiresLegalConsent && !agreedToLegal) ||
|
||||
@@ -386,7 +385,7 @@ export function SignUpForm({
|
||||
description={t(
|
||||
'Scan the QR code to follow the official account and reply with “验证码” to receive your verification code.'
|
||||
)}
|
||||
contentClassName='sm:max-w-sm'
|
||||
contentClassName='max-w-sm'
|
||||
headerClassName='text-left'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
|
||||
+1
-1
@@ -376,7 +376,7 @@ export async function manageMultiKeys(
|
||||
export async function getMultiKeyStatus(
|
||||
channelId: number,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
pageSize = 50,
|
||||
status?: number
|
||||
): Promise<MultiKeyStatusResponse> {
|
||||
return manageMultiKeys({
|
||||
|
||||
+94
-70
@@ -33,8 +33,11 @@ const SENSITIVE_MASK = '••••'
|
||||
|
||||
/**
|
||||
* Bespoke channel card for the card view. Reuses every column's existing cell
|
||||
* renderer via `flexRender`, preserving the original compact two-column
|
||||
* hierarchy and all row interactions.
|
||||
* renderer via `flexRender`, so the table's information and interactions are
|
||||
* 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.
|
||||
*/
|
||||
function ChannelCardComponent({
|
||||
row,
|
||||
@@ -49,7 +52,7 @@ function ChannelCardComponent({
|
||||
const cells = row.getAllCells()
|
||||
|
||||
const renderCell = (id: string) => {
|
||||
const cell = cells.find((candidate) => candidate.column.id === id)
|
||||
const cell = cells.find((c) => c.column.id === id)
|
||||
if (!cell || !cell.column.columnDef.cell) {
|
||||
return null
|
||||
}
|
||||
@@ -61,6 +64,7 @@ function ChannelCardComponent({
|
||||
response_time: t('Response'),
|
||||
test_time: t('Last Tested'),
|
||||
}
|
||||
|
||||
const groups = parseGroupsList(row.original.group ?? '')
|
||||
|
||||
const selectCell = renderCell('select')
|
||||
@@ -74,89 +78,109 @@ function ChannelCardComponent({
|
||||
const responseCell = renderCell('response_time')
|
||||
const testCell = renderCell('test_time')
|
||||
|
||||
const labelClass = 'text-muted-foreground text-xs font-medium select-none'
|
||||
const labelClass = 'text-muted-foreground text-[11px] 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)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-state={isSelected ? 'selected' : undefined}
|
||||
className='flex flex-col gap-3'
|
||||
>
|
||||
<div className='flex items-center 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'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && statusCell}
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
{actionsCell}
|
||||
</ChannelRowActionsLayoutContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='flex min-w-0 flex-1 flex-col gap-3'>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{!isTagRow && (
|
||||
<div className={labelClass}>
|
||||
#{sensitiveVisible ? row.original.id : SENSITIVE_MASK}
|
||||
</div>
|
||||
<ChannelRowActionsLayoutContext.Provider value='card'>
|
||||
<div
|
||||
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'>
|
||||
<div className='flex min-w-0 flex-1 items-center gap-2'>
|
||||
{!isTagRow && selectCell && (
|
||||
<span className='shrink-0'>{selectCell}</span>
|
||||
)}
|
||||
{nameCell}
|
||||
<div className='min-w-0 overflow-hidden'>{typeCell}</div>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-1.5'>
|
||||
{showStatusBadge && 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>
|
||||
|
||||
<div className='min-w-0'>
|
||||
<div className={cn('mb-1', labelClass)}>{fieldLabels.balance}</div>
|
||||
<div className='min-w-0 text-sm'>
|
||||
{balanceCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
{/* 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>
|
||||
|
||||
<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='text-sm'>
|
||||
{responseCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</div>
|
||||
<div className='text-sm'>
|
||||
{testCell ?? <span className='text-muted-foreground'>-</span>}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className='min-w-0'>
|
||||
{groups.length > 0 ? (
|
||||
<div className='-ml-1.5 flex flex-wrap gap-1'>
|
||||
{groups.map((group) => (
|
||||
<GroupBadge
|
||||
key={group}
|
||||
group={group}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>-</span>
|
||||
)}
|
||||
</div>
|
||||
</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)
|
||||
|
||||
+117
-136
@@ -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,9 +128,10 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{addCount > 0 && (
|
||||
<StatusBadge
|
||||
label={`+${addCount}`}
|
||||
variant='success'
|
||||
size='sm'
|
||||
render={<button type='button' />}
|
||||
copyable={false}
|
||||
className='cursor-pointer'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -142,15 +143,14 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
|
||||
'add'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{`+${addCount}`}
|
||||
</StatusBadge>
|
||||
/>
|
||||
)}
|
||||
{removeCount > 0 && (
|
||||
<StatusBadge
|
||||
variant='destructive'
|
||||
label={`-${removeCount}`}
|
||||
variant='danger'
|
||||
size='sm'
|
||||
render={<button type='button' />}
|
||||
copyable={false}
|
||||
className='cursor-pointer'
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
@@ -162,9 +162,7 @@ function UpstreamUpdateTags({ channel }: { channel: Channel }) {
|
||||
'remove'
|
||||
)
|
||||
}}
|
||||
>
|
||||
{`-${removeCount}`}
|
||||
</StatusBadge>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -364,11 +362,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge variant='neutral' size='sm' className='cursor-help'>
|
||||
{sensitiveVisible
|
||||
? `${t('Used:')} ${usedDisplay}`
|
||||
: maskedUsedLabel}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={
|
||||
sensitiveVisible
|
||||
? `${t('Used:')} ${usedDisplay}`
|
||||
: maskedUsedLabel
|
||||
}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
@@ -430,19 +435,18 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div
|
||||
className={
|
||||
layout === 'card'
|
||||
? 'flex flex-wrap items-center gap-1'
|
||||
: 'flex items-center gap-1'
|
||||
}
|
||||
>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge variant='neutral' size='sm' className='cursor-help'>
|
||||
{sensitiveVisible ? usedDisplay : SENSITIVE_MASK}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={sensitiveVisible ? usedDisplay : SENSITIVE_MASK}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-help'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
@@ -453,14 +457,14 @@ function BalanceCell({ channel }: { channel: Channel }) {
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={remainingBadgeLabel}
|
||||
variant={remainingBadgeVariant}
|
||||
size='sm'
|
||||
render={<button type='button' />}
|
||||
className='cursor-pointer underline-offset-2 hover:underline'
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='cursor-pointer'
|
||||
onClick={handleClickUpdate}
|
||||
>
|
||||
{remainingBadgeLabel}
|
||||
</StatusBadge>
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
@@ -566,11 +570,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: t('ID'),
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 20,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return <TableId value={sensitiveVisible ? id : SENSITIVE_MASK} />
|
||||
@@ -581,11 +581,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('Name'),
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const name = row.getValue('name') as string
|
||||
@@ -600,7 +596,8 @@ export function useChannelsColumns(
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
className='h-6 w-6 p-0'
|
||||
onClick={row.getToggleExpandedHandler()}
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
@@ -611,9 +608,12 @@ export function useChannelsColumns(
|
||||
</Button>
|
||||
<div className='flex items-center gap-1.5'>
|
||||
<span className='font-semibold'>Tag:{tag}</span>
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{`${childrenCount} channels`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`${childrenCount} channels`}
|
||||
variant='blue'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -638,7 +638,7 @@ export function useChannelsColumns(
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<AlertTriangle className='text-warning h-3.5 w-3.5 flex-shrink-0' />
|
||||
<AlertTriangle className='h-3.5 w-3.5 flex-shrink-0 text-amber-500' />
|
||||
}
|
||||
/>
|
||||
<TooltipContent side='top'>
|
||||
@@ -693,20 +693,17 @@ 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 variant='neutral' size='sm'>
|
||||
{t('Tag Aggregate')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Tag Aggregate')}
|
||||
variant='blue'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -732,7 +729,7 @@ export function useChannelsColumns(
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className='flex w-max items-center gap-2'>
|
||||
<div className='flex max-w-full min-w-0 items-center gap-2 overflow-hidden'>
|
||||
{isMultiKey && (
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
@@ -751,10 +748,19 @@ export function useChannelsColumns(
|
||||
)}
|
||||
<TooltipProvider delay={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className='shrink-0' />}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className='max-w-full min-w-0 overflow-hidden' />
|
||||
}
|
||||
>
|
||||
<ProviderBadge
|
||||
iconKey={`${iconName}.Color`}
|
||||
iconSize={18}
|
||||
label={typeName}
|
||||
colorText={false}
|
||||
copyable={false}
|
||||
showDot={false}
|
||||
className='max-w-full min-w-0 overflow-hidden'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>{typeName}</TooltipContent>
|
||||
@@ -765,11 +771,8 @@ export function useChannelsColumns(
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
render={<button type='button' />}
|
||||
className='cursor-pointer'
|
||||
<span
|
||||
className='flex cursor-pointer items-center gap-1.5 text-xs font-medium'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!deploymentId) {
|
||||
@@ -781,7 +784,13 @@ export function useChannelsColumns(
|
||||
/>
|
||||
}
|
||||
>
|
||||
IO.NET
|
||||
<StatusBadge
|
||||
label='IO.NET'
|
||||
variant='purple'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-pointer'
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top'>
|
||||
<div className='max-w-xs space-y-1'>
|
||||
@@ -818,7 +827,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
meta: { cardRole: 'badge', contentMode: 'wrap' },
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const isTagRow = isTagAggregateRow(row.original)
|
||||
const status = row.getValue('status') as number
|
||||
@@ -831,15 +840,21 @@ export function useChannelsColumns(
|
||||
|
||||
if (hasEnabled) {
|
||||
return (
|
||||
<StatusBadge variant='success' size='sm'>
|
||||
{`Active (${childrenCount})`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`Active (${childrenCount})`}
|
||||
variant='success'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{`Inactive (${childrenCount})`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`Inactive (${childrenCount})`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -884,9 +899,12 @@ export function useChannelsColumns(
|
||||
<TooltipProvider delay={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<span />}>
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='space-y-1 text-xs'>
|
||||
@@ -909,9 +927,12 @@ export function useChannelsColumns(
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
@@ -935,12 +956,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'models',
|
||||
header: t('Models'),
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 40,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const models = row.getValue('models') as string
|
||||
const modelArray = parseModelsList(models)
|
||||
@@ -949,12 +965,11 @@ export function useChannelsColumns(
|
||||
items={modelArray.map((model) => (
|
||||
<StatusBadge
|
||||
key={model}
|
||||
variant='neutral'
|
||||
label={model}
|
||||
autoColor={model}
|
||||
size='sm'
|
||||
title={model}
|
||||
>
|
||||
{model}
|
||||
</StatusBadge>
|
||||
className='font-mono'
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
@@ -967,12 +982,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'group',
|
||||
header: t('Groups'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const group = row.getValue('group') as string
|
||||
const groupArray = parseGroupsList(group)
|
||||
@@ -984,7 +994,6 @@ export function useChannelsColumns(
|
||||
group={g}
|
||||
label={sensitiveVisible ? undefined : SENSITIVE_MASK}
|
||||
size='sm'
|
||||
title={sensitiveVisible ? g : undefined}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
@@ -1006,23 +1015,14 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'tag',
|
||||
header: t('Tag'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const tag = row.getValue('tag') as string | null
|
||||
if (!tag) {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusBadge variant='neutral' size='sm' title={tag}>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
)
|
||||
return <StatusBadge label={tag} autoColor={tag} size='sm' />
|
||||
},
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
@@ -1032,11 +1032,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: t('Priority'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 30,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <PriorityCell channel={row.original} />,
|
||||
size: 100,
|
||||
},
|
||||
@@ -1045,11 +1041,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'weight',
|
||||
header: t('Weight'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 40,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => <WeightCell channel={row.original} />,
|
||||
size: 90,
|
||||
enableSorting: false,
|
||||
@@ -1059,12 +1051,6 @@ 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,
|
||||
},
|
||||
@@ -1073,19 +1059,18 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'response_time',
|
||||
header: t('Response'),
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 50,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const responseTime = row.getValue('response_time') as number
|
||||
const config = getResponseTimeConfig(responseTime)
|
||||
|
||||
return (
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{formatResponseTime(responseTime, t)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={formatResponseTime(responseTime, t)}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 110,
|
||||
@@ -1095,11 +1080,7 @@ export function useChannelsColumns(
|
||||
{
|
||||
accessorKey: 'test_time',
|
||||
header: t('Last Tested'),
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 60,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const testTime = row.getValue('test_time') as number
|
||||
|
||||
@@ -1118,12 +1099,12 @@ export function useChannelsColumns(
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<StatusBadge
|
||||
label={timeText}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
{timeText}
|
||||
</StatusBadge>
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side='top'>
|
||||
|
||||
+37
-28
@@ -34,8 +34,7 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Toggle } from '@/components/design-system/toggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -45,6 +44,8 @@ import {
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -106,37 +107,44 @@ export function ChannelsPrimaryButtons() {
|
||||
return (
|
||||
<>
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Desktop: view toggles */}
|
||||
<div className='hidden items-center gap-1.5 sm:flex'>
|
||||
<Toggle
|
||||
variant='outline'
|
||||
pressed={batchMode}
|
||||
onPressedChange={handleBatchModeToggle}
|
||||
aria-label={t('Batch Operations')}
|
||||
{/* Desktop: Toggle switches visible */}
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<ListChecks className='text-muted-foreground h-4 w-4' />
|
||||
<Label
|
||||
htmlFor='channel-batch-mode'
|
||||
className='cursor-pointer text-sm'
|
||||
>
|
||||
<ListChecks />
|
||||
{t('Batch Operations')}
|
||||
</Toggle>
|
||||
</Label>
|
||||
<Switch
|
||||
id='channel-batch-mode'
|
||||
checked={batchMode}
|
||||
onCheckedChange={handleBatchModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
variant='outline'
|
||||
pressed={enableTagMode}
|
||||
onPressedChange={handleTagModeToggle}
|
||||
aria-label={t('Tag Mode')}
|
||||
>
|
||||
<Tags />
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<Tags className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='tag-mode' className='cursor-pointer text-sm'>
|
||||
{t('Tag Mode')}
|
||||
</Toggle>
|
||||
</Label>
|
||||
<Switch
|
||||
id='tag-mode'
|
||||
checked={enableTagMode}
|
||||
onCheckedChange={handleTagModeToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
variant='outline'
|
||||
pressed={idSort}
|
||||
onPressedChange={handleIdSortToggle}
|
||||
aria-label={t('Sort by ID')}
|
||||
>
|
||||
<SortAsc />
|
||||
<div className='hidden items-center gap-2 rounded-md border px-3 py-1.5 sm:flex'>
|
||||
<SortAsc className='text-muted-foreground h-4 w-4' />
|
||||
<Label htmlFor='id-sort' className='cursor-pointer text-sm'>
|
||||
{t('Sort by ID')}
|
||||
</Toggle>
|
||||
</Label>
|
||||
<Switch
|
||||
id='id-sort'
|
||||
checked={idSort}
|
||||
onCheckedChange={handleIdSortToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Create Channel */}
|
||||
@@ -148,6 +156,7 @@ export function ChannelsPrimaryButtons() {
|
||||
setCurrentRow(null)
|
||||
setOpen('create-channel')
|
||||
}}
|
||||
size='sm'
|
||||
disabled={!canEditSensitive}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
@@ -164,7 +173,7 @@ export function ChannelsPrimaryButtons() {
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' />}>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<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/design-system/button'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -114,8 +114,7 @@ export function ChannelsTable() {
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
defaultPage: 1,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeStorageKey: 'channels:page-size:v1',
|
||||
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
@@ -313,6 +312,10 @@ export function ChannelsTable() {
|
||||
columns,
|
||||
totalCount,
|
||||
sorting,
|
||||
initialColumnVisibility: {
|
||||
models: false,
|
||||
tag: false,
|
||||
},
|
||||
columnVisibilityStorageKey: CHANNELS_COLUMN_VISIBILITY_STORAGE_KEY,
|
||||
columnSizingStorageKey: isMobile
|
||||
? false
|
||||
@@ -406,7 +409,6 @@ export function ChannelsTable() {
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
tableLabel={t('Channels')}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Channels Found')}
|
||||
@@ -419,19 +421,17 @@ export function ChannelsTable() {
|
||||
renderCard={(row, { isSelected }) => (
|
||||
<ChannelCard row={row} isSelected={isSelected} />
|
||||
)}
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-2 xl:grid-cols-3'
|
||||
cardGridClassName='grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3'
|
||||
applyHeaderSize
|
||||
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'
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,6 +116,7 @@ export function DataTableBulkActions<TData>({
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected channels')}
|
||||
title={t('Enable selected channels')}
|
||||
/>
|
||||
@@ -136,6 +137,7 @@ export function DataTableBulkActions<TData>({
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected channels')}
|
||||
title={t('Disable selected channels')}
|
||||
/>
|
||||
@@ -156,6 +158,7 @@ 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')}
|
||||
/>
|
||||
@@ -183,6 +186,7 @@ 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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -213,8 +213,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTest()
|
||||
}}
|
||||
aria-label={t('Test Channel Connection')}
|
||||
@@ -255,9 +255,8 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size='icon-sm'
|
||||
variant='ghost'
|
||||
className='data-popup-open:bg-muted'
|
||||
className='data-popup-open:bg-muted flex h-8 w-8 p-0'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -281,6 +280,7 @@ export function DataTableRowActions({ row }: DataTableRowActionsProps) {
|
||||
<PlugZap size={16} />
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Query Balance */}
|
||||
<DropdownMenuItem onClick={handleQueryBalance}>
|
||||
{t('Query Balance')}
|
||||
|
||||
+1
-1
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
|
||||
+22
-8
@@ -21,8 +21,10 @@ import { type ReactNode, useMemo, useRef, 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 { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,9 +32,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
@@ -316,6 +316,7 @@ export function AdvancedCustomEditorDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'visual' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToVisualMode}
|
||||
>
|
||||
{t('Visual')}
|
||||
@@ -323,6 +324,7 @@ export function AdvancedCustomEditorDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'json' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToJsonMode}
|
||||
>
|
||||
{t('JSON Text')}
|
||||
@@ -341,7 +343,7 @@ export function AdvancedCustomEditorDialog({
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='max-w-full min-w-[260px] flex-1 sm:w-[320px]'>
|
||||
<SelectTrigger className='h-8 max-w-full min-w-[260px] flex-1 sm:w-[320px]'>
|
||||
<SelectValue className='min-w-0 truncate'>
|
||||
{t(templateLabel)}
|
||||
</SelectValue>
|
||||
@@ -368,6 +370,7 @@ export function AdvancedCustomEditorDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => applyTemplate('fill')}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
@@ -375,6 +378,7 @@ export function AdvancedCustomEditorDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => applyTemplate('append')}
|
||||
>
|
||||
{t('Append Template')}
|
||||
@@ -385,7 +389,12 @@ 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' onClick={addRoute}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={addRoute}
|
||||
>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('Add route')}
|
||||
</Button>
|
||||
@@ -434,7 +443,12 @@ export function AdvancedCustomEditorDialog({
|
||||
) : (
|
||||
<div className='p-4'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Button type='button' variant='outline' onClick={formatJson}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={formatJson}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
|
||||
+1
-1
@@ -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'
|
||||
|
||||
|
||||
+35
-21
@@ -50,16 +50,6 @@ 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,
|
||||
@@ -68,8 +58,18 @@ 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: 20,
|
||||
pageSize: 30,
|
||||
})
|
||||
const endpointSelectItems = useMemo(
|
||||
() =>
|
||||
@@ -412,7 +412,7 @@ function ChannelTestDialogContent({
|
||||
setIsDeleteFailedDialogOpen(false)
|
||||
setIsDeletingFailed(false)
|
||||
setFailureDetails(null)
|
||||
setPagination({ pageIndex: 0, pageSize: 20 })
|
||||
setPagination({ pageIndex: 0, pageSize: 30 })
|
||||
}, [])
|
||||
|
||||
const streamDisabled = STREAM_INCOMPATIBLE_ENDPOINTS.has(endpointType)
|
||||
@@ -876,9 +876,12 @@ function ChannelTestDialogContent({
|
||||
{model}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<StatusBadge variant='info' size='sm'>
|
||||
{t('Default')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Default')}
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -1059,6 +1062,7 @@ function ChannelTestDialogContent({
|
||||
{isBatchTesting ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleStopBatchTest}
|
||||
disabled={isBatchStopRequested}
|
||||
>
|
||||
@@ -1069,6 +1073,7 @@ function ChannelTestDialogContent({
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => handleBatchTest(filteredModels)}
|
||||
disabled={isAnyTesting || filteredModels.length === 0}
|
||||
>
|
||||
@@ -1077,6 +1082,7 @@ function ChannelTestDialogContent({
|
||||
{successModels.length > 0 && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleSelectSuccessfulModels}
|
||||
>
|
||||
<CheckCircle2 data-icon='inline-start' />
|
||||
@@ -1088,6 +1094,7 @@ function ChannelTestDialogContent({
|
||||
{failedModels.length > 0 && (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setIsDeleteFailedDialogOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
@@ -1183,14 +1190,16 @@ function TestStatusCell({ result }: { result?: TestResult }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!result || result.status === 'idle') {
|
||||
return <StatusBadge variant='neutral'>{t('Not tested')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge label={t('Not tested')} variant='neutral' copyable={false} />
|
||||
)
|
||||
}
|
||||
|
||||
if (result.status === 'testing') {
|
||||
return (
|
||||
<StatusBadge variant='info'>
|
||||
<StatusBadge variant='info' copyable={false}>
|
||||
<Loader2 className='size-3.5 shrink-0 animate-spin' />
|
||||
<span className='leading-normal whitespace-nowrap'>
|
||||
<span className='min-w-0 truncate leading-normal'>
|
||||
{t('Testing...')}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
@@ -1198,10 +1207,12 @@ function TestStatusCell({ result }: { result?: TestResult }) {
|
||||
}
|
||||
|
||||
if (result.status === 'success') {
|
||||
return <StatusBadge variant='success'>{t('Success')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge label={t('Success')} variant='success' copyable={false} />
|
||||
)
|
||||
}
|
||||
|
||||
return <StatusBadge variant='destructive'>{t('Failed')}</StatusBadge>
|
||||
return <StatusBadge label={t('Failed')} variant='danger' copyable={false} />
|
||||
}
|
||||
|
||||
function TestResultCell({
|
||||
@@ -1279,6 +1290,7 @@ 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')
|
||||
}
|
||||
@@ -1291,6 +1303,7 @@ function FailureResultContent({
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-7 w-fit px-2 text-xs'
|
||||
aria-haspopup='dialog'
|
||||
onClick={() => onOpenDetails({ model, summary, details })}
|
||||
>
|
||||
@@ -1363,7 +1376,7 @@ function FailureDetailsSheet({
|
||||
onClick={() => copyToClipboard(details.details)}
|
||||
>
|
||||
{copiedText === details.details ? (
|
||||
<Check className='text-success mr-2 h-4 w-4' />
|
||||
<Check className='mr-2 h-4 w-4 text-green-600' />
|
||||
) : (
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
)}
|
||||
@@ -1394,6 +1407,7 @@ function TestModelsBulkActions({ table }: { table: TanStackTable<ModelRow> }) {
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={handleCopySelected}
|
||||
disabled={selectedModels.length === 0}
|
||||
/>
|
||||
|
||||
+81
-41
@@ -29,10 +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, type StatusBadgeProps } from '@/components/status-badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
@@ -328,8 +328,8 @@ const PLAN_TYPE_BADGE: Record<
|
||||
> = {
|
||||
enterprise: { label: 'Enterprise', variant: 'success' },
|
||||
team: { label: 'Team', variant: 'info' },
|
||||
pro: { label: 'Pro', variant: 'neutral' },
|
||||
plus: { label: 'Plus', variant: 'neutral' },
|
||||
pro: { label: 'Pro', variant: 'blue' },
|
||||
plus: { label: 'Plus', variant: 'purple' },
|
||||
free: { label: 'Free', variant: 'warning' },
|
||||
}
|
||||
|
||||
@@ -372,7 +372,7 @@ function windowLabel(windowData?: CodexRateLimitWindow | null) {
|
||||
const percent = clampPercent(windowData?.used_percent)
|
||||
let variant: StatusBadgeProps['variant'] = 'info'
|
||||
if (percent >= 95) {
|
||||
variant = 'destructive'
|
||||
variant = 'danger'
|
||||
} else if (percent >= 80) {
|
||||
variant = 'warning'
|
||||
}
|
||||
@@ -384,12 +384,16 @@ function getUsageStatusBadge(
|
||||
t: (key: string) => string
|
||||
) {
|
||||
if (!rateLimit || Object.keys(rateLimit).length === 0) {
|
||||
return <StatusBadge variant='neutral'>{t('Pending')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge label={t('Pending')} variant='neutral' copyable={false} />
|
||||
)
|
||||
}
|
||||
if (rateLimit.allowed && !rateLimit.limit_reached) {
|
||||
return <StatusBadge variant='success'>{t('Available')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge label={t('Available')} variant='success' copyable={false} />
|
||||
)
|
||||
}
|
||||
return <StatusBadge variant='destructive'>{t('Limited')}</StatusBadge>
|
||||
return <StatusBadge label={t('Limited')} variant='danger' copyable={false} />
|
||||
}
|
||||
|
||||
function formatLabelValue(label: string, value: string) {
|
||||
@@ -400,11 +404,27 @@ const percentTextClassName: Record<
|
||||
NonNullable<StatusBadgeProps['variant']>,
|
||||
string
|
||||
> = {
|
||||
success: 'text-success',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-destructive',
|
||||
info: 'text-info',
|
||||
neutral: 'text-muted-foreground',
|
||||
info: 'text-status-info',
|
||||
success: 'text-status-success',
|
||||
warning: 'text-status-warning',
|
||||
destructive: 'text-status-destructive',
|
||||
purple: 'text-chart-4',
|
||||
amber: 'text-warning',
|
||||
blue: 'text-chart-1',
|
||||
cyan: 'text-chart-2',
|
||||
green: 'text-success',
|
||||
grey: 'text-muted-foreground',
|
||||
indigo: 'text-chart-1',
|
||||
'light-blue': 'text-info',
|
||||
'light-green': 'text-emerald-500 dark:text-emerald-300',
|
||||
lime: 'text-chart-3',
|
||||
orange: 'text-warning',
|
||||
pink: 'text-chart-5',
|
||||
red: 'text-destructive',
|
||||
teal: 'text-chart-2',
|
||||
violet: 'text-chart-4',
|
||||
yellow: 'text-warning',
|
||||
}
|
||||
|
||||
type RateLimitWindowProps = {
|
||||
@@ -444,7 +464,7 @@ function RateLimitWindow(props: RateLimitWindowProps) {
|
||||
>
|
||||
{hasData ? `${percent}%` : '-'}
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs'>
|
||||
<div className='text-muted-foreground mt-1 text-[11px]'>
|
||||
{t('Used')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -462,7 +482,7 @@ function RateLimitWindow(props: RateLimitWindowProps) {
|
||||
)}
|
||||
<div className='mt-3 grid grid-cols-1 gap-2 text-xs sm:grid-cols-2'>
|
||||
<div className='min-w-0'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
{t('Reset at:')}
|
||||
</div>
|
||||
<div className='break-all tabular-nums'>
|
||||
@@ -470,7 +490,7 @@ function RateLimitWindow(props: RateLimitWindowProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className='min-w-0 sm:text-right'>
|
||||
<div className='text-muted-foreground text-xs'>
|
||||
<div className='text-muted-foreground text-[11px]'>
|
||||
{t('Resets in:')}
|
||||
</div>
|
||||
<div className='tabular-nums'>
|
||||
@@ -545,7 +565,9 @@ function RateLimitGroupSection(props: RateLimitGroupSectionProps) {
|
||||
</SectionHeading>
|
||||
{props.meteredFeature ? (
|
||||
<div className='bg-background ring-border/60 inline-flex max-w-full flex-wrap items-center gap-x-2 gap-y-1 rounded-lg px-2 py-1 text-xs ring-1'>
|
||||
<span className='text-muted-foreground text-xs'>metered_feature</span>
|
||||
<span className='text-muted-foreground text-[11px]'>
|
||||
metered_feature
|
||||
</span>
|
||||
<span className='min-w-0 font-mono break-all'>
|
||||
{props.meteredFeature}
|
||||
</span>
|
||||
@@ -578,7 +600,7 @@ function InfoField(props: {
|
||||
props.className
|
||||
)}
|
||||
>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div className='mt-1 flex min-w-0 items-start justify-between gap-2'>
|
||||
@@ -613,7 +635,7 @@ function ResetCreditTimeField(props: {
|
||||
}) {
|
||||
return (
|
||||
<div className='min-w-0'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||||
{props.label}
|
||||
</div>
|
||||
<div
|
||||
@@ -645,9 +667,11 @@ function ResetCreditItem(props: { credit: CodexResetCredit; index: number }) {
|
||||
<div className='min-w-0 text-sm font-medium break-words'>
|
||||
{title}
|
||||
</div>
|
||||
<StatusBadge variant={statusBadge.variant}>
|
||||
{t(statusBadge.label)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(statusBadge.label)}
|
||||
variant={statusBadge.variant}
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
{props.credit.description ? (
|
||||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||||
@@ -655,13 +679,13 @@ function ResetCreditItem(props: { credit: CodexResetCredit; index: number }) {
|
||||
</div>
|
||||
) : null}
|
||||
{props.credit.id ? (
|
||||
<div className='text-muted-foreground mt-1 font-mono text-xs break-all'>
|
||||
<div className='text-muted-foreground mt-1 font-mono text-[11px] break-all'>
|
||||
{props.credit.id}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='shrink-0 text-right'>
|
||||
<div className='text-muted-foreground text-xs font-medium'>
|
||||
<div className='text-muted-foreground text-[11px] font-medium'>
|
||||
{t('Expires in')}
|
||||
</div>
|
||||
<div
|
||||
@@ -796,6 +820,7 @@ function ResetCreditsPanel(props: {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={props.onRefresh}
|
||||
disabled={props.isLoading}
|
||||
>
|
||||
@@ -816,6 +841,7 @@ function ResetCreditsPanel(props: {
|
||||
<Button
|
||||
type='button'
|
||||
variant={canReset ? 'destructive' : 'outline'}
|
||||
size='sm'
|
||||
onClick={props.onRequestReset}
|
||||
disabled={!canReset || props.isLoading || props.isResetting}
|
||||
className='shrink-0'
|
||||
@@ -1057,7 +1083,7 @@ export function CodexUsageDialog({
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{errorMessage && (
|
||||
<div className='border-destructive/25 bg-destructive/8 text-status-destructive rounded-lg border px-4 py-3 text-sm'>
|
||||
<div className='rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400'>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -1072,6 +1098,7 @@ export function CodexUsageDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={onRefresh}
|
||||
disabled={Boolean(isRefreshing)}
|
||||
>
|
||||
@@ -1083,25 +1110,35 @@ export function CodexUsageDialog({
|
||||
</CardHeader>
|
||||
<CardContent className='p-4 pt-0'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<StatusBadge variant={accountBadge.variant}>
|
||||
{accountBadge.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={accountBadge.label}
|
||||
variant={accountBadge.variant}
|
||||
copyable={false}
|
||||
/>
|
||||
{getUsageStatusBadge(rateLimit, t)}
|
||||
<StatusBadge variant='neutral'>
|
||||
{`HTTP ${response?.upstream_status ?? '-'}`}
|
||||
</StatusBadge>
|
||||
<StatusBadge variant='neutral'>
|
||||
{formatLabelValue(t('Reset count:'), resetCreditsText)}
|
||||
</StatusBadge>
|
||||
<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}
|
||||
/>
|
||||
{payload?.credits?.overage_limit_reached ? (
|
||||
<StatusBadge variant='destructive'>
|
||||
{t('Overage limited')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Overage limited')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
/>
|
||||
) : null}
|
||||
{payload?.spend_control?.reached ? (
|
||||
<StatusBadge variant='destructive'>
|
||||
{t('Spend limited')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Spend limited')}
|
||||
variant='danger'
|
||||
copyable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className='mt-4 grid grid-cols-1 gap-3 md:grid-cols-2'>
|
||||
@@ -1140,9 +1177,11 @@ export function CodexUsageDialog({
|
||||
<div className='text-sm font-semibold'>
|
||||
{t('Reset Credits')}
|
||||
</div>
|
||||
<StatusBadge variant='neutral'>
|
||||
{`${t('Available')} ${resetCreditsText}`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`${t('Available')} ${resetCreditsText}`}
|
||||
variant={Number(resetCredits) > 0 ? 'blue' : 'neutral'}
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className='text-muted-foreground mt-1 text-xs leading-5'>
|
||||
{t('View issued reset credits, grant dates, and expiration.')}
|
||||
@@ -1242,6 +1281,7 @@ export function CodexUsageDialog({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => copyToClipboard(rawJsonText)}
|
||||
disabled={!rawJsonText}
|
||||
>
|
||||
|
||||
+2
-2
@@ -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,13 +17,18 @@ 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, X } from 'lucide-react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useEffect, 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 { 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -31,12 +36,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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'
|
||||
} from '@/components/ui/select'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
@@ -229,7 +229,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
description={t(
|
||||
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
|
||||
)}
|
||||
contentClassName='max-h-[90vh] sm:max-w-2xl'
|
||||
contentClassName='max-h-[90vh] max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
@@ -285,16 +285,15 @@ 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) => (
|
||||
<Badge
|
||||
<StatusBadge
|
||||
key={model}
|
||||
variant='secondary'
|
||||
render={<button type='button' />}
|
||||
variant='neutral'
|
||||
className='cursor-pointer transition-opacity hover:opacity-70'
|
||||
copyable={false}
|
||||
onClick={() => handleRemoveModel(model)}
|
||||
aria-label={`${t('Remove')}: ${model}`}
|
||||
>
|
||||
{model}
|
||||
<X data-icon='inline-end' aria-hidden='true' />
|
||||
</Badge>
|
||||
{model} ×
|
||||
</StatusBadge>
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground text-sm'>
|
||||
@@ -305,10 +304,12 @@ 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)) {
|
||||
@@ -381,6 +382,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setModelMapping(
|
||||
JSON.stringify(
|
||||
@@ -396,6 +398,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
|
||||
>
|
||||
{t('Clear Mapping')}
|
||||
@@ -403,6 +406,7 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setModelMapping('')}
|
||||
>
|
||||
{t('No Change')}
|
||||
|
||||
+5
-10
@@ -22,22 +22,17 @@ 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,
|
||||
@@ -349,7 +344,7 @@ export function FetchModelsDialog({
|
||||
{redirectOnlySet.has(normalizeModelName(model)) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<Info className='text-warning h-3.5 w-3.5' />}
|
||||
render={<Info className='h-3.5 w-3.5 text-amber-500' />}
|
||||
></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t('From model redirect, not yet added to models list')}
|
||||
@@ -388,7 +383,7 @@ export function FetchModelsDialog({
|
||||
t('Fetch available models from upstream')
|
||||
)
|
||||
}
|
||||
contentClassName='sm:max-w-3xl'
|
||||
contentClassName='max-w-3xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
|
||||
Vendored
+3
-3
@@ -27,8 +27,8 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/design-system/alert-dialog'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export type MissingModelsAction = 'cancel' | 'submit' | 'add'
|
||||
|
||||
@@ -76,7 +76,7 @@ export function MissingModelsConfirmationDialog({
|
||||
'The following models in the model redirect have not been added to the "Models" list and may fail during invocation due to missing available models:'
|
||||
)}
|
||||
</div>
|
||||
<div className='bg-destructive/8 text-status-destructive rounded-md p-2 font-mono text-xs break-all'>
|
||||
<div className='rounded-md bg-red-50 p-2 font-mono text-xs break-all text-red-600 dark:bg-red-950/50 dark:text-red-400'>
|
||||
{missingModels.join(', ')}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
+44
-23
@@ -24,7 +24,9 @@ import { toast } from 'sonner'
|
||||
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { StaticDataTable } from '@/components/data-table'
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -32,9 +34,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
} from '@/components/ui/select'
|
||||
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(20)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
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 || 20)
|
||||
setPageSize(response.data.page_size || 10)
|
||||
setTotalPages(response.data.total_pages || 0)
|
||||
setEnabledCount(response.data.enabled_count || 0)
|
||||
setManualDisabledCount(response.data.manual_disabled_count || 0)
|
||||
@@ -218,7 +218,14 @@ export function MultiKeyManageDialog({
|
||||
|
||||
const renderStatusBadge = (status: number) => {
|
||||
const config = getMultiKeyStatusConfig(status)
|
||||
return <StatusBadge variant={config.variant}>{t(config.label)}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t(config.label)}
|
||||
variant={config.variant}
|
||||
showDot
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const formatKeyTimestamp = (timestamp?: number) => {
|
||||
@@ -236,20 +243,28 @@ export function MultiKeyManageDialog({
|
||||
title={
|
||||
<>
|
||||
{t('Multi-Key Management')}
|
||||
<StatusBadge variant='neutral'>{currentRow.name}</StatusBadge>
|
||||
<StatusBadge
|
||||
label={currentRow.name}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
{currentRow.channel_info?.multi_key_mode && (
|
||||
<StatusBadge variant='neutral'>
|
||||
{currentRow.channel_info.multi_key_mode === 'random'
|
||||
? t('Random')
|
||||
: t('Polling')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={
|
||||
currentRow.channel_info.multi_key_mode === 'random'
|
||||
? t('Random')
|
||||
: t('Polling')
|
||||
}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={t(
|
||||
'Manage multi-key status and configuration for this channel'
|
||||
)}
|
||||
contentClassName='flex max-h-[90vh] flex-col sm:max-w-5xl'
|
||||
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
|
||||
titleClassName='flex items-center gap-2'
|
||||
contentHeight='min(72vh, 720px)'
|
||||
bodyClassName='space-y-4'
|
||||
@@ -279,10 +294,12 @@ 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)}
|
||||
>
|
||||
@@ -303,6 +320,7 @@ export function MultiKeyManageDialog({
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => loadKeyStatus()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
@@ -312,6 +330,7 @@ export function MultiKeyManageDialog({
|
||||
{manualDisabledCount + autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='default'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'enable-all' })}
|
||||
>
|
||||
<Power className='mr-2 h-4 w-4' />
|
||||
@@ -322,6 +341,7 @@ export function MultiKeyManageDialog({
|
||||
{enabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setConfirmAction({ type: 'disable-all' })}
|
||||
>
|
||||
<PowerOff className='mr-2 h-4 w-4' />
|
||||
@@ -332,6 +352,7 @@ export function MultiKeyManageDialog({
|
||||
{autoDisabledCount > 0 && (
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
if (!canEditSensitive) return
|
||||
setConfirmAction({ type: 'delete-disabled' })
|
||||
@@ -357,17 +378,15 @@ 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>
|
||||
)}
|
||||
{!isLoading && keys.length === 0 && (
|
||||
) : 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]'
|
||||
@@ -431,6 +450,7 @@ export function MultiKeyManageDialog({
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
>
|
||||
@@ -438,6 +458,7 @@ export function MultiKeyManageDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages || isLoading}
|
||||
>
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import type { MultiKeyConfirmAction } from '../../types'
|
||||
|
||||
|
||||
+9
-8
@@ -22,6 +22,7 @@ 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,
|
||||
@@ -31,11 +32,10 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} 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'
|
||||
} from '@/components/ui/alert-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 { Progress } from '@/components/ui/progress'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
@@ -484,13 +484,14 @@ export function OllamaModelsDialog({
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button variant='outline' onClick={selectAllFiltered}>
|
||||
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
|
||||
{t('Select all (filtered)')}
|
||||
</Button>
|
||||
<Button variant='outline' onClick={clearSelection}>
|
||||
<Button variant='outline' size='sm' onClick={clearSelection}>
|
||||
{t('Clear selection')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
onClick={() => void applySelection('append')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
@@ -498,6 +499,7 @@ export function OllamaModelsDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() => void applySelection('replace')}
|
||||
disabled={!selected.length}
|
||||
>
|
||||
@@ -545,8 +547,7 @@ export function OllamaModelsDialog({
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={t('Delete')}
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => {
|
||||
setDeleteTarget(m.id)
|
||||
|
||||
+127
-62
@@ -36,8 +36,16 @@ import {
|
||||
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 { 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -45,15 +53,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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'
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -576,11 +576,17 @@ const getOperationSummary = (
|
||||
}
|
||||
|
||||
const getModeTagTailwind = (mode: string): string => {
|
||||
// Only destructive-ish operations get a color voice; the rest stay neutral —
|
||||
// the mode label itself carries the identity.
|
||||
if (mode.includes('header'))
|
||||
return 'bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/20'
|
||||
if (mode.includes('replace') || mode.includes('trim'))
|
||||
return 'bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/20'
|
||||
if (mode.includes('copy') || mode.includes('move'))
|
||||
return 'bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/20'
|
||||
if (mode.includes('error') || mode.includes('prune'))
|
||||
return 'bg-destructive/10 text-destructive border-destructive/20'
|
||||
return 'bg-muted text-muted-foreground border-border/60'
|
||||
return 'bg-red-500/15 text-red-700 dark:text-red-300 border-red-500/20'
|
||||
if (mode.includes('sync'))
|
||||
return 'bg-green-500/15 text-green-700 dark:text-green-300 border-green-500/20'
|
||||
return 'bg-muted text-muted-foreground'
|
||||
}
|
||||
|
||||
const getModePathLabel = (mode: string): string => {
|
||||
@@ -964,6 +970,7 @@ const validateOperations = (
|
||||
if (headers.length === 0)
|
||||
return t('Rule {{line}} pass_headers format is invalid', { line })
|
||||
}
|
||||
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -1748,6 +1755,7 @@ export function ParamOverrideEditorDialog(
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'visual' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToVisualMode}
|
||||
>
|
||||
{t('Visual')}
|
||||
@@ -1755,6 +1763,7 @@ export function ParamOverrideEditorDialog(
|
||||
<Button
|
||||
type='button'
|
||||
variant={editMode === 'json' ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
onClick={switchToJsonMode}
|
||||
>
|
||||
{t('JSON Text')}
|
||||
@@ -1777,7 +1786,7 @@ export function ParamOverrideEditorDialog(
|
||||
setTemplatePresetKey(v || 'operations_default')
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[220px]'>
|
||||
<SelectTrigger className='h-8 w-[220px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -1793,6 +1802,7 @@ export function ParamOverrideEditorDialog(
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => fillTemplate('fill')}
|
||||
>
|
||||
{t('Fill Template')}
|
||||
@@ -1800,11 +1810,17 @@ export function ParamOverrideEditorDialog(
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => fillTemplate('append')}
|
||||
>
|
||||
{t('Append Template')}
|
||||
</Button>
|
||||
<Button type='button' variant='ghost' onClick={resetEditorState}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={resetEditorState}
|
||||
>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1844,7 +1860,7 @@ export function ParamOverrideEditorDialog(
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
onClick={addOperation}
|
||||
>
|
||||
<Plus className='h-4 w-4' />
|
||||
@@ -1857,7 +1873,7 @@ export function ParamOverrideEditorDialog(
|
||||
<span
|
||||
key={`mode_stat_${mode}`}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-medium',
|
||||
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
|
||||
getModeTagTailwind(mode)
|
||||
)}
|
||||
>
|
||||
@@ -1874,7 +1890,7 @@ export function ParamOverrideEditorDialog(
|
||||
value={operationSearch}
|
||||
onChange={(e) => setOperationSearch(e.target.value)}
|
||||
placeholder={t('Search rules...')}
|
||||
className='pl-8 text-xs'
|
||||
className='h-8 pl-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1943,21 +1959,24 @@ export function ParamOverrideEditorDialog(
|
||||
<span className='text-xs font-semibold'>
|
||||
#{index + 1}
|
||||
</span>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className='text-[10px]'
|
||||
>
|
||||
{operation.conditions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-xs'>
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-1 text-[11px]'>
|
||||
{getOperationSummary(operation, index)}
|
||||
</p>
|
||||
{operation.description.trim() && (
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-xs'>
|
||||
<p className='text-muted-foreground mt-0.5 line-clamp-2 text-[10px]'>
|
||||
{operation.description}
|
||||
</p>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-medium',
|
||||
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
|
||||
getModeTagTailwind(operation.mode || 'set')
|
||||
)}
|
||||
>
|
||||
@@ -2026,7 +2045,12 @@ export function ParamOverrideEditorDialog(
|
||||
/* JSON mode */
|
||||
<div className='p-4'>
|
||||
<div className='mb-2 flex items-center gap-2'>
|
||||
<Button type='button' variant='outline' onClick={formatJson}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={formatJson}
|
||||
>
|
||||
{t('Format')}
|
||||
</Button>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
@@ -2130,6 +2154,7 @@ 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' />
|
||||
@@ -2138,6 +2163,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive'
|
||||
onClick={() => ruleEditorProps.removeOperation(operation.id)}
|
||||
>
|
||||
@@ -2166,7 +2192,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className='h-9'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -2193,7 +2219,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
})
|
||||
}
|
||||
placeholder={getModePathPlaceholder(mode)}
|
||||
className=''
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2212,7 +2238,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
<label className='text-xs font-medium'>
|
||||
{t('Rule Description (optional)')}
|
||||
</label>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
<span className='text-muted-foreground text-[10px]'>
|
||||
{operation.description.length}/180
|
||||
</span>
|
||||
</div>
|
||||
@@ -2227,7 +2253,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
'e.g. Clean tool parameters to avoid upstream validation errors'
|
||||
)}
|
||||
maxLength={180}
|
||||
className=''
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2258,7 +2284,8 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs sm:h-auto'
|
||||
size='sm'
|
||||
className='text-muted-foreground h-auto px-1.5 py-0.5 text-xs'
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(operation.value_text)
|
||||
@@ -2328,7 +2355,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
})
|
||||
}
|
||||
placeholder={getModeFromPlaceholder(mode)}
|
||||
className=''
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2345,7 +2372,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
})
|
||||
}
|
||||
placeholder={getModeToPlaceholder(mode)}
|
||||
className=''
|
||||
className='h-9'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -2370,7 +2397,7 @@ function RuleEditor(ruleEditorProps: RuleEditorProps) {
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[120px]'>
|
||||
<SelectTrigger className='h-7 w-[120px] text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -2387,6 +2414,8 @@ 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' />
|
||||
@@ -2395,6 +2424,8 @@ 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' />
|
||||
@@ -2405,6 +2436,8 @@ 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' />
|
||||
@@ -2476,7 +2509,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
<div className='rounded-md border'>
|
||||
<CollapsibleTrigger className='hover:bg-muted/50 flex w-full items-center justify-between px-3 py-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
C{conditionEditorProps.conditionIndex + 1}
|
||||
</Badge>
|
||||
<span className='text-muted-foreground text-xs'>
|
||||
@@ -2498,7 +2531,8 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive h-7 text-xs'
|
||||
onClick={() =>
|
||||
conditionEditorProps.removeCondition(
|
||||
conditionEditorProps.operationId,
|
||||
@@ -2512,7 +2546,9 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-3'>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-xs font-medium'>{t('Field Path')}</label>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Field Path')}
|
||||
</label>
|
||||
<Input
|
||||
value={condition.path}
|
||||
onChange={(e) =>
|
||||
@@ -2523,11 +2559,13 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='model'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-xs font-medium'>{t('Match Mode')}</label>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Match Mode')}
|
||||
</label>
|
||||
<Select
|
||||
items={[
|
||||
...CONDITION_MODE_OPTIONS.map((o) => ({
|
||||
@@ -2545,7 +2583,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className='h-8 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -2560,7 +2598,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<label className='text-xs font-medium'>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Match Value')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -2573,7 +2611,7 @@ function ConditionEditor(conditionEditorProps: ConditionEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='gpt'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2640,6 +2678,8 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.simpleMode ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
returnErrorEditorProps.updateDraft(
|
||||
returnErrorEditorProps.operationId,
|
||||
@@ -2652,6 +2692,8 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.simpleMode ? 'outline' : 'default'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
returnErrorEditorProps.updateDraft(
|
||||
returnErrorEditorProps.operationId,
|
||||
@@ -2702,7 +2744,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='400'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
@@ -2718,7 +2760,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='forced_bad_request'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
@@ -2734,7 +2776,7 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='invalid_request_error'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2745,6 +2787,8 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.skipRetry ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
returnErrorEditorProps.updateDraft(
|
||||
returnErrorEditorProps.operationId,
|
||||
@@ -2757,6 +2801,8 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.skipRetry ? 'outline' : 'default'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
returnErrorEditorProps.updateDraft(
|
||||
returnErrorEditorProps.operationId,
|
||||
@@ -2792,6 +2838,8 @@ function ReturnErrorEditor(returnErrorEditorProps: ReturnErrorEditorProps) {
|
||||
key={preset.code}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 text-[10px]'
|
||||
onClick={() =>
|
||||
returnErrorEditorProps.updateDraft(
|
||||
returnErrorEditorProps.operationId,
|
||||
@@ -2848,6 +2896,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.simpleMode ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.updateDraft(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
@@ -2860,6 +2910,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.simpleMode ? 'outline' : 'default'}
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.updateDraft(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
@@ -2883,7 +2935,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='redacted_thinking'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2909,7 +2961,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className='h-8 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -2930,6 +2982,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.recursive ? 'default' : 'outline'}
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.updateDraft(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
@@ -2942,6 +2996,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant={draft.recursive ? 'outline' : 'default'}
|
||||
size='sm'
|
||||
className='h-8 text-xs'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.updateDraft(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
@@ -2963,6 +3019,8 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.addRule(
|
||||
pruneObjectsEditorProps.operationId
|
||||
@@ -2987,13 +3045,14 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
className='bg-background rounded-md border p-2'
|
||||
>
|
||||
<div className='mb-1 flex items-center justify-between'>
|
||||
<Badge variant='outline' className='text-xs'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
R{ruleIndex + 1}
|
||||
</Badge>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='text-destructive hover:text-destructive'
|
||||
size='sm'
|
||||
className='text-destructive hover:text-destructive h-6 text-[10px]'
|
||||
onClick={() =>
|
||||
pruneObjectsEditorProps.removeRule(
|
||||
pruneObjectsEditorProps.operationId,
|
||||
@@ -3007,7 +3066,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
</div>
|
||||
<div className='grid gap-2 sm:grid-cols-3'>
|
||||
<div className='space-y-0.5'>
|
||||
<label className='text-xs font-medium'>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Field Path')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -3020,11 +3079,11 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='type'
|
||||
className='text-xs'
|
||||
className='h-7 text-xs'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-0.5'>
|
||||
<label className='text-xs font-medium'>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Match Mode')}
|
||||
</label>
|
||||
<Select
|
||||
@@ -3044,7 +3103,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className='h-7 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -3059,7 +3118,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-0.5'>
|
||||
<label className='text-xs font-medium'>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Match Value (optional)')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -3072,12 +3131,12 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='redacted_thinking'
|
||||
className='text-xs'
|
||||
className='h-7 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-1.5 flex flex-wrap gap-3'>
|
||||
<label className='flex items-center gap-1.5 text-xs'>
|
||||
<label className='flex items-center gap-1.5 text-[10px]'>
|
||||
<Switch
|
||||
checked={rule.invert}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -3090,7 +3149,7 @@ function PruneObjectsEditor(pruneObjectsEditorProps: PruneObjectsEditorProps) {
|
||||
/>
|
||||
{t('Invert match')}
|
||||
</label>
|
||||
<label className='flex items-center gap-1.5 text-xs'>
|
||||
<label className='flex items-center gap-1.5 text-[10px]'>
|
||||
<Switch
|
||||
checked={rule.pass_missing_key}
|
||||
onCheckedChange={(checked) =>
|
||||
@@ -3136,7 +3195,9 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
<label className='text-xs font-medium'>{t('Sync Endpoints')}</label>
|
||||
<div className='grid gap-3 sm:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<label className='text-xs font-medium'>{t('Source Endpoint')}</label>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Source Endpoint')}
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
items={[
|
||||
@@ -3159,7 +3220,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[110px]'>
|
||||
<SelectTrigger className='h-8 w-[110px] text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -3186,12 +3247,14 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='session_id'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<label className='text-xs font-medium'>{t('Target Endpoint')}</label>
|
||||
<label className='text-[10px] font-medium'>
|
||||
{t('Target Endpoint')}
|
||||
</label>
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
items={[
|
||||
@@ -3214,7 +3277,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='w-[110px]'>
|
||||
<SelectTrigger className='h-8 w-[110px] text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -3241,7 +3304,7 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
)
|
||||
}
|
||||
placeholder='prompt_cache_key'
|
||||
className='text-xs'
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3263,6 +3326,8 @@ function SyncFieldsEditor(syncFieldsEditorProps: SyncFieldsEditorProps) {
|
||||
key={preset.label}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-6 text-[10px]'
|
||||
onClick={() =>
|
||||
syncFieldsEditorProps.updateOperation(
|
||||
syncFieldsEditorProps.operationId,
|
||||
|
||||
+3
-3
@@ -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 {
|
||||
@@ -88,7 +88,7 @@ export function StatusCodeRiskDialog({
|
||||
</>
|
||||
}
|
||||
description={t('High-risk status code retry risk disclaimer')}
|
||||
contentClassName='sm:max-w-lg'
|
||||
contentClassName='max-w-lg'
|
||||
titleClassName='text-destructive flex items-center gap-2'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
|
||||
+3
-3
@@ -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'
|
||||
@@ -195,7 +195,7 @@ export function TagBatchEditDialog({
|
||||
<strong>{currentTag}</strong>
|
||||
</>
|
||||
}
|
||||
contentClassName='sm:max-w-2xl'
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
footer={
|
||||
|
||||
+9
-14
@@ -21,18 +21,13 @@ 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
|
||||
@@ -102,8 +97,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
const handleConfirm = () => {
|
||||
const hasAdd = props.addModels.length > 0
|
||||
const hasRemove = props.removeModels.length > 0
|
||||
const selectedAddArr = [...selectedAdd]
|
||||
const selectedRemoveArr = [...selectedRemove]
|
||||
const selectedAddArr = Array.from(selectedAdd)
|
||||
const selectedRemoveArr = Array.from(selectedRemove)
|
||||
const anyAdd = selectedAddArr.length > 0
|
||||
const anyRemove = selectedRemoveArr.length > 0
|
||||
|
||||
@@ -157,13 +152,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'>
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedAdd.size}/{props.addModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value='remove' className='gap-1'>
|
||||
{t('Remove Models')}
|
||||
<StatusBadge variant='neutral' className='ml-1'>
|
||||
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
|
||||
{selectedRemove.size}/{props.removeModels.length}
|
||||
</StatusBadge>
|
||||
</TabsTrigger>
|
||||
@@ -287,8 +282,8 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
|
||||
handleConfirm={() => {
|
||||
setPartialConfirmOpen(false)
|
||||
props.onConfirm({
|
||||
addModels: [...selectedAdd],
|
||||
removeModels: [...selectedRemove],
|
||||
addModels: Array.from(selectedAdd),
|
||||
removeModels: Array.from(selectedRemove),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
+46
-20
@@ -55,17 +55,6 @@ 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,
|
||||
@@ -78,6 +67,8 @@ 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,
|
||||
@@ -87,6 +78,15 @@ 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,6 +1847,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='shrink-0'
|
||||
onClick={pasteConnectionInfoFromClipboard}
|
||||
>
|
||||
@@ -1858,7 +1859,7 @@ export function ChannelMutateDrawer({
|
||||
</SheetHeader>
|
||||
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
'Sensitive channel settings are read-only for your account.'
|
||||
@@ -1877,6 +1878,7 @@ export function ChannelMutateDrawer({
|
||||
<span className='flex shrink-0 gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
size='sm'
|
||||
onClick={() => applyConnectionInfo(clipboardConnectionInfo)}
|
||||
>
|
||||
{t('Fill in')}
|
||||
@@ -1884,6 +1886,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => setClipboardConnectionInfo(null)}
|
||||
>
|
||||
{t('Ignore')}
|
||||
@@ -2074,7 +2077,7 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t('No permission to perform this action')}
|
||||
</AlertDescription>
|
||||
@@ -2803,6 +2806,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setAdvancedCustomEditorOpen(true)
|
||||
}
|
||||
@@ -2839,7 +2843,10 @@ export function ChannelMutateDrawer({
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className='w-full sm:w-56'>
|
||||
<SelectTrigger
|
||||
size='sm'
|
||||
className='w-full sm:w-56'
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
@@ -2953,6 +2960,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDeduplicateKeys}
|
||||
className='w-fit'
|
||||
>
|
||||
@@ -2979,6 +2987,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRevealKey}
|
||||
disabled={
|
||||
isChannelKeyLoading ||
|
||||
@@ -2996,6 +3005,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={async () => {
|
||||
if (channelKey) {
|
||||
await copyToClipboard(
|
||||
@@ -3039,6 +3049,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
disabled={
|
||||
sensitiveLocked ||
|
||||
@@ -3057,7 +3068,7 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"Disclaimer: Personal use only. Do not distribute or share any credentials. This channel has prerequisites and requires prior setup; use it only if you understand the flow and risks, and comply with OpenAI's terms and policies. Credentials and configuration are for Codex CLI integration only, and are not intended for any other client, platform, or channel."
|
||||
@@ -3233,7 +3244,7 @@ export function ChannelMutateDrawer({
|
||||
</FormControl>
|
||||
{modelMappingGuardrail.exposedTargetModels
|
||||
.length > 0 && (
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span>
|
||||
{t('The mapped upstream model(s)')}{' '}
|
||||
@@ -3247,6 +3258,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const hiddenTargets = new Set(
|
||||
modelMappingGuardrail.exposedTargetModels
|
||||
@@ -3286,6 +3298,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFillRelatedModels}
|
||||
disabled={!basicModels.length}
|
||||
>
|
||||
@@ -3298,6 +3311,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFillAllModels}
|
||||
disabled={!allModelsList.length}
|
||||
>
|
||||
@@ -3312,6 +3326,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleFetchModels}
|
||||
disabled={!isEditing && !canEditSensitive}
|
||||
>
|
||||
@@ -3333,6 +3348,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleCopyModels}
|
||||
disabled={currentModelsArray.length === 0}
|
||||
>
|
||||
@@ -3345,6 +3361,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={handleClearModels}
|
||||
disabled={currentModelsArray.length === 0}
|
||||
>
|
||||
@@ -3365,6 +3382,7 @@ export function ChannelMutateDrawer({
|
||||
key={group.id}
|
||||
type='button'
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
handleAddPrefillGroup(group)
|
||||
}
|
||||
@@ -3433,7 +3451,7 @@ export function ChannelMutateDrawer({
|
||||
)
|
||||
)}
|
||||
{remainingMappingCount > 0 && (
|
||||
<div className='text-xs opacity-70'>
|
||||
<div className='text-[11px] opacity-70'>
|
||||
+{remainingMappingCount}{' '}
|
||||
{t('more mapping')}
|
||||
{remainingMappingCount > 1
|
||||
@@ -3442,7 +3460,7 @@ export function ChannelMutateDrawer({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-xs leading-relaxed opacity-80'>
|
||||
<p className='text-[11px] leading-relaxed opacity-80'>
|
||||
{t(
|
||||
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
||||
)}
|
||||
@@ -3483,7 +3501,7 @@ export function ChannelMutateDrawer({
|
||||
)}
|
||||
{modelMappingGuardrail.missingSourceModels
|
||||
.length > 0 && (
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span>
|
||||
{t('Add')}{' '}
|
||||
@@ -3497,6 +3515,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
updateModels([
|
||||
...currentModelsArray,
|
||||
@@ -3806,6 +3825,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
setParamOverrideEditorOpen(true)
|
||||
}
|
||||
@@ -3816,6 +3836,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
field.onChange(
|
||||
JSON.stringify(
|
||||
@@ -3848,6 +3869,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => field.onChange('')}
|
||||
>
|
||||
{t('Clear')}
|
||||
@@ -3891,6 +3913,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
JSON.stringify(
|
||||
@@ -3913,6 +3936,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() =>
|
||||
field.onChange(
|
||||
JSON.stringify(
|
||||
@@ -3928,6 +3952,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
@@ -3946,6 +3971,7 @@ export function ChannelMutateDrawer({
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={() => field.onChange('')}
|
||||
>
|
||||
{t('Clear')}
|
||||
@@ -4000,7 +4026,7 @@ export function ChannelMutateDrawer({
|
||||
icon={<Settings className='h-4 w-4' />}
|
||||
/>
|
||||
{sensitiveLocked && (
|
||||
<Alert className='border-warning/40 bg-warning/10'>
|
||||
<Alert className='border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
||||
<AlertDescription>
|
||||
{t('No permission to perform this action')}
|
||||
</AlertDescription>
|
||||
|
||||
@@ -20,15 +20,10 @@ 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'
|
||||
|
||||
@@ -242,7 +237,8 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='link'
|
||||
className='h-auto p-0 sm:h-auto'
|
||||
size='sm'
|
||||
className='h-auto p-0'
|
||||
onClick={handleFillTemplate}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
@@ -303,6 +299,7 @@ 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' />
|
||||
@@ -320,6 +317,7 @@ export function ModelMappingEditor(props: ModelMappingEditorProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleAddRow}
|
||||
disabled={props.disabled}
|
||||
className='w-full'
|
||||
|
||||
@@ -96,7 +96,7 @@ export function NumericSpinnerInput({
|
||||
const commitValue = () => {
|
||||
setEditing(false)
|
||||
const num = Number(localValue)
|
||||
if (Number.isNaN(num) || localValue === '' || localValue === '-') {
|
||||
if (isNaN(num) || localValue === '' || localValue === '-') {
|
||||
setLocalValue(String(value ?? 0))
|
||||
return
|
||||
}
|
||||
@@ -167,7 +167,7 @@ export function NumericSpinnerInput({
|
||||
disabled={disabled}
|
||||
title={localValue}
|
||||
className={cn(
|
||||
'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center text-sm tabular-nums',
|
||||
'h-7 min-w-8 max-w-16 cursor-text truncate px-1 text-center font-mono text-sm tabular-nums',
|
||||
disabled && 'cursor-default opacity-50'
|
||||
)}
|
||||
>
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ export const CHANNEL_STATUS_CONFIG = {
|
||||
label: 'Enabled',
|
||||
},
|
||||
[CHANNEL_STATUS.MANUAL_DISABLED]: {
|
||||
variant: 'destructive' as const,
|
||||
variant: 'danger' 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: 'destructive' as const,
|
||||
variant: 'danger' 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: 'destructive' as const, label: 'Poor' },
|
||||
POOR: { variant: 'danger' as const, label: 'Poor' },
|
||||
UNKNOWN: { variant: 'neutral' as const, label: 'Not tested' },
|
||||
} as const
|
||||
|
||||
|
||||
+2
-2
@@ -340,12 +340,12 @@ export function formatBalance(balance: number | null | undefined): string {
|
||||
*/
|
||||
export function getBalanceVariant(
|
||||
balance: number
|
||||
): 'success' | 'warning' | 'destructive' | 'neutral' {
|
||||
): 'success' | 'warning' | 'danger' | 'neutral' {
|
||||
if (balance === 0) {
|
||||
return 'neutral'
|
||||
}
|
||||
if (balance < 1) {
|
||||
return 'destructive'
|
||||
return 'danger'
|
||||
}
|
||||
if (balance < 10) {
|
||||
return 'warning'
|
||||
|
||||
@@ -41,8 +41,6 @@ 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 {
|
||||
@@ -53,6 +51,8 @@ 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,7 +20,8 @@ import { Filter, X } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -29,8 +30,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/design-system/command'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -108,6 +108,7 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
aria-label={t('Filter by node')}
|
||||
/>
|
||||
}
|
||||
@@ -115,7 +116,9 @@ 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'>{selectedCount}</Badge>
|
||||
<Badge variant='secondary' className='rounded-sm px-1'>
|
||||
{selectedCount}
|
||||
</Badge>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -198,14 +201,14 @@ export function FlowNodeFilterControl(props: FlowNodeFilterControlProps) {
|
||||
<Badge
|
||||
key={flowNodeFilterKey(option)}
|
||||
variant='secondary'
|
||||
className='max-w-[14rem] pr-1'
|
||||
className='max-w-[14rem] rounded-sm pr-1'
|
||||
>
|
||||
<span className='truncate'>
|
||||
{t(props.stageLabels[option.kind])}: {option.label}
|
||||
</span>
|
||||
<button
|
||||
type='button'
|
||||
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'
|
||||
className='hover:bg-muted-foreground/15 flex size-4 shrink-0 items-center justify-center rounded-sm'
|
||||
aria-label={t('Remove node filter')}
|
||||
onClick={() =>
|
||||
props.onRemoveNode({ kind: option.kind, id: option.id })
|
||||
|
||||
@@ -169,7 +169,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
||||
</div>
|
||||
) : error ? (
|
||||
<>
|
||||
<div className='text-muted-foreground mt-1.5 text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
|
||||
<div className='text-muted-foreground mt-1.5 font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'>
|
||||
--
|
||||
</div>
|
||||
<div className='text-muted-foreground/40 mt-1 hidden text-xs md:block'>
|
||||
@@ -179,7 +179,7 @@ export function LogStatCards(props: LogStatCardsProps) {
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className='text-foreground mt-1.5 max-w-full truncate text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'
|
||||
className='text-foreground mt-1.5 max-w-full truncate font-mono text-lg font-bold tracking-tight tabular-nums sm:mt-2 sm:text-2xl'
|
||||
title={it.fullValue}
|
||||
>
|
||||
{it.value}
|
||||
|
||||
+5
-5
@@ -20,7 +20,9 @@ import { Save, Settings2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -28,9 +30,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
} from '@/components/ui/select'
|
||||
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'>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Settings2 className='mr-2 h-4 w-4' />
|
||||
{t('Preferences')}
|
||||
</Button>
|
||||
|
||||
+8
-7
@@ -21,8 +21,11 @@ import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DateTimePicker } from '@/components/datetime-picker'
|
||||
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 { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,10 +33,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
TIME_GRANULARITY_OPTIONS,
|
||||
TIME_RANGE_PRESETS,
|
||||
@@ -171,7 +171,7 @@ export function ModelsFilter(props: ModelsFilterProps) {
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
trigger={
|
||||
<Button variant='outline'>
|
||||
<Button variant='outline' size='sm'>
|
||||
<Filter className='mr-2 h-4 w-4' />
|
||||
{t('Filter')}
|
||||
</Button>
|
||||
@@ -210,6 +210,7 @@ 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(
|
||||
|
||||
+24
-25
@@ -21,14 +21,13 @@ 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,
|
||||
getSuccessRateLevel,
|
||||
getSuccessRateDotClass,
|
||||
getSuccessRateTextClass,
|
||||
} from '@/features/performance-metrics/lib/format'
|
||||
import type { PerfModelSummary } from '@/features/performance-metrics/types'
|
||||
@@ -36,11 +35,6 @@ 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'
|
||||
|
||||
@@ -66,7 +60,7 @@ function simpleAverage(
|
||||
count++
|
||||
}
|
||||
|
||||
return count > 0 ? total / count : Number.NaN
|
||||
return count > 0 ? total / count : NaN
|
||||
}
|
||||
|
||||
function buildPerformanceSummary(rows: PerfModelSummary[]): PerformanceSummary {
|
||||
@@ -134,8 +128,8 @@ export function PerformanceOverview() {
|
||||
{/* 3 KPI inline metrics */}
|
||||
{loading ? (
|
||||
<div className='flex flex-wrap items-center gap-x-5 gap-y-2'>
|
||||
{METRIC_SKELETON_KEYS.map((key) => (
|
||||
<div key={key} className='flex items-center gap-1.5'>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className='flex items-center gap-1.5'>
|
||||
<Skeleton className='h-3 w-14' />
|
||||
<Skeleton className='h-4 w-16' />
|
||||
</div>
|
||||
@@ -192,10 +186,10 @@ function InlineMetric(props: {
|
||||
className='text-muted-foreground/50 size-3 shrink-0'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs'>{props.label}</span>
|
||||
<span className='text-muted-foreground text-[11px]'>{props.label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold tabular-nums',
|
||||
'font-mono text-xs font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
@@ -207,22 +201,27 @@ 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 (
|
||||
<StatusBadge variant={variant}>
|
||||
<span className='mr-1 whitespace-nowrap'>{model.model_name}</span>
|
||||
<span className='tabular-nums'>
|
||||
<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-[11px]'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
getSuccessRateDotClass(model.success_rate)
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono text-[11px] font-semibold tabular-nums',
|
||||
getSuccessRateTextClass(model.success_rate)
|
||||
)}
|
||||
>
|
||||
{formatUptimePct(model.success_rate)}
|
||||
</span>
|
||||
</StatusBadge>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,9 +20,12 @@ import { Zap, ExternalLink, Gauge } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { CopyButton } from '@/components/copy-button'
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { StatusBadge, type StatusVariant } from '@/components/status-badge'
|
||||
import { openExternalSpeedTest } from '@/features/dashboard/lib/api-info'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
getLatencyColorClass,
|
||||
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'
|
||||
@@ -37,12 +40,6 @@ 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'>
|
||||
@@ -72,25 +69,36 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
<div className='flex shrink-0 items-center gap-2'>
|
||||
<div className='flex items-center'>
|
||||
{status.testing && (
|
||||
<StatusBadge variant='warning'>{t('Testing...')}</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Testing...')}
|
||||
variant='warning'
|
||||
className='animate-pulse'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{status.latency !== null && !status.testing && (
|
||||
<StatusBadge variant={latencyVariant}>
|
||||
{status.latency}
|
||||
{t('ms')}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
variant='success'
|
||||
label={`${status.latency}${t('ms')}`}
|
||||
className={cn(
|
||||
'font-mono font-medium',
|
||||
getLatencyColorClass(status.latency)
|
||||
)}
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
{status.error && (
|
||||
<StatusBadge variant='neutral'>{t('N/A')}</StatusBadge>
|
||||
<StatusBadge label={t('N/A')} variant='neutral' copyable={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-0.5'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
onClick={() => props.onTest(item.url)}
|
||||
disabled={status.testing}
|
||||
className='size-7 p-0'
|
||||
title={t('Test Latency')}
|
||||
>
|
||||
<Zap
|
||||
@@ -100,9 +108,9 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
onClick={() => openExternalSpeedTest(item.url)}
|
||||
className='hidden sm:inline-flex'
|
||||
className='hidden size-7 p-0 sm:inline-flex'
|
||||
title={t('External Speed Test')}
|
||||
>
|
||||
<Gauge className='size-3.5' />
|
||||
@@ -111,7 +119,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
<CopyButton
|
||||
value={item.url}
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
className='size-7 p-0'
|
||||
iconClassName='size-3.5'
|
||||
tooltip={t('Copy URL')}
|
||||
aria-label={t('Copy URL')}
|
||||
@@ -119,8 +128,8 @@ export function ApiInfoItemComponent(props: ApiInfoItemProps) {
|
||||
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='hidden sm:inline-flex'
|
||||
size='sm'
|
||||
className='hidden size-7 p-0 sm:inline-flex'
|
||||
title={t('Open in New Tab')}
|
||||
render={<a href={item.url} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
|
||||
+698
-1
@@ -16,15 +16,49 @@ 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 { motion, useReducedMotion } from 'motion/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 { MOTION_TRANSITION } from '@/lib/motion'
|
||||
import { ROLE } from '@/lib/roles'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
|
||||
import { useDashboardContentVisibility } from '../../hooks/use-status-data'
|
||||
import {
|
||||
useApiInfo,
|
||||
useDashboardContentVisibility,
|
||||
} from '../../hooks/use-status-data'
|
||||
import { AnnouncementsPanel } from './announcements-panel'
|
||||
import { ApiInfoPanel } from './api-info-panel'
|
||||
import { FAQPanel } from './faq-panel'
|
||||
@@ -32,22 +66,685 @@ 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-[11px] 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 font-mono 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 shouldReduceMotion = useReducedMotion()
|
||||
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 (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={shouldReduceMotion ? undefined : { opacity: 1, y: 0, scale: 1 }}
|
||||
transition={MOTION_TRANSITION.slow}
|
||||
className='bg-background/75 relative overflow-hidden rounded-2xl border p-3 shadow-sm backdrop-blur'
|
||||
>
|
||||
{!shouldReduceMotion && (
|
||||
<motion.div
|
||||
className='via-foreground/30 pointer-events-none absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent to-transparent'
|
||||
animate={{ x: ['-100%', '100%'] }}
|
||||
transition={{ duration: 3.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</motion.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 && (
|
||||
|
||||
+5
-5
@@ -135,7 +135,7 @@ export function PerformanceHealthPanel() {
|
||||
) : (
|
||||
hasData && (
|
||||
<div>
|
||||
<span className='text-muted-foreground mb-1 block text-xs font-medium'>
|
||||
<span className='text-muted-foreground mb-1 block text-[11px] font-medium'>
|
||||
{t('Top models by traffic')}
|
||||
</span>
|
||||
<div className='grid grid-cols-1 gap-x-4 sm:grid-cols-2'>
|
||||
@@ -144,7 +144,7 @@ export function PerformanceHealthPanel() {
|
||||
key={model.model_name}
|
||||
className='flex items-center justify-between gap-2 rounded px-1.5 py-1'
|
||||
>
|
||||
<span className='min-w-0 flex-1 truncate font-mono text-xs'>
|
||||
<span className='min-w-0 flex-1 truncate font-mono text-[11px]'>
|
||||
{model.model_name}
|
||||
</span>
|
||||
<span className='inline-flex shrink-0 items-center gap-1'>
|
||||
@@ -157,7 +157,7 @@ export function PerformanceHealthPanel() {
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold tabular-nums',
|
||||
'font-mono text-[11px] font-semibold tabular-nums',
|
||||
getSuccessRateTextClass(model.success_rate)
|
||||
)}
|
||||
>
|
||||
@@ -185,7 +185,7 @@ function MetricCell(props: {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div className='bg-muted/40 rounded-xl px-3 py-2.5'>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-xs font-medium'>
|
||||
<div className='text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium'>
|
||||
<Icon className='size-3 shrink-0' aria-hidden='true' />
|
||||
<span className='truncate'>{props.label}</span>
|
||||
</div>
|
||||
@@ -194,7 +194,7 @@ function MetricCell(props: {
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1.5 text-sm font-semibold tabular-nums',
|
||||
'mt-1.5 font-mono text-sm font-semibold tabular-nums',
|
||||
props.valueClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
@@ -281,7 +281,7 @@ export function SummaryCards() {
|
||||
className={cn('size-1.5 rounded-full', healthCfg.dotClass)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<span className='text-muted-foreground text-xs font-medium'>
|
||||
<span className='text-muted-foreground text-[11px] font-medium'>
|
||||
{t(healthCfg.labelKey)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -293,7 +293,7 @@ export function SummaryCards() {
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<div className='bg-background/60 rounded-lg px-2.5 py-2'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-xs leading-none font-medium'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-[11px] leading-none font-medium'>
|
||||
<Flame className='size-3 shrink-0' aria-hidden='true' />
|
||||
<span className='truncate'>{t('Last 24h usage')}</span>
|
||||
</div>
|
||||
@@ -302,7 +302,7 @@ export function SummaryCards() {
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-background/60 rounded-lg px-2.5 py-2'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-xs leading-none font-medium'>
|
||||
<div className='text-muted-foreground flex items-center gap-1 text-[11px] leading-none font-medium'>
|
||||
{runwayDays !== null && runwayDays < 3 ? (
|
||||
<TrendingDown
|
||||
className='size-3 shrink-0'
|
||||
|
||||
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { getUptimeStatus } from '@/features/dashboard/api'
|
||||
import type {
|
||||
@@ -32,10 +32,10 @@ import { cn } from '@/lib/utils'
|
||||
import { PanelWrapper } from '../ui/panel-wrapper'
|
||||
|
||||
const STATUS_COLOR_MAP: Record<number, string> = {
|
||||
1: 'bg-success',
|
||||
0: 'bg-destructive',
|
||||
2: 'bg-warning',
|
||||
3: 'bg-info',
|
||||
1: 'bg-emerald-500',
|
||||
0: 'bg-red-500',
|
||||
2: 'bg-amber-500',
|
||||
3: 'bg-blue-500',
|
||||
}
|
||||
const DEFAULT_STATUS_COLOR = 'bg-muted-foreground/40'
|
||||
|
||||
@@ -110,9 +110,10 @@ export function UptimePanel() {
|
||||
headerActions={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
className='size-7 p-0'
|
||||
>
|
||||
<RotateCw
|
||||
className={cn('size-3.5', refreshing && 'animate-spin')}
|
||||
@@ -130,7 +131,7 @@ export function UptimePanel() {
|
||||
<h4 className='text-muted-foreground text-xs font-semibold tracking-wider uppercase'>
|
||||
{group.categoryName}
|
||||
</h4>
|
||||
<span className='text-muted-foreground/40 text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground/40 font-mono text-xs tabular-nums'>
|
||||
{group.monitors?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
@@ -158,7 +159,7 @@ export function UptimePanel() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className='text-foreground shrink-0 text-sm font-semibold tabular-nums'>
|
||||
<span className='text-foreground shrink-0 font-mono text-sm font-semibold tabular-nums'>
|
||||
{((monitor.uptime ?? 0) * 100).toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -42,9 +42,7 @@ function PanelHeader(props: {
|
||||
}) {
|
||||
const heading = (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-base font-semibold tracking-tight'>
|
||||
{props.title}
|
||||
</div>
|
||||
<div className='text-sm font-semibold'>{props.title}</div>
|
||||
{props.description != null && (
|
||||
<div className='text-muted-foreground text-xs'>{props.description}</div>
|
||||
)}
|
||||
@@ -70,7 +68,7 @@ export function PanelWrapper(props: PanelWrapperProps) {
|
||||
const resolvedEmptyMessage = props.emptyMessage ?? t('No data available')
|
||||
const height = props.height ?? 'h-64'
|
||||
const frameClassName = cn(
|
||||
'overflow-hidden rounded-xl border bg-card',
|
||||
'overflow-hidden rounded-2xl border bg-card shadow-xs',
|
||||
props.className
|
||||
)
|
||||
|
||||
|
||||
@@ -51,17 +51,15 @@ interface StatCardProps {
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
/* Sparklines are data visualization, so they draw from chart tokens — solid
|
||||
* fills, no decorative gradients, consistent with the dashboard charts. */
|
||||
const TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'bg-chart-5/60',
|
||||
teal: 'bg-chart-2/60',
|
||||
gray: 'bg-muted-foreground/30',
|
||||
rose: 'from-rose-500/80 via-rose-300/70 to-rose-200/20 dark:from-rose-400/70 dark:via-rose-500/30 dark:to-rose-500/5',
|
||||
teal: 'from-teal-500/80 via-teal-300/70 to-teal-200/20 dark:from-teal-400/70 dark:via-teal-500/30 dark:to-teal-500/5',
|
||||
gray: 'from-muted-foreground/50 via-muted-foreground/20 to-transparent dark:from-muted-foreground/40 dark:via-muted-foreground/20',
|
||||
}
|
||||
|
||||
const LINE_TONE_CLASSES: Record<StatCardTone, string> = {
|
||||
rose: 'text-chart-5',
|
||||
teal: 'text-chart-2',
|
||||
rose: 'text-warning',
|
||||
teal: 'text-primary',
|
||||
gray: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
@@ -168,7 +166,7 @@ function BarSparkline(props: { values?: number[]; tone: StatCardTone }) {
|
||||
<span
|
||||
key={`spark-${index}`}
|
||||
className={cn(
|
||||
'flex-1 rounded-t-sm',
|
||||
'flex-1 rounded-t-sm bg-linear-to-t',
|
||||
height <= 0 && 'opacity-20',
|
||||
TONE_CLASSES[props.tone]
|
||||
)}
|
||||
@@ -187,7 +185,7 @@ function StatCardDetails(props: { details: StatCardDetail[] }) {
|
||||
key={detail.label}
|
||||
className='bg-muted/40 rounded-lg border border-transparent px-2.5 py-2'
|
||||
>
|
||||
<div className='text-muted-foreground truncate text-xs leading-none font-medium'>
|
||||
<div className='text-muted-foreground truncate text-[11px] leading-none font-medium'>
|
||||
{detail.label}
|
||||
</div>
|
||||
<div
|
||||
@@ -230,7 +228,7 @@ export function StatCard(props: StatCardProps) {
|
||||
</div>
|
||||
) : props.error ? (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-muted-foreground mt-0.5 text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
<div className='text-muted-foreground mt-0.5 font-mono text-base font-bold tracking-tight break-all tabular-nums sm:text-2xl'>
|
||||
--
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
@@ -239,7 +237,7 @@ export function StatCard(props: StatCardProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className='text-foreground text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
<div className='text-foreground font-mono text-2xl font-semibold tracking-tight break-all tabular-nums'>
|
||||
{props.value}
|
||||
</div>
|
||||
<p className='text-muted-foreground/60 text-xs leading-relaxed'>
|
||||
|
||||
@@ -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
@@ -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'
|
||||
className='text-muted-foreground hover:text-foreground size-8'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -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 sm:group-data-horizontal/tabs:h-auto'>
|
||||
<TabsList className='max-w-full flex-wrap justify-start group-data-horizontal/tabs:h-auto'>
|
||||
{visibleSections.map((section) => (
|
||||
<TabsTrigger key={section} value={section}>
|
||||
{t(SECTION_META[section].titleKey)}
|
||||
|
||||
+4
-4
@@ -23,12 +23,12 @@ import type { PingStatus } from '@/features/dashboard/types'
|
||||
*/
|
||||
export function getLatencyColorClass(latency: number): string {
|
||||
if (latency < 200) {
|
||||
return 'text-status-success'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
if (latency < 500) {
|
||||
return 'text-status-warning'
|
||||
return 'text-yellow-600 dark:text-yellow-400'
|
||||
}
|
||||
return 'text-status-destructive'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,7 @@ export async function testUrlLatency(url: string): Promise<PingStatus> {
|
||||
const latency = Math.round(endTime - startTime)
|
||||
|
||||
return { latency, testing: false, error: false }
|
||||
} catch {
|
||||
} catch (_error) {
|
||||
return { latency: null, testing: false, error: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
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')
|
||||
|
||||
+13
-1
@@ -16,7 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import type { FlowQuotaDataItem } from '@/features/dashboard/types'
|
||||
import type {
|
||||
FlowQuotaDataItem,
|
||||
FlowUserFilterOption,
|
||||
} from '@/features/dashboard/types'
|
||||
|
||||
export type FlowDisplayState = 'loading' | 'error' | 'empty' | 'chart'
|
||||
|
||||
@@ -52,3 +55,12 @@ 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))
|
||||
}
|
||||
|
||||
+6
-4
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'node:test'
|
||||
|
||||
import type { FlowQuotaDataItem } from '../types'
|
||||
import { buildDashboardFlowData, buildFlowSankeySpec } from './flow'
|
||||
import {
|
||||
buildDashboardFlowData,
|
||||
buildFlowFilterOptions,
|
||||
buildFlowSankeySpec,
|
||||
} from './flow'
|
||||
|
||||
const rows: FlowQuotaDataItem[] = [
|
||||
{
|
||||
@@ -272,9 +276,7 @@ describe('dashboard flow data', () => {
|
||||
})
|
||||
|
||||
test('builds user filter options with stable values', () => {
|
||||
const options = buildDashboardFlowData(rows, 'quota', {
|
||||
role: 'admin',
|
||||
}).filterOptions
|
||||
const options = buildFlowFilterOptions(rows, 'quota')
|
||||
|
||||
assert.deepEqual(
|
||||
options.users.map((user) => [user.value, user.label, user.valueLabel]),
|
||||
|
||||
+22
@@ -947,6 +947,28 @@ 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
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ForbiddenError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
+1
-1
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const FEEDBACK_URL = 'https://github.com/QuantumNous/new-api/issues'
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function MaintenanceError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
+1
-1
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function NotFoundError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
+1
-1
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function UnauthorisedError() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -20,7 +20,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface HeroButtonsProps {
|
||||
isAuthenticated: boolean
|
||||
|
||||
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface CTAProps {
|
||||
className?: string
|
||||
|
||||
+6
-11
@@ -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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
|
||||
import { HeroTerminalDemo } from '../hero-terminal-demo'
|
||||
@@ -57,8 +57,7 @@ export function Hero(props: HeroProps) {
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='xl'
|
||||
className='group border-border/50 hover:border-border hover:bg-muted/50 gap-1.5 rounded-lg text-sm'
|
||||
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'
|
||||
render={
|
||||
<a href={docsUrl} target='_blank' rel='noopener noreferrer' />
|
||||
}
|
||||
@@ -71,8 +70,7 @@ export function Hero(props: HeroProps) {
|
||||
return (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='xl'
|
||||
className='group border-border/50 hover:border-border hover:bg-muted/50 gap-1.5 rounded-lg text-sm'
|
||||
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'
|
||||
render={<Link to={docsUrl} />}
|
||||
>
|
||||
<BookOpen className='text-muted-foreground/80 group-hover:text-foreground size-4 transition-colors duration-200' />
|
||||
@@ -142,8 +140,7 @@ export function Hero(props: HeroProps) {
|
||||
{props.isAuthenticated ? (
|
||||
<>
|
||||
<Button
|
||||
size='xl'
|
||||
className='group rounded-lg text-sm'
|
||||
className='group h-11 rounded-lg px-5 text-sm font-medium'
|
||||
render={<Link to='/dashboard' />}
|
||||
>
|
||||
{t('Go to Dashboard')}
|
||||
@@ -154,8 +151,7 @@ export function Hero(props: HeroProps) {
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size='xl'
|
||||
className='group rounded-lg text-sm'
|
||||
className='group h-11 rounded-lg px-5 text-sm font-medium'
|
||||
render={<Link to='/sign-up' />}
|
||||
>
|
||||
{t('Get Started')}
|
||||
@@ -163,8 +159,7 @@ export function Hero(props: HeroProps) {
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='xl'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 rounded-lg text-sm'
|
||||
className='border-border/50 hover:border-border hover:bg-muted/50 h-11 rounded-lg px-5 text-sm font-medium'
|
||||
render={<Link to='/pricing' />}
|
||||
>
|
||||
{t('View Pricing')}
|
||||
|
||||
Vendored
+1
-1
@@ -35,7 +35,7 @@ import type {
|
||||
export async function getApiKeys(
|
||||
params: GetApiKeysParams = {}
|
||||
): Promise<GetApiKeysResponse> {
|
||||
const { p = 1, size = 20 } = params
|
||||
const { p = 1, size = 10 } = params
|
||||
const res = await api.get(`/api/token/?p=${p}&size=${size}`)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2023-2026 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { flexRender, type Row } from '@tanstack/react-table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { DataTableCardField, DataTableCardRow } 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 hasMetaRows = [
|
||||
'group',
|
||||
'quota',
|
||||
'created_time',
|
||||
'accessed_time',
|
||||
'expired_time',
|
||||
].some((columnId) => visibleColumnIds.has(columnId))
|
||||
const hasDetailSections =
|
||||
visibleColumnIds.has('model_limits') || visibleColumnIds.has('allow_ips')
|
||||
|
||||
return (
|
||||
<div className='flex min-w-0 flex-col'>
|
||||
<div className='flex min-w-0 items-start justify-between gap-3'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
{visibleColumnIds.has('name') && (
|
||||
<div className='text-[15px] leading-tight font-semibold break-words'>
|
||||
{renderApiKeyCell(props.row, 'name')}
|
||||
</div>
|
||||
)}
|
||||
{visibleColumnIds.has('key') && (
|
||||
<div className='mt-1.5 min-w-0'>
|
||||
{renderApiKeyCell(props.row, 'key')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visibleColumnIds.has('status') && (
|
||||
<div className='shrink-0'>
|
||||
{renderApiKeyCell(props.row, 'status')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMetaRows && (
|
||||
<div className='mt-3 space-y-0.5 border-t pt-3'>
|
||||
{visibleColumnIds.has('quota') && (
|
||||
<DataTableCardRow label={t('Quota')} 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>
|
||||
)}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('group') && (
|
||||
<DataTableCardRow label={t('Group')} contentMode='full'>
|
||||
{renderApiKeyCell(props.row, 'group')}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('created_time') && (
|
||||
<DataTableCardRow label={t('Created')} contentMode='full'>
|
||||
{renderApiKeyCell(props.row, 'created_time')}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('accessed_time') && (
|
||||
<DataTableCardRow label={t('Last Used')} contentMode='full'>
|
||||
{renderApiKeyCell(props.row, 'accessed_time')}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
{visibleColumnIds.has('expired_time') && (
|
||||
<DataTableCardRow label={t('Expires')} contentMode='full'>
|
||||
{renderApiKeyCell(props.row, 'expired_time')}
|
||||
</DataTableCardRow>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDetailSections && (
|
||||
<div className='mt-3 space-y-3 border-t pt-3'>
|
||||
{visibleColumnIds.has('model_limits') && (
|
||||
<DataTableCardField label={t('Models')} contentMode='full'>
|
||||
<ApiKeyModels apiKey={apiKey} />
|
||||
</DataTableCardField>
|
||||
)}
|
||||
{visibleColumnIds.has('allow_ips') && (
|
||||
<DataTableCardField label={t('IP Restriction')} contentMode='full'>
|
||||
<ApiKeyIpRestrictions apiKey={apiKey} />
|
||||
</DataTableCardField>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleColumnIds.has('actions') && (
|
||||
<div className='mt-3 flex justify-end border-t pt-2'>
|
||||
{renderApiKeyCell(props.row, 'actions')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,8 @@ import { Check, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -28,8 +29,7 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/design-system/command'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -62,19 +62,19 @@ function formatGroupRatio(
|
||||
|
||||
function getRatioBadgeClassName(ratio: ApiKeyGroupOption['ratio']) {
|
||||
if (typeof ratio !== 'number') {
|
||||
return 'border-success/25 bg-success/10 text-success'
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
if (ratio > 5) {
|
||||
return 'border-destructive/25 bg-destructive/10 text-destructive'
|
||||
return 'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-300'
|
||||
}
|
||||
if (ratio > 3) {
|
||||
return 'border-warning/30 bg-warning/10 text-warning'
|
||||
return 'border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-orange-300'
|
||||
}
|
||||
if (ratio > 1) {
|
||||
return 'border-info/25 bg-info/10 text-info'
|
||||
return 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/60 dark:bg-blue-950/40 dark:text-blue-300'
|
||||
}
|
||||
return 'border-success/25 bg-success/10 text-success'
|
||||
return 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900/60 dark:bg-emerald-950/40 dark:text-emerald-300'
|
||||
}
|
||||
|
||||
function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
|
||||
@@ -87,7 +87,7 @@ function GroupRatioBadge({ ratio }: { ratio: ApiKeyGroupOption['ratio'] }) {
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'max-w-24 shrink-0 truncate text-xs sm:max-w-none sm:text-xs',
|
||||
'max-w-24 shrink-0 truncate text-[10px] sm:max-w-none sm:text-xs',
|
||||
getRatioBadgeClassName(ratio)
|
||||
)}
|
||||
>
|
||||
@@ -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:h-auto 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:min-h-20 sm:gap-3 sm:px-4 sm:py-3'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -149,7 +149,7 @@ export function ApiKeyGroupCombobox({
|
||||
{selectedOption?.label || placeholder || t('Select a group')}
|
||||
</span>
|
||||
{selectedOption?.desc && (
|
||||
<span className='text-muted-foreground block truncate text-xs sm:text-xs'>
|
||||
<span className='text-muted-foreground block truncate text-[11px] sm:text-xs'>
|
||||
{selectedOption.desc}
|
||||
</span>
|
||||
)}
|
||||
|
||||
+25
-13
@@ -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,
|
||||
@@ -58,7 +58,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
if (isLoading) {
|
||||
copyIcon = <Loader2 className='size-3.5 animate-spin' />
|
||||
} else if (isCopied) {
|
||||
copyIcon = <Check className='text-success size-3.5' />
|
||||
copyIcon = <Check className='size-3.5 text-green-600' />
|
||||
}
|
||||
|
||||
let copyTooltip = t('Copy API key')
|
||||
@@ -99,7 +99,7 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
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'
|
||||
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'
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -135,8 +135,8 @@ export function ApiKeyCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
render={
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
className='shrink-0'
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={handleCopy}
|
||||
onFocus={() => {
|
||||
if (!resolvedFullKey) void resolveRealKey(apiKey.id)
|
||||
@@ -160,7 +160,9 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!apiKey.model_limits_enabled || !apiKey.model_limits) {
|
||||
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge label={t('Unlimited')} variant='neutral' copyable={false} />
|
||||
)
|
||||
}
|
||||
|
||||
const models = apiKey.model_limits.split(',').filter(Boolean)
|
||||
@@ -168,9 +170,11 @@ export function ModelLimitsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<BadgeCell />}>
|
||||
<StatusBadge variant='neutral'>
|
||||
{t('{{count}} model(s)', { count: models.length })}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('{{count}} model(s)', { count: models.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
@@ -190,7 +194,13 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
const allowIps = apiKey.allow_ips?.trim()
|
||||
|
||||
if (!allowIps) {
|
||||
return <StatusBadge variant='neutral'>{t('No restriction')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('No restriction')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ips = allowIps
|
||||
@@ -201,9 +211,11 @@ export function IpRestrictionsCell({ apiKey }: { apiKey: ApiKey }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<BadgeCell />}>
|
||||
<StatusBadge variant='neutral'>
|
||||
{t('{{count}} IP(s)', { count: ips.length })}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('{{count}} IP(s)', { count: ips.length })}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='max-w-xs'>
|
||||
<div className='max-h-[200px] space-y-0.5 overflow-y-auto text-xs'>
|
||||
|
||||
+61
-78
@@ -16,6 +16,7 @@ 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'
|
||||
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { useGroupRatios } from '@/hooks/use-group-ratios'
|
||||
import { getUserGroups } from '@/lib/api'
|
||||
import { formatQuota, formatTimestampToDate } from '@/lib/format'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -43,11 +44,29 @@ import {
|
||||
import { DataTableRowActions } from './data-table-row-actions'
|
||||
|
||||
function getQuotaProgressColor(percentage: number): string {
|
||||
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'
|
||||
if (percentage <= 10) return '[&_[data-slot=progress-indicator]]:bg-rose-500'
|
||||
if (percentage <= 30) return '[&_[data-slot=progress-indicator]]:bg-amber-500'
|
||||
return '[&_[data-slot=progress-indicator]]:bg-emerald-500'
|
||||
}
|
||||
|
||||
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>[] {
|
||||
@@ -76,7 +95,6 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
meta: { cardRole: 'hidden' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'name',
|
||||
@@ -85,11 +103,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
<span className='font-medium'>{row.getValue('name')}</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
@@ -98,18 +112,16 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
const statusConfig = API_KEY_STATUSES[row.getValue('status') as number]
|
||||
if (!statusConfig) return null
|
||||
return (
|
||||
<StatusBadge variant={statusConfig.variant}>
|
||||
{t(statusConfig.label)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t(statusConfig.label)}
|
||||
variant={statusConfig.variant}
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => value.includes(String(row.getValue(id))),
|
||||
size: 120,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileBadge: true },
|
||||
},
|
||||
{
|
||||
id: 'key',
|
||||
@@ -118,12 +130,6 @@ 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',
|
||||
@@ -132,7 +138,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
cell: ({ row }) => {
|
||||
const apiKey = row.original
|
||||
if (apiKey.unlimited_quota) {
|
||||
return <StatusBadge variant='neutral'>{t('Unlimited')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Unlimited')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const used = apiKey.used_quota
|
||||
@@ -174,12 +186,6 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 170,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'group',
|
||||
@@ -197,7 +203,11 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
>
|
||||
<GroupBadge group='auto' />
|
||||
{apiKey.cross_group_retry && (
|
||||
<StatusBadge variant='info'>{t('Cross-group')}</StatusBadge>
|
||||
<StatusBadge
|
||||
label={t('Cross-group')}
|
||||
variant='info'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -220,12 +230,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 160,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'model_limits',
|
||||
@@ -234,12 +239,7 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
cell: ({ row }) => <ModelLimitsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 30,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'allow_ips',
|
||||
@@ -248,27 +248,18 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
cell: ({ row }) => <IpRestrictionsCell apiKey={row.original} />,
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 40,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
header: t('Created'),
|
||||
cell: ({ row }) => (
|
||||
<span className='text-muted-foreground block truncate text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(row.getValue('created_time'))}
|
||||
</span>
|
||||
),
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 50,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'accessed_time',
|
||||
@@ -279,17 +270,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<span className='text-muted-foreground block truncate text-xs tabular-nums'>
|
||||
<span className='text-muted-foreground block truncate font-mono text-xs tabular-nums'>
|
||||
{formatTimestampToDate(accessedTime)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 60,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
accessorKey: 'expired_time',
|
||||
@@ -297,13 +284,19 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
cell: ({ row }) => {
|
||||
const expiredTime = row.getValue('expired_time') as number
|
||||
if (expiredTime === -1) {
|
||||
return <StatusBadge variant='neutral'>{t('Never')}</StatusBadge>
|
||||
return (
|
||||
<StatusBadge
|
||||
label={t('Never')}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const isExpired = expiredTime * 1000 < Date.now()
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-xs tabular-nums',
|
||||
'block truncate font-mono text-xs tabular-nums',
|
||||
isExpired ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -312,23 +305,13 @@ export function useApiKeysColumns(): ColumnDef<ApiKey>[] {
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 70,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: () => t('Actions'),
|
||||
cell: ({ row }) => <DataTableRowActions row={row} />,
|
||||
meta: {
|
||||
pinned: 'right' as const,
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 80,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { pinned: 'right' as const },
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/design-system/alert-dialog'
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
import { deleteApiKey } from '../api'
|
||||
import { ERROR_MESSAGES, SUCCESS_MESSAGES } from '../constants'
|
||||
|
||||
@@ -25,8 +25,6 @@ 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,
|
||||
@@ -37,6 +35,7 @@ import {
|
||||
sideDrawerSwitchItemClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
@@ -260,7 +260,9 @@ export function ApiKeysMutateDrawer({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SheetContent className={sideDrawerContentClassName('sm:max-w-[620px]')}>
|
||||
<SheetContent
|
||||
className={sideDrawerContentClassName('max-w-none sm:!max-w-[620px]')}
|
||||
>
|
||||
<SheetHeader className={sideDrawerHeaderClassName()}>
|
||||
<SheetTitle>
|
||||
{isUpdate ? t('Update API Key') : t('Create API Key')}
|
||||
@@ -362,6 +364,8 @@ 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')}
|
||||
@@ -369,6 +373,8 @@ 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')}
|
||||
@@ -376,6 +382,8 @@ 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')}
|
||||
@@ -383,6 +391,8 @@ 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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { useApiKeys } from './api-keys-provider'
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ApiKeysPrimaryButtons() {
|
||||
const { setOpen } = useApiKeys()
|
||||
return (
|
||||
<div className='flex gap-2'>
|
||||
<Button onClick={() => setOpen('create')}>
|
||||
<Button size='sm' onClick={() => setOpen('create')}>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Create API Key')}
|
||||
</Button>
|
||||
|
||||
+136
-26
@@ -18,6 +18,8 @@ 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'
|
||||
|
||||
@@ -25,24 +27,36 @@ import {
|
||||
DISABLED_ROW_DESKTOP,
|
||||
DISABLED_ROW_MOBILE,
|
||||
DataTablePage,
|
||||
MobileCardList,
|
||||
useDebouncedColumnFilter,
|
||||
useDataTable,
|
||||
} from '@/components/data-table'
|
||||
import { Input } from '@/components/design-system/input'
|
||||
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 { 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 { ApiKeyCard } from './api-key-card'
|
||||
import { type ApiKey } from '../types'
|
||||
import { ApiKeyCell } from './api-keys-cells'
|
||||
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'
|
||||
@@ -51,6 +65,122 @@ 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-[11px]'>
|
||||
{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()
|
||||
@@ -67,11 +197,7 @@ export function ApiKeysTable() {
|
||||
} = useTableUrlState({
|
||||
search: route.useSearch(),
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
defaultPage: 1,
|
||||
defaultPageSize: 20,
|
||||
pageSizeStorageKey: 'api-keys:page-size:v1',
|
||||
},
|
||||
pagination: { defaultPage: 1, defaultPageSize: 20 },
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
{ columnId: 'status', searchKey: 'status', type: 'array' },
|
||||
@@ -157,7 +283,6 @@ export function ApiKeysTable() {
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
tableLabel={t('API Keys')}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No API Keys Found')}
|
||||
@@ -168,8 +293,6 @@ export function ApiKeysTable() {
|
||||
applyHeaderSize
|
||||
toolbarProps={{
|
||||
searchPlaceholder: t('Filter by name...'),
|
||||
hasAdditionalFilters: Boolean(tokenFilterInput.trim()),
|
||||
onReset: () => setTokenFilterInput(''),
|
||||
additionalSearch: (
|
||||
<Input
|
||||
placeholder={t('Filter by API key...')}
|
||||
@@ -188,20 +311,7 @@ export function ApiKeysTable() {
|
||||
},
|
||||
],
|
||||
}}
|
||||
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
|
||||
}
|
||||
/>
|
||||
}
|
||||
mobile={<ApiKeysMobileList table={table} isLoading={isLoading} />}
|
||||
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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -89,6 +89,7 @@ export function DataTableBulkActions<TData>({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='size-8'
|
||||
onClick={handleBatchCopy}
|
||||
disabled={isCopying}
|
||||
aria-label={t('Copy selected keys')}
|
||||
@@ -113,6 +114,7 @@ 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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
@@ -203,7 +203,7 @@ export function DataTableRowActions<TData>({
|
||||
className={
|
||||
isEnabled
|
||||
? 'text-destructive hover:text-destructive'
|
||||
: 'text-success hover:text-success'
|
||||
: 'text-emerald-600 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-400'
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: 'destructive',
|
||||
variant: 'danger',
|
||||
value: API_KEY_STATUS.EXHAUSTED,
|
||||
},
|
||||
} as const
|
||||
|
||||
+6
-2
@@ -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'
|
||||
@@ -125,7 +125,11 @@ export function LegalDocument({
|
||||
return (
|
||||
<PublicLayout showMainContainer={!contentIsHtml}>
|
||||
{contentIsHtml ? (
|
||||
<RichContent mode='html' htmlVariant='isolated' content={rawContent} />
|
||||
<RichContent
|
||||
mode='html'
|
||||
htmlVariant='isolated'
|
||||
content={rawContent}
|
||||
/>
|
||||
) : (
|
||||
<div className='mx-auto max-w-4xl space-y-6 py-12'>
|
||||
<div className='space-y-2'>
|
||||
|
||||
@@ -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,6 +103,7 @@ export function DataTableBulkActions<TData>({
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleEnableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Enable selected models')}
|
||||
title={t('Enable selected models')}
|
||||
/>
|
||||
@@ -123,6 +124,7 @@ export function DataTableBulkActions<TData>({
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleDisableAll}
|
||||
className='size-8'
|
||||
aria-label={t('Disable selected models')}
|
||||
title={t('Disable selected models')}
|
||||
/>
|
||||
@@ -143,6 +145,7 @@ export function DataTableBulkActions<TData>({
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleCopyNames}
|
||||
className='size-8'
|
||||
aria-label={t('Copy model names')}
|
||||
title={t('Copy model names')}
|
||||
/>
|
||||
@@ -163,6 +166,7 @@ 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/design-system/button'
|
||||
import { Button } from '@/components/ui/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'
|
||||
@@ -74,7 +74,7 @@ function LoadingStep({
|
||||
{status === 'loading' && (
|
||||
<Loader2 className='text-primary h-5 w-5 animate-spin' />
|
||||
)}
|
||||
{status === 'done' && <CheckCircle2 className='text-success h-5 w-5' />}
|
||||
{status === 'done' && <CheckCircle2 className='h-5 w-5 text-green-500' />}
|
||||
{status === 'pending' && (
|
||||
<Circle className='text-muted-foreground/40 h-5 w-5' />
|
||||
)}
|
||||
@@ -141,8 +141,8 @@ export function DeploymentAccessGuard({
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='text-center'>
|
||||
<div className='bg-warning/10 mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<Server className='text-warning h-8 w-8' />
|
||||
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-amber-100 dark:bg-amber-900/20'>
|
||||
<Server className='h-8 w-8 text-amber-600 dark:text-amber-400' />
|
||||
</div>
|
||||
<h3 className='mb-6 text-xl font-semibold'>
|
||||
{t('Model deployment service is disabled')}
|
||||
@@ -172,8 +172,8 @@ export function DeploymentAccessGuard({
|
||||
return (
|
||||
<div className='mx-auto mt-8 max-w-md'>
|
||||
<div className='text-center'>
|
||||
<div className='bg-destructive/10 text-destructive mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl'>
|
||||
<WifiOff className='size-8' />
|
||||
<div className='mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/20'>
|
||||
<WifiOff className='h-8 w-8 text-red-600 dark:text-red-400' />
|
||||
</div>
|
||||
<h3 className='mb-6 text-xl font-semibold'>
|
||||
{t('Connection failed')}
|
||||
@@ -199,5 +199,5 @@ export function DeploymentAccessGuard({
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -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 { Button } from '@/components/design-system/button'
|
||||
import { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
@@ -53,12 +53,7 @@ export function useDeploymentsColumns(opts: {
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: t('ID'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.original.id
|
||||
return <TableId value={id} />
|
||||
@@ -70,17 +65,17 @@ export function useDeploymentsColumns(opts: {
|
||||
accessorFn: (row) =>
|
||||
row.container_name || row.deployment_name || row.name || '',
|
||||
header: t('Name'),
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ getValue }) => {
|
||||
const name = String(getValue() || '-') || '-'
|
||||
return (
|
||||
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
|
||||
{name}
|
||||
</CopyableStatusBadge>
|
||||
<StatusBadge
|
||||
label={name}
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
minSize: 220,
|
||||
@@ -88,7 +83,7 @@ export function useDeploymentsColumns(opts: {
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: t('Status'),
|
||||
meta: { cardRole: 'badge', contentMode: 'wrap' },
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const raw = row.original.status
|
||||
const key = normalizeDeploymentStatus(raw)
|
||||
@@ -98,9 +93,12 @@ export function useDeploymentsColumns(opts: {
|
||||
variant: 'neutral' as const,
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{config.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={config.label}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
@@ -126,19 +124,16 @@ export function useDeploymentsColumns(opts: {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{String(provider)}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={String(provider)}
|
||||
autoColor={String(provider)}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 10,
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'time_remaining',
|
||||
@@ -171,9 +166,12 @@ export function useDeploymentsColumns(opts: {
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='font-medium'>{remainingText}</span>
|
||||
{status === 'running' && percentRemain !== null ? (
|
||||
<StatusBadge variant='info' size='sm'>
|
||||
{`${percentRemain}%`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`${percentRemain}%`}
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{remainingHuman ? (
|
||||
@@ -186,22 +184,11 @@ export function useDeploymentsColumns(opts: {
|
||||
},
|
||||
minSize: 220,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
cardRole: 'primary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'hardware',
|
||||
header: t('Hardware'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
accessorFn: (row) =>
|
||||
row.hardware_info || row.hardware_name || row.brand_name || '',
|
||||
cell: ({ row }) => {
|
||||
@@ -218,14 +205,13 @@ export function useDeploymentsColumns(opts: {
|
||||
return <span className='text-muted-foreground text-xs'>-</span>
|
||||
}
|
||||
return (
|
||||
<div className='flex max-w-full min-w-0 flex-wrap items-center gap-2'>
|
||||
<CopyableStatusBadge
|
||||
value={String(hardware)}
|
||||
<div className='flex max-w-full min-w-0 flex-nowrap items-center gap-2 overflow-hidden'>
|
||||
<StatusBadge
|
||||
label={String(hardware)}
|
||||
variant='neutral'
|
||||
copyText={String(hardware)}
|
||||
size='sm'
|
||||
>
|
||||
{String(hardware)}
|
||||
</CopyableStatusBadge>
|
||||
/>
|
||||
{qty !== null ? (
|
||||
<span className='text-muted-foreground text-xs'>×{qty}</span>
|
||||
) : null}
|
||||
@@ -238,11 +224,7 @@ export function useDeploymentsColumns(opts: {
|
||||
{
|
||||
accessorKey: 'created_at',
|
||||
header: t('Created'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 30,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
let ts: number | undefined
|
||||
if (typeof row.original.created_at === 'number') {
|
||||
|
||||
@@ -32,7 +32,8 @@ import {
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/design-system/alert-dialog'
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useMediaQuery } from '@/hooks'
|
||||
import { useTableUrlState } from '@/hooks/use-table-url-state'
|
||||
|
||||
import { deleteDeployment, listDeployments, searchDeployments } from '../api'
|
||||
@@ -51,6 +52,7 @@ 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 {
|
||||
@@ -68,8 +70,7 @@ export function DeploymentsTable() {
|
||||
pageKey: 'dPage',
|
||||
pageSizeKey: 'dPageSize',
|
||||
defaultPage: 1,
|
||||
defaultPageSize: 20,
|
||||
pageSizeStorageKey: 'model-deployments:page-size:v1',
|
||||
defaultPageSize: isMobile ? 8 : 10,
|
||||
},
|
||||
globalFilter: { enabled: true, key: 'dFilter' },
|
||||
columnFilters: [
|
||||
@@ -219,7 +220,6 @@ 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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { useModels } from './models-provider'
|
||||
|
||||
@@ -41,11 +41,11 @@ export function DescriptionCell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-w-full sm:max-w-[150px]'>
|
||||
<div className='max-w-[150px]'>
|
||||
<Button
|
||||
variant='link'
|
||||
onClick={handleClick}
|
||||
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'
|
||||
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'
|
||||
>
|
||||
{description}
|
||||
</Button>
|
||||
|
||||
+10
-10
@@ -24,16 +24,6 @@ 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,
|
||||
@@ -42,6 +32,7 @@ import {
|
||||
sideDrawerHeaderClassName,
|
||||
} from '@/components/drawer-layout'
|
||||
import { MultiSelect } from '@/components/multi-select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -50,6 +41,15 @@ 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,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DescriptionDialog({
|
||||
onOpenChange={onOpenChange}
|
||||
title={modelName}
|
||||
description={t('Model Description')}
|
||||
contentClassName='sm:max-w-2xl'
|
||||
contentClassName='max-w-2xl'
|
||||
contentHeight='auto'
|
||||
bodyClassName='space-y-4'
|
||||
>
|
||||
|
||||
+2
-2
@@ -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'
|
||||
|
||||
+15
-12
@@ -21,10 +21,9 @@ 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 { CopyableStatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -32,6 +31,7 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from '@/components/ui/empty'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
|
||||
import { getMissingModels } from '../../api'
|
||||
@@ -118,26 +118,24 @@ export function MissingModelsDialog({
|
||||
description={t(
|
||||
'Models that are being used but not configured in the system'
|
||||
)}
|
||||
contentClassName='flex max-h-[85vh] flex-col gap-3 p-4 sm:max-w-2xl'
|
||||
contentClassName='flex max-h-[85vh] max-w-2xl flex-col gap-3 p-4'
|
||||
headerClassName='flex-shrink-0 text-start'
|
||||
contentHeight='min(74vh, 760px)'
|
||||
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>
|
||||
)}
|
||||
{!isLoading && missingModels.length === 0 && (
|
||||
) : 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'>
|
||||
@@ -179,11 +177,14 @@ export function MissingModelsDialog({
|
||||
className='flex items-center justify-between gap-3 p-3'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<CopyableStatusBadge value={modelName} variant='neutral'>
|
||||
{modelName}
|
||||
</CopyableStatusBadge>
|
||||
<StatusBadge
|
||||
label={modelName}
|
||||
variant='neutral'
|
||||
copyText={modelName}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
className='flex-shrink-0 gap-1'
|
||||
onClick={() => handleConfigureModel(modelName)}
|
||||
>
|
||||
@@ -206,6 +207,7 @@ export function MissingModelsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
||||
}
|
||||
@@ -217,6 +219,7 @@ export function MissingModelsDialog({
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
className='h-8 w-8'
|
||||
onClick={() =>
|
||||
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
|
||||
}
|
||||
|
||||
+45
-25
@@ -32,12 +32,11 @@ 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 { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -197,7 +196,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'>
|
||||
<StatusBadge variant={meta.badge} size='sm' copyable={false}>
|
||||
{meta.label}
|
||||
<span className='text-muted-foreground/30'>·</span>
|
||||
<span className='text-muted-foreground font-mono'>
|
||||
@@ -239,21 +238,30 @@ 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>
|
||||
<Badge variant='outline'>
|
||||
{`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
label={`${parsedItems.length} item${parsedItems.length === 1 ? '' : 's'}`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
{parsedItems.length > 0 ? (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge key={item} variant='neutral' size='sm'>
|
||||
{item}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{`+${parsedItems.length - 6} more`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -302,9 +310,12 @@ export function PrefillGroupManagementDialog({
|
||||
header: t('Type'),
|
||||
cellClassName: 'align-top',
|
||||
cell: ({ meta }) => (
|
||||
<StatusBadge variant={meta.badge} size='sm'>
|
||||
{meta.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -318,14 +329,20 @@ export function PrefillGroupManagementDialog({
|
||||
{parsedItems.length > 0 ? (
|
||||
<>
|
||||
{parsedItems.slice(0, 6).map((item) => (
|
||||
<StatusBadge key={item} variant='neutral' size='sm'>
|
||||
{item}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
key={item}
|
||||
label={item}
|
||||
autoColor={item}
|
||||
size='sm'
|
||||
/>
|
||||
))}
|
||||
{parsedItems.length > 6 && (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{`+${parsedItems.length - 6} more`}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={`+${parsedItems.length - 6} more`}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
@@ -391,11 +408,12 @@ 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 onClick={onCreateGroup}>
|
||||
<Button size='sm' onClick={onCreateGroup}>
|
||||
<Plus className='mr-2 h-4 w-4' />
|
||||
{t('New Group')}
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
onClick={() => refetchGroups()}
|
||||
disabled={isFetching}
|
||||
@@ -408,9 +426,11 @@ export function PrefillGroupManagementDialog({
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<Badge variant='outline'>
|
||||
{`${groups.length} group${groups.length === 1 ? '' : 's'}`}
|
||||
</Badge>
|
||||
<StatusBadge
|
||||
label={`${groups.length} group${groups.length === 1 ? '' : 's'}`}
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3'>
|
||||
|
||||
+2
-2
@@ -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,7 +188,11 @@ export function SyncWizardDialog({
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>{option.label}</span>
|
||||
{option.value === 'official' && (
|
||||
<StatusBadge variant='neutral'>Default</StatusBadge>
|
||||
<StatusBadge
|
||||
label='Default'
|
||||
variant='neutral'
|
||||
copyable={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
|
||||
+2
-2
@@ -25,9 +25,8 @@ 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,
|
||||
@@ -41,6 +40,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
import { getDeployment, updateDeployment } from '../../api'
|
||||
|
||||
+49
-28
@@ -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, RowSelectionState } from '@tanstack/react-table'
|
||||
import { type ColumnDef, type RowSelectionState } from '@tanstack/react-table'
|
||||
import {
|
||||
Search,
|
||||
Info,
|
||||
@@ -30,8 +30,16 @@ import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { DataTableView, useDataTable } from '@/components/data-table'
|
||||
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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -39,15 +47,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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'
|
||||
} from '@/components/ui/select'
|
||||
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(20)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -213,9 +213,12 @@ export function UpstreamConflictDialog({
|
||||
{row.original.fieldKey}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{row.original.fieldLabel}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={row.original.fieldLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -232,7 +235,11 @@ export function UpstreamConflictDialog({
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant='ghost' size={isMobile ? 'icon-sm' : 'sm'} />
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className={isMobile ? 'h-7 w-7 p-0' : 'h-7 gap-2 px-2 text-xs'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MousePointerClick className='h-3.5 w-3.5' />
|
||||
@@ -240,17 +247,25 @@ export function UpstreamConflictDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[min(90vw,24rem)] space-y-4 text-sm'>
|
||||
<div>
|
||||
<StatusBadge variant='neutral' size='sm' className='mb-1'>
|
||||
Local
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label='Local'
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='mb-1'
|
||||
/>
|
||||
<pre className='bg-muted rounded-md p-2 text-xs'>
|
||||
{formatValue(row.original.localValue)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<StatusBadge variant='info' size='sm' className='mb-1'>
|
||||
Upstream
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label='Upstream'
|
||||
variant='info'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
className='mb-1'
|
||||
/>
|
||||
<pre className='bg-muted rounded-md p-2 text-xs'>
|
||||
{formatValue(row.original.upstreamValue)}
|
||||
</pre>
|
||||
@@ -294,9 +309,12 @@ export function UpstreamConflictDialog({
|
||||
accessorKey: 'fieldLabel',
|
||||
header: 'Field',
|
||||
cell: ({ row }) => (
|
||||
<StatusBadge variant='neutral' size='sm'>
|
||||
{row.original.fieldLabel}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={row.original.fieldLabel}
|
||||
variant='neutral'
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 160,
|
||||
@@ -378,7 +396,7 @@ export function UpstreamConflictDialog({
|
||||
const payload: SyncOverwritePayload[] = Object.entries(groupedSelections)
|
||||
.map(([modelName, fields]) => ({
|
||||
model_name: modelName,
|
||||
fields: [...fields],
|
||||
fields: Array.from(fields),
|
||||
}))
|
||||
.filter((item) => item.fields.length > 0)
|
||||
|
||||
@@ -494,6 +512,7 @@ export function UpstreamConflictDialog({
|
||||
</div>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
onClick={clearSelections}
|
||||
disabled={!hasSelection}
|
||||
>
|
||||
@@ -541,7 +560,7 @@ export function UpstreamConflictDialog({
|
||||
setPageIndex(0)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='w-[70px] text-xs sm:w-[72px]'>
|
||||
<SelectTrigger className='h-8 w-[70px] text-xs sm:h-8 sm:w-[72px]'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
@@ -559,6 +578,7 @@ 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))
|
||||
}
|
||||
@@ -576,6 +596,7 @@ 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)
|
||||
|
||||
+2
-2
@@ -24,9 +24,8 @@ 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,
|
||||
@@ -36,6 +35,7 @@ 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,12 +145,13 @@ 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' onClick={handleCopyId}>
|
||||
<Button variant='outline' size='sm' onClick={handleCopyId}>
|
||||
<Copy className='mr-2 h-4 w-4' />
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetchingDetails || isFetchingContainers}
|
||||
>
|
||||
@@ -256,6 +257,7 @@ export function ViewDetailsDialog({
|
||||
{url ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => window.open(url, '_blank')}
|
||||
>
|
||||
<ExternalLink className='mr-2 h-4 w-4' />
|
||||
|
||||
@@ -21,7 +21,8 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { Button } from '@/components/design-system/button'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -29,8 +30,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/design-system/select'
|
||||
import { Dialog } from '@/components/dialog'
|
||||
} from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
|
||||
import { getDeploymentLogs, listDeploymentContainers } from '../../api'
|
||||
@@ -155,32 +155,28 @@ export function ViewLogsDialog({
|
||||
if (isLoadingContainers || isLoadingLogs) {
|
||||
logsContent = (
|
||||
<div className='flex items-center justify-center py-8'>
|
||||
<Loader2 className='text-muted-foreground size-6 animate-spin' />
|
||||
<Loader2 className='h-6 w-6 animate-spin text-gray-400' />
|
||||
</div>
|
||||
)
|
||||
} else if (containers.length === 0) {
|
||||
logsContent = (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No containers')}
|
||||
</div>
|
||||
<div className='py-8 text-center text-gray-400'>{t('No containers')}</div>
|
||||
)
|
||||
} else if (!containerId) {
|
||||
logsContent = (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
<div className='py-8 text-center text-gray-400'>
|
||||
{t('Please select a container')}
|
||||
</div>
|
||||
)
|
||||
} else if (!logsText.trim()) {
|
||||
logsContent = (
|
||||
<div className='text-muted-foreground py-8 text-center'>
|
||||
{t('No logs')}
|
||||
</div>
|
||||
<div className='py-8 text-center text-gray-400'>{t('No logs')}</div>
|
||||
)
|
||||
} else {
|
||||
logsContent = (
|
||||
<div className='font-mono text-sm'>
|
||||
{keyedLogLines.map(({ key, line }) => (
|
||||
<div key={key} className='text-foreground whitespace-pre-wrap'>
|
||||
<div key={key} className='whitespace-pre-wrap text-gray-200'>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
@@ -210,6 +206,7 @@ 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()
|
||||
@@ -225,6 +222,7 @@ export function ViewLogsDialog({
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleDownload}
|
||||
disabled={!logsText.trim()}
|
||||
>
|
||||
|
||||
+11
-11
@@ -25,16 +25,6 @@ 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,
|
||||
@@ -45,6 +35,7 @@ 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,
|
||||
@@ -59,8 +50,17 @@ 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 className='w-[200px]'>
|
||||
<SelectTrigger size='sm' className='w-[200px]'>
|
||||
<SelectValue placeholder={t('Load template...')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent alignItemWithTrigger={false}>
|
||||
|
||||
+51
-49
@@ -24,16 +24,6 @@ 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,
|
||||
@@ -44,6 +34,7 @@ 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,
|
||||
@@ -53,6 +44,15 @@ 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,28 +143,27 @@ 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,
|
||||
items:
|
||||
values.type === 'endpoint'
|
||||
? typeof values.items === 'string'
|
||||
? values.items
|
||||
: ''
|
||||
: Array.isArray(values.items)
|
||||
? values.items
|
||||
: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const response =
|
||||
isEdit && currentGroup
|
||||
? await updatePrefillGroup({
|
||||
id: currentGroup.id,
|
||||
...payload,
|
||||
})
|
||||
: await createPrefillGroup(payload)
|
||||
const response = isEdit
|
||||
? await updatePrefillGroup({
|
||||
id: currentGroup!.id,
|
||||
...payload,
|
||||
})
|
||||
: await createPrefillGroup(payload)
|
||||
|
||||
if (response.success) {
|
||||
toast.success(
|
||||
@@ -186,12 +185,6 @@ 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}>
|
||||
@@ -284,20 +277,22 @@ 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 &&
|
||||
@@ -340,9 +335,12 @@ 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 variant={meta.badge} size='sm'>
|
||||
{meta.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={meta.label}
|
||||
variant={meta.badge}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -401,7 +399,11 @@ export function PrefillGroupFormDrawer({
|
||||
</SheetClose>
|
||||
<Button type='submit' form='prefill-group-form' disabled={isSaving}>
|
||||
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
|
||||
{submitLabel}
|
||||
{isSaving
|
||||
? t('Saving...')
|
||||
: isEdit
|
||||
? t('Save changes')
|
||||
: t('Create')}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
|
||||
+59
-106
@@ -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 { CopyableStatusBadge, StatusBadge } from '@/components/status-badge'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { TableId } from '@/components/table-id'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
@@ -94,12 +94,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: t('ID'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 10,
|
||||
cardSpan: 2,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue('id') as number
|
||||
return <TableId value={id} />
|
||||
@@ -111,7 +106,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'icon',
|
||||
header: t('Icon'),
|
||||
meta: { cardRole: 'hidden' },
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const model = row.original
|
||||
const iconKey =
|
||||
@@ -135,17 +130,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'model_name',
|
||||
header: t('Model Name'),
|
||||
meta: {
|
||||
cardRole: 'title',
|
||||
cardSpan: 2,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileTitle: true },
|
||||
cell: ({ row }) => {
|
||||
const name = row.getValue('model_name') as string
|
||||
return (
|
||||
<CopyableStatusBadge value={name} variant='neutral' size='sm'>
|
||||
{name}
|
||||
</CopyableStatusBadge>
|
||||
<StatusBadge
|
||||
label={name}
|
||||
variant='neutral'
|
||||
copyText={name}
|
||||
size='sm'
|
||||
className='font-mono'
|
||||
/>
|
||||
)
|
||||
},
|
||||
minSize: 200,
|
||||
@@ -166,9 +161,18 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={label}
|
||||
variant={
|
||||
(config.color === 'error' ? 'danger' : config.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
/>
|
||||
)
|
||||
|
||||
// Show tooltip with matched models for non-exact rules
|
||||
@@ -178,9 +182,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
model.matched_models.length > 0
|
||||
) {
|
||||
const matchedBadges = model.matched_models.map((m) => (
|
||||
<StatusBadge key={m} variant='neutral' size='sm'>
|
||||
{m}
|
||||
</StatusBadge>
|
||||
<StatusBadge key={m} label={m} autoColor={m} size='sm' />
|
||||
))
|
||||
|
||||
return (
|
||||
@@ -202,27 +204,25 @@ 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: { cardRole: 'badge', contentMode: 'wrap' },
|
||||
meta: { mobileBadge: true },
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue('status') as number
|
||||
const config =
|
||||
MODEL_STATUS_CONFIG[status as 0 | 1] || MODEL_STATUS_CONFIG[0]
|
||||
|
||||
return (
|
||||
<StatusBadge variant={config.variant} size='sm'>
|
||||
{config.label}
|
||||
</StatusBadge>
|
||||
<StatusBadge
|
||||
label={config.label}
|
||||
variant={config.variant}
|
||||
size='sm'
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
@@ -249,7 +249,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
}
|
||||
|
||||
return (
|
||||
<BadgeCell className='overflow-visible'>
|
||||
<BadgeCell>
|
||||
<ProviderBadge iconKey={vendor.icon} label={vendor.name} />
|
||||
</BadgeCell>
|
||||
)
|
||||
@@ -260,24 +260,13 @@ 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: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 20,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const description = row.getValue('description') as string
|
||||
const modelName = row.getValue('model_name') as string
|
||||
@@ -294,21 +283,14 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'tags',
|
||||
header: t('Tags'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 30,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const tags = row.getValue('tags') as string
|
||||
const tagArray = parseModelTags(tags)
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={tagArray.map((tag) => (
|
||||
<StatusBadge key={tag} variant='neutral' size='sm'>
|
||||
{tag}
|
||||
</StatusBadge>
|
||||
<StatusBadge key={tag} label={tag} autoColor={tag} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
@@ -321,21 +303,14 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'endpoints',
|
||||
header: t('Endpoints'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 40,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const endpoints = row.getValue('endpoints') as string
|
||||
const endpointArray = formatEndpointsDisplay(endpoints)
|
||||
return (
|
||||
<BadgeListCell
|
||||
items={endpointArray.map((ep) => (
|
||||
<StatusBadge key={ep} variant='neutral' size='sm'>
|
||||
{ep}
|
||||
</StatusBadge>
|
||||
<StatusBadge key={ep} label={ep} autoColor={ep} size='sm' />
|
||||
))}
|
||||
/>
|
||||
)
|
||||
@@ -348,12 +323,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'bound_channels',
|
||||
header: t('Bound Channels'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 50,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const channels = row.getValue('bound_channels') as Array<{
|
||||
id: number
|
||||
@@ -366,11 +336,10 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
items={(channels ?? []).map((c) => (
|
||||
<StatusBadge
|
||||
key={`${c.id}-${c.name}-${c.type ?? ''}`}
|
||||
variant='neutral'
|
||||
label={`${c.name} (${c.type})`}
|
||||
autoColor={c.name}
|
||||
size='sm'
|
||||
>
|
||||
{`${c.name} (${c.type})`}
|
||||
</StatusBadge>
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
)
|
||||
@@ -383,12 +352,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'enable_groups',
|
||||
header: t('Enable Groups'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 60,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const groups = row.getValue('enable_groups') as string[]
|
||||
return (
|
||||
@@ -407,12 +371,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'quota_types',
|
||||
header: t('Quota Types'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 70,
|
||||
cardSpan: 2,
|
||||
contentMode: 'summary',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const quotaTypes = row.getValue('quota_types') as number[]
|
||||
return (
|
||||
@@ -422,11 +381,17 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
return (
|
||||
<StatusBadge
|
||||
key={qt}
|
||||
variant={config?.variant || 'neutral'}
|
||||
label={config?.label || String(qt)}
|
||||
variant={
|
||||
(config?.color === 'error' ? 'danger' : config?.color) as
|
||||
| 'neutral'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
}
|
||||
size='sm'
|
||||
>
|
||||
{config?.label || String(qt)}
|
||||
</StatusBadge>
|
||||
/>
|
||||
)
|
||||
})}
|
||||
/>
|
||||
@@ -440,20 +405,16 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'sync_official',
|
||||
header: t('Official Sync'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 80,
|
||||
contentMode: 'wrap',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
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'
|
||||
>
|
||||
{syncOfficial === 1 ? t('Official Sync') : t('No Sync')}
|
||||
</StatusBadge>
|
||||
copyable={false}
|
||||
/>
|
||||
)
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
@@ -471,11 +432,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'created_time',
|
||||
header: t('Created'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 90,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
cell: ({ row }) => {
|
||||
const timestamp = row.getValue('created_time') as number
|
||||
return (
|
||||
@@ -491,11 +448,7 @@ export function useModelsColumns(vendors: Vendor[] = []): ColumnDef<Model>[] {
|
||||
{
|
||||
accessorKey: 'updated_time',
|
||||
header: t('Updated'),
|
||||
meta: {
|
||||
cardRole: 'secondary',
|
||||
cardOrder: 100,
|
||||
contentMode: 'full',
|
||||
},
|
||||
meta: { mobileHidden: true },
|
||||
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/design-system/button'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -66,14 +66,14 @@ export function ModelsPrimaryButtons() {
|
||||
return (
|
||||
<div className='flex items-center gap-2'>
|
||||
{/* Create Model */}
|
||||
<Button onClick={handleCreateModel}>
|
||||
<Button onClick={handleCreateModel} size='sm'>
|
||||
<Plus className='h-4 w-4' />
|
||||
{t('Add Model')}
|
||||
</Button>
|
||||
|
||||
{/* More Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' />}>
|
||||
<DropdownMenuTrigger render={<Button variant='outline' size='sm' />}>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-56'>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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'
|
||||
@@ -40,6 +41,7 @@ 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 {
|
||||
@@ -55,8 +57,7 @@ export function ModelsTable() {
|
||||
navigate: route.useNavigate(),
|
||||
pagination: {
|
||||
defaultPage: 1,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeStorageKey: 'models:page-size:v1',
|
||||
defaultPageSize: isMobile ? 10 : DEFAULT_PAGE_SIZE,
|
||||
},
|
||||
globalFilter: { enabled: true, key: 'filter' },
|
||||
columnFilters: [
|
||||
@@ -200,7 +201,6 @@ export function ModelsTable() {
|
||||
<DataTablePage
|
||||
table={table}
|
||||
columns={columns}
|
||||
tableLabel={t('Models')}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
emptyTitle={t('No Models Found')}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user