perf(web): improve dialog sizing and footer layout

- migrate frontend dialogs to the shared footer API so actions stay separated from scrollable body content.
- tune dialog dimensions for model analytics, prefill groups, billing history, channel model sync, and related workflows.
- update channel terminology and dialog action translations across supported locales.
This commit is contained in:
QuentinHsu
2026-06-06 21:49:33 +08:00
parent 7a5348caa3
commit 2eaa943d9f
80 changed files with 8137 additions and 8517 deletions
@@ -22,14 +22,6 @@ import { type Table } from '@tanstack/react-table'
import { Power, PowerOff, Tag, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -38,6 +30,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { Dialog } from '@/components/dialog'
import {
handleBatchDelete,
handleBatchDisable,
@@ -188,29 +181,21 @@ export function DataTableBulkActions<TData>({
</BulkActionsToolbar>
{/* Set Tag Dialog */}
<Dialog open={showTagDialog} onOpenChange={setShowTagDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Set Tag')}</DialogTitle>
<DialogDescription>
{t('Set a tag for')} {selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={showTagDialog}
onOpenChange={setShowTagDialog}
title={t('Set Tag')}
description={
<>
{t('Set a tag for')}
{selectedIds.length}{' '}
{t('selected channel(s). Leave empty to remove tag.')}
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => {
@@ -221,22 +206,37 @@ export function DataTableBulkActions<TData>({
{t('Cancel')}
</Button>
<Button onClick={handleSetTag}>{t('Set Tag')}</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='grid gap-4 py-4'>
<div className='grid gap-2'>
<Label htmlFor='tag'>{t('Tag')}</Label>
<Input
id='tag'
placeholder={t('Enter tag name (optional)')}
value={tagValue}
onChange={(e) => setTagValue(e.target.value)}
/>
</div>
</div>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Delete Channels?')}</DialogTitle>
<DialogDescription>
{t('Are you sure you want to delete')} {selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Dialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
title={t('Delete Channels?')}
description={
<>
{t('Are you sure you want to delete')}
{selectedIds.length}{' '}
{t('channel(s)? This action cannot be undone.')}
</>
}
contentHeight='auto'
footer={
<>
<Button
variant='outline'
onClick={() => setShowDeleteConfirm(false)}
@@ -246,8 +246,10 @@ export function DataTableBulkActions<TData>({
<Button variant='destructive' onClick={handleDeleteAll}>
{t('Delete')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{' '}
</Dialog>
</>
)
@@ -24,14 +24,7 @@ import { toast } from 'sonner'
import { formatCurrencyFromUSD } from '@/lib/currency'
import { formatTimestampToDate } from '@/lib/format'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Dialog } from '@/components/dialog'
import { getCodexUsage, updateChannelBalance } from '../../api'
import { channelsQueryKeys } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -161,53 +154,55 @@ export function BalanceQueryDialog({
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Query Balance')}</DialogTitle>
<DialogDescription>
{t('Update balance for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(
balanceUpdatedTime ?? currentRow.balance_updated_time
)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Query Balance')}
description={
<>
{t('Update balance for:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose} disabled={isQuerying}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
{/* Current Balance Display */}
<div className='bg-muted/50 rounded-lg border p-4'>
<div className='text-muted-foreground mb-2 flex items-center gap-2 text-sm'>
<DollarSign className='h-4 w-4' />
<span>{t('Current Balance')}</span>
</div>
<div className='text-2xl font-bold'>
{balance !== null
? formatBalance(balance)
: formatBalance(currentRow.balance)}
</div>
<div className='text-muted-foreground mt-2 text-xs'>
{t('Last updated:')}{' '}
{formatDate(balanceUpdatedTime ?? currentRow.balance_updated_time)}
</div>
</div>
{/* Balance Update Button */}
<Button
className='w-full'
onClick={handleQueryBalance}
disabled={isQuerying}
>
{isQuerying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{!isQuerying && <RefreshCw className='mr-2 h-4 w-4' />}
{isQuerying ? t('Querying...') : t('Update Balance')}
</Button>
</div>
</Dialog>
)
}
@@ -33,14 +33,6 @@ import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { useIsMobile } from '@/hooks/use-mobile'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
@@ -75,6 +67,7 @@ import {
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { DataTablePagination } from '@/components/data-table/pagination'
import { Dialog } from '@/components/dialog'
import {
sideDrawerContentClassName,
sideDrawerFooterClassName,
@@ -529,179 +522,184 @@ export function ChannelTestDialog({
return (
<>
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Test Channel Connection')}</DialogTitle>
<DialogDescription>
{t('Test connectivity for:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Test Channel Connection')}
description={
<>
{t('Test connectivity for:')}
<strong>{currentRow.name}</strong>
</>
}
contentClassName='max-h-[90vh] overflow-hidden sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-4 pr-1'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='grid gap-2'>
<Label htmlFor='endpoint-type'>{t('Endpoint Type')}</Label>
<Select
items={[
...endpointTypeOptions.map((option) => {
const itemValue = option.value
return { value: itemValue, label: t(option.label) }
}),
]}
value={endpointType}
onValueChange={(v) => v !== null && setEndpointType(v)}
>
<SelectTrigger id='endpoint-type'>
<SelectValue placeholder={t('Auto detect (default)')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{endpointTypeOptions.map((option) => {
const itemValue = option.value
return (
<SelectItem key={itemValue} value={itemValue}>
{t(option.label)}
</SelectItem>
)
})}
</SelectGroup>
</SelectContent>
</Select>
<p className='text-muted-foreground text-xs'>
{t(
'Override the endpoint used for testing. Leave empty to auto detect.'
)}
</p>
</div>
<div className='grid gap-2'>
<Label htmlFor='stream-toggle'>{t('Stream Mode')}</Label>
<div className='flex items-center gap-2'>
<Switch
id='stream-toggle'
checked={isStreamTest}
onCheckedChange={setIsStreamTest}
disabled={streamDisabled}
/>
<span className='text-sm'>
{isStreamTest ? t('Enabled') : t('Disabled')}
</span>
</div>
<p className='text-muted-foreground text-xs'>
{t('Enable streaming mode for the test request.')}
</p>
</div>
</div>
<div className='space-y-3 max-sm:has-[div[role="toolbar"]]:pb-16'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Channel models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models to run batch tests.')}
</p>
</div>
<Input
placeholder={t('Filter models...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className='sm:w-64'
/>
</div>
<div className='space-y-3'>
<div
className='overflow-hidden rounded-md border'
role='region'
aria-label={t('Channel models')}
>
<div className='max-h-90 overflow-auto **:data-[slot=table-container]:overflow-visible'>
<Table className='w-max min-w-full table-auto'>
<colgroup>
<col className='w-10 min-w-10' />
<col className='w-auto' />
<col className='w-70' />
<col className='w-24 sm:w-28' />
</colgroup>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={getTestTableColumnClass(
header.column.id
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={getTestTableColumnClass(
cell.column.id
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className='text-muted-foreground h-16 text-center text-sm'
>
{models.length
? 'No models matched your search.'
: 'This channel has no configured models.'}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<DataTablePagination table={table} />
</div>
<TestModelsBulkActions
table={table}
disabled={isAnyTesting}
onTestSelected={handleBatchTest}
/>
</div>
</div>
</Dialog>
<FailureDetailsSheet
details={failureDetails}
@@ -24,15 +24,8 @@ import { tryPrettyJson } from '@/lib/utils'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Dialog } from '@/components/dialog'
import { completeCodexOAuth, startCodexOAuth } from '../../api'
type CodexOAuthDialogProps = {
@@ -129,78 +122,18 @@ export function CodexOAuthDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{t('Codex Authorization')}</DialogTitle>
<DialogDescription>
{t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Authorization')}
description={t(
'Generate a Codex OAuth credential and paste it into the channel key field.'
)}
contentClassName='sm:max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -215,8 +148,68 @@ export function CodexOAuthDialog({
)}
{state.isCompleting ? t('Generating...') : t('Generate credential')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
<Alert>
<AlertDescription>
{t(
'1) Click "Open authorization page" and complete login. 2) Your browser may redirect to localhost (it is OK if the page does not load). 3) Copy the full URL from the address bar and paste it below. 4) Click "Generate credential".'
)}
</AlertDescription>
</Alert>
<div className='flex flex-wrap gap-2'>
<Button onClick={handleStart} disabled={state.isStarting}>
{state.isStarting ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<ExternalLink className='mr-2 h-4 w-4' />
)}
{t('Open authorization page')}
</Button>
<Button
type='button'
variant='outline'
disabled={!canCopyAuthorizeUrl}
onClick={async () => {
if (!state.authorizeUrl) return
await copyToClipboard(state.authorizeUrl)
}}
aria-label={t('Copy authorization link')}
title={t('Copy authorization link')}
>
{copiedText === state.authorizeUrl ? (
<Check className='mr-2 h-4 w-4 text-green-600' />
) : (
<Copy className='mr-2 h-4 w-4' />
)}
{t('Copy authorization link')}
</Button>
</div>
<div className='space-y-2'>
<div className='text-sm font-medium'>{t('Callback URL')}</div>
<Input
value={state.callbackUrl}
onChange={(e) =>
setState((prev) => ({ ...prev, callbackUrl: e.target.value }))
}
placeholder={t(
'Paste the full callback URL (includes code & state)'
)}
autoComplete='off'
spellCheck={false}
/>
<div className='text-muted-foreground text-xs'>
{t(
'Tip: The generated key is a JSON credential including access_token / refresh_token / account_id.'
)}
</div>
</div>
</div>
</Dialog>
)
}
@@ -31,16 +31,9 @@ import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Dialog } from '@/components/dialog'
import { StatusBadge, type StatusBadgeProps } from '@/components/status-badge'
type CodexRateLimitWindow = {
@@ -414,177 +407,23 @@ export function CodexUsageDialog({
}, [response])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-3xl'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Codex Account & Usage')}
</DialogTitle>
<DialogDescription>
{t('Channel:')} <strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{errorMessage && (
<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>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Codex Account & Usage')}
description={
<>
{t('Channel:')}
<strong>{channelName || '-'}</strong>{' '}
{channelId ? `(#${channelId})` : ''}
</>
}
contentClassName='sm:max-w-3xl'
titleClassName='flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -592,8 +431,166 @@ export function CodexUsageDialog({
>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{errorMessage && (
<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>
)}
{/* Account summary */}
<div className='rounded-lg border p-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex flex-wrap items-center gap-2'>
<StatusBadge
label={accountBadge.label}
variant={accountBadge.variant}
copyable={false}
/>
{statusBadge}
{typeof response?.upstream_status === 'number' && (
<StatusBadge
label={`${t('Status:')} ${response.upstream_status}`}
variant='neutral'
copyable={false}
/>
)}
</div>
{onRefresh && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onRefresh}
disabled={Boolean(isRefreshing)}
>
<RefreshCw className='mr-1.5 h-3.5 w-3.5' />
{t('Refresh')}
</Button>
)}
</div>
{/* Account identity info */}
<div className='bg-muted/30 mt-3 rounded-md px-3 py-2'>
<CopyableField
icon={<User className='h-3.5 w-3.5' />}
label='User ID'
value={payload?.user_id}
mono
/>
<CopyableField
icon={<Mail className='h-3.5 w-3.5' />}
label={t('Email')}
value={payload?.email}
/>
<CopyableField
icon={<Hash className='h-3.5 w-3.5' />}
label='Account ID'
value={payload?.account_id}
mono
/>
</div>
</div>
{/* Rate limit windows */}
<div className='space-y-5'>
<div>
<div className='mb-1 text-sm font-medium'>
{t('Rate Limit Windows')}
</div>
<p className='text-muted-foreground mb-3 text-xs'>
{t(
'Tracks current account base limits and additional metered usage on Codex upstream.'
)}
</p>
<RateLimitGroupSection
title={t('Base Limits')}
description={t('Base rate limit windows for this account.')}
source={payload}
/>
</div>
{additionalRateLimits.length > 0 && (
<div className='space-y-4 border-t pt-4'>
<div>
<div className='text-sm font-medium'>
{t('Additional Limits')}
</div>
<p className='text-muted-foreground text-xs'>
{t(
'Per-feature metered windows split by model or capability.'
)}
</p>
</div>
<div className='space-y-4'>
{additionalRateLimits.map((item, index) => {
const limitName =
item.limit_name ||
item.metered_feature ||
`${t('Additional Limit')} ${index + 1}`
return (
<div
key={`${limitName}-${item.metered_feature ?? ''}-${index}`}
className={index > 0 ? 'border-t pt-4' : ''}
>
<RateLimitGroupSection
title={limitName}
description={t('Additional metered capability')}
source={item}
meteredFeature={item.metered_feature}
/>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Raw JSON collapsible */}
<div className='rounded-lg border'>
<button
type='button'
className='hover:bg-muted/40 flex w-full items-center justify-between gap-2 p-3 transition-colors'
onClick={() => setShowRawJson((v) => !v)}
>
<div className='text-sm font-medium'>{t('Raw JSON')}</div>
{showRawJson ? (
<ChevronUp className='text-muted-foreground h-4 w-4' />
) : (
<ChevronDown className='text-muted-foreground h-4 w-4' />
)}
</button>
{showRawJson && (
<>
<div className='flex justify-end border-t px-3 py-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => copyToClipboard(rawJsonText)}
disabled={!rawJsonText}
>
{copiedText === rawJsonText ? (
<Check className='mr-1.5 h-3.5 w-3.5 text-green-600' />
) : (
<Copy className='mr-1.5 h-3.5 w-3.5' />
)}
{t('Copy')}
</Button>
</div>
<ScrollArea className='max-h-[50vh]'>
<pre className='bg-muted/30 m-0 p-3 text-xs break-words whitespace-pre-wrap'>
{rawJsonText || '-'}
</pre>
</ScrollArea>
</>
)}
</div>
</div>
</Dialog>
)
}
@@ -22,16 +22,9 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
import { handleCopyChannel } from '../../lib'
import { useChannels } from '../channels-provider'
@@ -74,45 +67,20 @@ export function CopyChannelDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Copy Channel')}</DialogTitle>
<DialogDescription>
{t('Create a copy of:')} <strong>{currentRow.name}</strong>
</DialogDescription>
</DialogHeader>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={t('Copy Channel')}
description={
<>
{t('Create a copy of:')}
<strong>{currentRow.name}</strong>
</>
}
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
variant='outline'
onClick={() => onOpenChange(false)}
@@ -122,10 +90,39 @@ export function CopyChannelDialog({
</Button>
<Button onClick={handleCopy} disabled={isCopying}>
{isCopying && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isCopying ? 'Copying...' : 'Copy Channel'}
{isCopying ? t('Copying...') : t('Copy Channel')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4 py-4'>
<div className='space-y-2'>
<Label htmlFor='suffix'>{t('Name Suffix')}</Label>
<Input
id='suffix'
placeholder={t('_copy')}
value={suffix}
onChange={(e) => setSuffix(e.target.value)}
disabled={isCopying}
/>
<p className='text-muted-foreground text-xs'>
{t('New name will be:')} {currentRow.name}
{suffix}
</p>
</div>
<div className='flex items-center space-x-2'>
<Checkbox
id='reset-balance'
checked={resetBalance}
onCheckedChange={(checked) => setResetBalance(!!checked)}
disabled={isCopying}
/>
<Label htmlFor='reset-balance' className='text-sm font-normal'>
{t('Reset balance and used quota')}
</Label>
</div>
</div>
</Dialog>
)
}
@@ -22,14 +22,6 @@ import { Loader2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ScrollArea } from '@/components/ui/scroll-area'
@@ -43,6 +35,7 @@ import {
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { GroupBadge } from '@/components/group-badge'
import { StatusBadge } from '@/components/status-badge'
import {
@@ -222,216 +215,23 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl'>
<DialogHeader>
<DialogTitle>
{t('Edit Tag:')} {currentTag}
</DialogTitle>
<DialogDescription>
{t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
</DialogDescription>
</DialogHeader>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
<DialogFooter>
<Dialog
open={open}
onOpenChange={handleClose}
title={
<>
{t('Edit Tag:')}
{currentTag}
</>
}
description={t(
'Batch edit all channels with this tag. Leave fields empty to keep current values.'
)}
contentClassName='max-h-[90vh] max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleClose}>
{t('Cancel')}
</Button>
@@ -439,8 +239,204 @@ export function EditTagDialog({ open, onOpenChange }: EditTagDialogProps) {
{isSubmitting && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{t('Save Changes')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<ScrollArea className='max-h-[60vh] pr-4'>
<div className='space-y-6'>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>
{t('Tag Name')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Leave empty to dissolve tag)')}
</span>
</Label>
<Input
id='new-tag'
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
placeholder={t('Enter new tag name or leave empty')}
/>
</div>
<Separator />
{/* Models */}
<div className='space-y-2'>
<Label>
{t('Models')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' models)")}
</span>
</Label>
{isLoadingTagModels ? (
<div className='flex items-center gap-2 py-4'>
<Loader2 className='h-4 w-4 animate-spin' />
<span className='text-muted-foreground text-sm'>
{t('Loading current models...')}
</span>
</div>
) : (
<>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{selectedModels.length > 0 ? (
selectedModels.map((model) => (
<StatusBadge
key={model}
variant='neutral'
className='cursor-pointer transition-opacity hover:opacity-70'
copyable={false}
onClick={() => handleRemoveModel(model)}
>
{model} ×
</StatusBadge>
))
) : (
<span className='text-muted-foreground text-sm'>
{t('No models selected')}
</span>
)}
</div>
<div className='flex gap-2'>
<Select<string>
items={[
...availableModels.map((model) => ({
value: model,
label: model,
})),
]}
onValueChange={(value) => {
if (value === null) return
if (!selectedModels.includes(value)) {
setSelectedModels([...selectedModels, value])
}
}}
>
<SelectTrigger className='flex-1'>
<SelectValue
placeholder={t('Add from available models...')}
/>
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<ScrollArea className='h-60'>
{availableModels.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</ScrollArea>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='flex gap-2'>
<Input
placeholder={t('Custom model (comma-separated)')}
value={customModel}
onChange={(e) => setCustomModel(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAddCustomModel()
}
}}
/>
<Button
type='button'
variant='secondary'
onClick={handleAddCustomModel}
>
{t('Add')}
</Button>
</div>
</>
)}
</div>
<Separator />
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>
{t('Model Mapping (JSON)')}
<span className='text-muted-foreground ml-2 text-xs'>
{t('(Optional: redirect model names)')}
</span>
</Label>
<Textarea
id='model-mapping'
value={modelMapping}
onChange={(e) => setModelMapping(e.target.value)}
placeholder={'{\n "gpt-3.5-turbo": "gpt-3.5-turbo-0125"\n}'}
rows={4}
className='font-mono text-sm'
/>
<div className='flex gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setModelMapping(
JSON.stringify(
{ 'gpt-3.5-turbo': 'gpt-3.5-turbo-0125' },
null,
2
)
)
}
>
{t('Example')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping(JSON.stringify({}, null, 2))}
>
{t('Clear Mapping')}
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => setModelMapping('')}
>
{t('No Change')}
</Button>
</div>
</div>
<Separator />
{/* Groups */}
<div className='space-y-2'>
<Label>
{t('Groups')}
<span className='text-muted-foreground ml-2 text-xs'>
{t("(Override all channels' groups)")}
</span>
</Label>
<div className='flex min-h-[60px] flex-wrap gap-2 rounded-md border p-3'>
{availableGroups.map((group) => (
<GroupBadge
key={group}
group={group}
className={`cursor-pointer rounded-sm transition-opacity hover:opacity-70 ${
selectedGroups.includes(group) ? 'bg-muted/70 px-1' : ''
}`}
onClick={() => handleToggleGroup(group)}
/>
))}
</div>
</div>
</div>
</ScrollArea>
</Dialog>
)
}
@@ -28,14 +28,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -44,6 +36,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Dialog } from '@/components/dialog'
import { fetchUpstreamModels, updateChannel } from '../../api'
import {
channelsQueryKeys,
@@ -365,152 +358,153 @@ export function FetchModelsDialog({
)
}
const showFooterActions =
!!(activeChannel || customFetcher) &&
!isFetching &&
(fetchedModels.length > 0 || removedModels.length > 0)
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Fetch Models')}</DialogTitle>
<DialogDescription>
{activeChannel ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Fetch available models for:')}{' '}
<strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)}
</DialogDescription>
</DialogHeader>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Fetch Models')}
description={
activeChannel ? (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) =>
renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
{t('Channel:')} <strong>{activeChannel.name}</strong>
</>
) : channelName ? (
<>
{t('Channel:')} <strong>{channelName}</strong>
</>
) : (
t('Fetch available models from upstream')
)
}
contentClassName='max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
showFooterActions ? (
<>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</>
) : null
}
>
{!activeChannel && !customFetcher ? (
<div className='text-muted-foreground py-8 text-center'>
{t('No channel selected')}
</div>
) : isFetching ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : fetchedModels.length === 0 && removedModels.length === 0 ? (
<div className='text-muted-foreground py-8 text-center'>
<p>{t('No models fetched yet.')}</p>
<Button
className='mt-4'
onClick={handleFetchModels}
disabled={isFetching}
>
{t('Fetch Models')}
</Button>
</div>
) : (
<>
<div className='space-y-4'>
{/* Search Bar */}
<div className='relative'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className='pl-9'
/>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
disabled={isSaving}
{/* Tabs for New vs Existing vs Removed */}
<Tabs
key={`${activeChannel?.id ?? 'custom'}-${fetchedModels.length}-${removedModels.length}`}
defaultValue={
newModels.length > 0
? 'new'
: removedModels.length > 0
? 'removed'
: 'existing'
}
>
<TabsList
className={`grid w-full ${removedModels.length > 0 ? 'grid-cols-3' : 'grid-cols-2'}`}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Models')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
<TabsTrigger value='new' disabled={newModels.length === 0}>
{t('New Models ({{count}})', { count: newModels.length })}
</TabsTrigger>
<TabsTrigger
value='existing'
disabled={existingFilteredModels.length === 0}
>
{t('Existing Models ({{count}})', {
count: existingFilteredModels.length,
})}
</TabsTrigger>
{removedModels.length > 0 && (
<TabsTrigger value='removed'>
{t('Removed Models ({{count}})', {
count: removedModels.length,
})}
</TabsTrigger>
)}
</TabsList>
<TabsContent
value='new'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(newModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
<TabsContent
value='existing'
className='max-h-96 space-y-2 overflow-y-auto'
>
{getSortedCategoryEntries(existingModelsByCategory).map(
([category, models]) => renderModelCategory(category, models)
)}
</TabsContent>
{removedModels.length > 0 && (
<TabsContent
value='removed'
className='max-h-96 space-y-2 overflow-y-auto'
>
<p className='text-muted-foreground text-xs'>
{t(
'These models are still in your selection but were not returned by the upstream listing. Entries that are only model_mapping source aliases are omitted. Toggle to adjust before saving.'
)}
</p>
{renderModelCategory(t('Removed'), removedModels)}
</TabsContent>
)}
</Tabs>
{/* Selection Summary */}
<div className='bg-muted/50 rounded-lg border p-3 text-sm'>
{t('{{n}} model(s) selected', { n: selectedModels.length })}
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -22,13 +22,6 @@ import { Loader2, RefreshCw, Trash2, Power, PowerOff } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
@@ -47,6 +40,7 @@ import {
TableRow,
} from '@/components/ui/table'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
import {
getMultiKeyStatus,
@@ -228,215 +222,217 @@ export function MultiKeyManageDialog({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='flex max-h-[90vh] max-w-5xl flex-col'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
{t('Multi-Key Management')}
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
{t('Multi-Key Management')}
<StatusBadge
label={currentRow.name}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={currentRow.name}
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
{currentRow.channel_info?.multi_key_mode && (
<StatusBadge
label={
currentRow.channel_info.multi_key_mode === 'random'
? t('Random')
: t('Polling')
}
variant='neutral'
copyable={false}
/>
)}
</DialogTitle>
<DialogDescription>
{t('Manage multi-key status and configuration for this channel')}
</DialogDescription>
</DialogHeader>
)}
</>
}
description={t(
'Manage multi-key status and configuration for this channel'
)}
contentClassName='flex max-h-[90vh] max-w-5xl flex-col'
titleClassName='flex items-center gap-2'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<div className='flex min-h-0 flex-1 flex-col space-y-4 overflow-hidden'>
{/* Statistics */}
<div className='grid shrink-0 grid-cols-3 gap-3'>
<StatisticsCard
label={t('Enabled')}
count={enabledCount}
total={total}
/>
<StatisticsCard
label={t('Manual Disabled')}
count={manualDisabledCount}
total={total}
/>
<StatisticsCard
label={t('Auto Disabled')}
count={autoDisabledCount}
total={total}
/>
</div>
<Separator className='shrink-0' />
<Separator className='shrink-0' />
{/* 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),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{/* 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),
})),
]}
value={statusFilter === null ? 'all' : statusFilter.toString()}
onValueChange={(v) => v !== null && handleStatusFilterChange(v)}
<div className='flex items-center gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
>
<SelectTrigger className='w-40'>
<SelectValue placeholder={t('All Status')} />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{MULTI_KEY_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<RefreshCw className='h-4 w-4' />
</Button>
<div className='flex items-center gap-2'>
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='outline'
variant='default'
size='sm'
onClick={() => loadKeyStatus()}
disabled={isLoading}
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<RefreshCw className='h-4 w-4' />
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{manualDisabledCount + autoDisabledCount > 0 && (
<Button
variant='default'
size='sm'
onClick={() => setConfirmAction({ type: 'enable-all' })}
>
<Power className='mr-2 h-4 w-4' />
{t('Enable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{enabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'disable-all' })}
>
<PowerOff className='mr-2 h-4 w-4' />
{t('Disable All')}
</Button>
)}
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() =>
setConfirmAction({ type: 'delete-disabled' })
}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{autoDisabledCount > 0 && (
<Button
variant='destructive'
size='sm'
onClick={() => setConfirmAction({ type: 'delete-disabled' })}
>
<Trash2 className='mr-2 h-4 w-4' />
{t('Delete Auto-Disabled')}
</Button>
)}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
{/* Table */}
<div className='min-h-0 flex-1 overflow-auto rounded-md border'>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : keys.length === 0 ? (
<div className='text-muted-foreground py-12 text-center'>
{t('No keys found')}
</div>
) : (
<div className='min-w-[800px]'>
<Table>
<TableHeader>
<TableRow>
<TableHead className='w-20'>{t('Index')}</TableHead>
<TableHead className='w-32'>{t('Status')}</TableHead>
<TableHead className='min-w-[200px]'>
{t('Disabled Reason')}
</TableHead>
<TableHead className='w-44'>
{t('Disabled Time')}
</TableHead>
<TableHead className='w-44 text-right'>
{t('Actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{keys.map((key) => (
<TableRow key={key.index}>
<TableCell className='font-mono text-sm'>
#{key.index + 1}
</TableCell>
<TableCell>{renderStatusBadge(key.status)}</TableCell>
<TableCell className='max-w-xs truncate text-sm'>
{key.reason || '-'}
</TableCell>
<TableCell className='text-muted-foreground text-sm'>
{formatKeyTimestamp(key.disabled_time)}
</TableCell>
<TableCell>
<MultiKeyTableRowActions
keyIndex={key.index}
status={key.status}
onAction={setConfirmAction}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</DialogContent>
{/* Pagination */}
{totalPages > 1 && (
<div className='flex shrink-0 items-center justify-between'>
<div className='text-muted-foreground text-sm'>
{t('Page {{current}} of {{total}}', {
current: currentPage,
total: totalPages,
})}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
>
{t('Previous')}
</Button>
<Button
variant='outline'
size='sm'
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages || isLoading}
>
{t('Next')}
</Button>
</div>
</div>
)}
</div>
</Dialog>
{/* Confirmation Dialog */}
@@ -34,18 +34,11 @@ import {
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Dialog } from '@/components/dialog'
import {
deleteOllamaModel,
fetchModels as fetchModelsFromEndpoint,
@@ -375,203 +368,203 @@ export function OllamaModelsDialog({
if (!open) return null
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className='max-h-[90vh] overflow-hidden sm:max-w-3xl'>
<DialogHeader>
<DialogTitle>{t('Ollama Models')}</DialogTitle>
<DialogDescription>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</DialogDescription>
</DialogHeader>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='max-h-[78vh] space-y-4 overflow-y-auto py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
<Dialog
open={open}
onOpenChange={close}
title={t('Ollama Models')}
description={
<>
{t('Manage local models for:')} <strong>{currentRow?.name}</strong>
</>
}
contentClassName='sm:max-w-3xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
}
>
{!isOllamaChannel ? (
<div className='text-muted-foreground py-8 text-center'>
{t('This channel is not an Ollama channel.')}
</div>
) : (
<div className='space-y-4 py-2 pr-1'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
<div className='flex-1 space-y-2'>
<Label htmlFor='ollama-pull'>{t('Pull model')}</Label>
<div className='flex gap-2'>
<Input
id='ollama-pull'
placeholder={t('e.g. llama3.1:8b')}
value={pullName}
onChange={(e) => setPullName(e.target.value)}
disabled={!channelId || isPulling}
/>
<Button
onClick={() => void pullModel()}
disabled={!channelId || isPulling}
>
{isPulling ? (
<>
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
{t('Pulling...')}
</>
) : (
<>
<Download className='mr-2 h-4 w-4' />
{t('Pull')}
</>
)}
</Button>
</div>
{pullProgress && (
<div className='space-y-2'>
<div className='text-muted-foreground text-xs'>
{t('Status:')} {String(pullProgress.status || '-')}
</div>
<Progress
value={
typeof pullProgress.completed === 'number' &&
typeof pullProgress.total === 'number' &&
pullProgress.total > 0
? Math.min(
100,
Math.round(
(pullProgress.completed / pullProgress.total) *
100
)
: 0
}
/>
)
: 0
}
/>
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) => toggleSelected(m.id, !!v)}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
<div className='flex gap-2'>
<Button
variant='outline'
onClick={() => void fetchOllamaModels()}
disabled={!channelId || isFetching}
>
{isFetching ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : (
<RefreshCw className='mr-2 h-4 w-4' />
)}
{t('Refresh')}
</Button>
</div>
</div>
<Separator />
<div className='space-y-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
<div>
<p className='text-sm font-medium'>{t('Local models')}</p>
<p className='text-muted-foreground text-xs'>
{t('Select models and apply to channel models list.')}
</p>
</div>
<div className='relative sm:w-72'>
<Search className='text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2' />
<Input
placeholder={t('Search models...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className='pl-9'
/>
</div>
</div>
<div className='flex flex-wrap gap-2'>
<Button variant='outline' size='sm' onClick={selectAllFiltered}>
{t('Select all (filtered)')}
</Button>
<Button variant='outline' size='sm' onClick={clearSelection}>
{t('Clear selection')}
</Button>
<Button
size='sm'
onClick={() => void applySelection('append')}
disabled={!selected.length}
>
{t('Append to channel')}
</Button>
<Button
variant='secondary'
size='sm'
onClick={() => void applySelection('replace')}
disabled={!selected.length}
>
{t('Replace channel models')}
</Button>
</div>
<div className='overflow-hidden rounded-md border'>
<div className='max-h-[420px] overflow-y-auto'>
{filteredModels.length === 0 ? (
<div className='text-muted-foreground p-6 text-center text-sm'>
{t('No models found.')}
</div>
) : (
<div className='divide-y'>
{filteredModels.map((m) => {
const checked = selected.includes(m.id)
return (
<div
key={m.id}
className='flex items-center justify-between gap-3 p-3'
>
<div className='flex min-w-0 items-start gap-3'>
<Checkbox
checked={checked}
onCheckedChange={(v) =>
toggleSelected(m.id, !!v)
}
aria-label={`Select model ${m.id}`}
/>
<div className='min-w-0'>
<div className='truncate font-mono text-sm'>
{m.id}
</div>
<div className='text-muted-foreground flex flex-wrap gap-x-3 gap-y-1 text-xs'>
<span>
{t('Size:')} {formatBytes(m.size)}
</span>
{m.digest && (
<span className='truncate'>
{t('Digest:')} {String(m.digest)}
</span>
)}
</div>
</div>
</div>
<Button
variant='ghost'
size='sm'
className='text-destructive hover:text-destructive'
onClick={() => {
setDeleteTarget(m.id)
setDeleteOpen(true)
}}
disabled={!channelId}
>
<Trash2 className='h-4 w-4' />
</Button>
</div>
)
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={close}>
{t('Close')}
</Button>
</DialogFooter>
</DialogContent>
</div>
)}
<AlertDialog
open={deleteOpen}
@@ -43,14 +43,6 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
@@ -63,6 +55,7 @@ import {
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
// ---------------------------------------------------------------------------
// Types
@@ -1701,356 +1694,20 @@ export function ParamOverrideEditorDialog(
// ---------------------------------------------------------------------------
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'>
<DialogHeader className='border-b px-6 py-4'>
<DialogTitle>{t('Parameter Override')}</DialogTitle>
<DialogDescription>
{t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
</DialogDescription>
</DialogHeader>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() =>
setSelectedOperationId(operation.id)
}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) =>
handleDragOver(e, operation.id)
}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<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-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(
operation.mode || 'set'
)
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON text directly. Format will be validated on save.'
)}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className='border-t px-6 py-4'>
<Dialog
open={props.open}
onOpenChange={props.onOpenChange}
title={t('Parameter Override')}
description={t(
'Create request parameter override rules with a visual editor or raw JSON.'
)}
contentClassName='flex max-h-[90vh] flex-col gap-0 p-0 sm:max-w-5xl'
headerClassName='border-b px-6 py-4'
footerClassName='border-t px-6 py-4'
contentHeight='min(72vh, 720px)'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='outline'
@@ -2061,8 +1718,337 @@ export function ParamOverrideEditorDialog(
<Button type='button' onClick={handleSave}>
{t('Save')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
{/* Toolbar */}
<div className='bg-muted/30 border-b px-4 py-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='text-muted-foreground text-xs font-medium'>
{t('Mode')}
</span>
<Button
type='button'
variant={editMode === 'visual' ? 'default' : 'outline'}
size='sm'
onClick={switchToVisualMode}
>
{t('Visual')}
</Button>
<Button
type='button'
variant={editMode === 'json' ? 'default' : 'outline'}
size='sm'
onClick={switchToJsonMode}
>
{t('JSON Text')}
</Button>
<div className='bg-border mx-1 h-5 w-px' />
<span className='text-muted-foreground text-xs font-medium'>
{t('Template')}
</span>
<Select
items={[
...templatePresetOptions.map((o) => ({
value: o.value,
label: t(o.label),
})),
]}
value={templatePresetKey}
onValueChange={(v) =>
setTemplatePresetKey(v || 'operations_default')
}
>
<SelectTrigger className='h-8 w-[220px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
{templatePresetOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{t(o.label)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => fillTemplate('fill')}
>
{t('Fill Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={() => fillTemplate('append')}
>
{t('Append Template')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
onClick={resetEditorState}
>
{t('Reset')}
</Button>
</div>
</div>
{/* Content */}
<div className='min-h-0 flex-1 overflow-hidden'>
{editMode === 'visual' ? (
visualMode === 'legacy' ? (
<div className='p-4'>
<p className='text-muted-foreground mb-2 text-sm'>
{t('Legacy Format (JSON Object)')}
</p>
<Textarea
value={legacyValue}
onChange={(e) => setLegacyValue(e.target.value)}
placeholder={JSON.stringify(LEGACY_TEMPLATE, null, 2)}
rows={14}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Edit JSON object directly. Suitable for simple parameter overrides.'
)}
</p>
</div>
) : (
<div className='flex h-full'>
{/* Left sidebar */}
<div className='flex w-[280px] flex-shrink-0 flex-col border-r'>
<div className='flex items-center justify-between border-b px-3 py-2'>
<div className='flex items-center gap-2'>
<span className='text-sm font-medium'>{t('Rules')}</span>
<Badge variant='secondary'>
{operationCount}/{operations.length}
</Badge>
</div>
<Button
type='button'
variant='ghost'
size='sm'
onClick={addOperation}
>
<Plus className='h-4 w-4' />
</Button>
</div>
{topOperationModes.length > 0 && (
<div className='flex flex-wrap gap-1 border-b px-3 py-2'>
{topOperationModes.map(([mode, count]) => (
<span
key={`mode_stat_${mode}`}
className={cn(
'inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(mode)
)}
>
{t(OPERATION_MODE_LABEL_MAP[mode] || mode)} · {count}
</span>
))}
</div>
)}
<div className='px-3 py-2'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-3.5 w-3.5' />
<Input
value={operationSearch}
onChange={(e) => setOperationSearch(e.target.value)}
placeholder={t('Search rules...')}
className='h-8 pl-8 text-xs'
/>
</div>
</div>
<ScrollArea className='flex-1'>
<div className='flex flex-col gap-1 px-3 pb-3'>
{filteredOperations.length === 0 ? (
<p className='text-muted-foreground py-4 text-center text-xs'>
{t('No matching rules')}
</p>
) : (
filteredOperations.map((operation) => {
const index = operations.findIndex(
(o) => o.id === operation.id
)
const isActive = operation.id === selectedOperationId
const isDragging = operation.id === draggedOperationId
const isDropTarget =
operation.id === dragOverOperationId &&
draggedOperationId !== '' &&
draggedOperationId !== operation.id
return (
<div
key={operation.id}
role='button'
tabIndex={0}
draggable={operations.length > 1}
onClick={() => setSelectedOperationId(operation.id)}
onDragStart={(e) =>
handleDragStart(e, operation.id)
}
onDragOver={(e) => handleDragOver(e, operation.id)}
onDrop={(e) => handleDrop(e, operation.id)}
onDragEnd={resetDragState}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setSelectedOperationId(operation.id)
}
}}
className={cn(
'cursor-pointer rounded-lg border p-2.5 transition-colors',
isActive
? 'border-primary bg-primary/5'
: 'hover:bg-muted/50',
isDragging && 'opacity-50',
isDropTarget &&
dragOverPosition === 'before' &&
'border-t-primary border-t-2',
isDropTarget &&
dragOverPosition === 'after' &&
'border-b-primary border-b-2'
)}
>
<div className='flex items-start gap-2'>
<GripVertical
className={cn(
'text-muted-foreground mt-0.5 h-3.5 w-3.5 flex-shrink-0',
operations.length > 1
? 'cursor-grab'
: 'cursor-default'
)}
/>
<div className='min-w-0 flex-1'>
<div className='flex items-center justify-between gap-1'>
<span className='text-xs font-semibold'>
#{index + 1}
</span>
<Badge
variant='outline'
className='text-[10px]'
>
{operation.conditions.length}
</Badge>
</div>
<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-[10px]'>
{operation.description}
</p>
)}
<span
className={cn(
'mt-1 inline-flex items-center rounded-md border px-1.5 py-0.5 text-[10px] font-medium',
getModeTagTailwind(operation.mode || 'set')
)}
>
{t(
OPERATION_MODE_LABEL_MAP[
operation.mode || 'set'
] ||
operation.mode ||
'set'
)}
</span>
</div>
</div>
</div>
)
})
)}
</div>
</ScrollArea>
</div>
{/* Right panel - Rule editor */}
<div className='flex min-w-0 flex-1 flex-col overflow-y-auto'>
{selectedOperation ? (
<RuleEditor
operation={selectedOperation}
operationIndex={selectedOperationIndex}
operations={operations}
returnErrorDraft={returnErrorDraft}
pruneObjectsDraft={pruneObjectsDraft}
expandedConditions={expandedConditions}
setExpandedConditions={setExpandedConditions}
updateOperation={updateOperation}
duplicateOperation={duplicateOperation}
removeOperation={removeOperation}
addCondition={addCondition}
updateCondition={updateCondition}
removeCondition={removeCondition}
updateReturnErrorDraft={updateReturnErrorDraft}
updatePruneObjectsDraft={updatePruneObjectsDraft}
addPruneRule={addPruneRule}
updatePruneRule={updatePruneRule}
removePruneRule={removePruneRule}
expandAllConditions={expandAllConditions}
collapseAllConditions={collapseAllConditions}
/>
) : (
<div className='flex flex-1 items-center justify-center'>
<p className='text-muted-foreground text-sm'>
{t('Select a rule to edit.')}
</p>
</div>
)}
{visualValidationError && (
<div className='border-t px-4 py-2'>
<p className='text-destructive text-xs'>
{visualValidationError}
</p>
</div>
)}
</div>
</div>
)
) : (
/* JSON mode */
<div className='p-4'>
<div className='mb-2 flex items-center gap-2'>
<Button
type='button'
variant='outline'
size='sm'
onClick={formatJson}
>
{t('Format')}
</Button>
<span className='text-muted-foreground text-xs'>
{t('Advanced text editing')}
</span>
</div>
<Textarea
value={jsonText}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={JSON.stringify(OPERATION_TEMPLATE, null, 2)}
rows={20}
className='font-mono text-xs'
/>
<p className='text-muted-foreground mt-2 text-xs'>
{t('Edit JSON text directly. Format will be validated on save.')}
</p>
{jsonError && (
<p className='text-destructive mt-1 text-xs'>{jsonError}</p>
)}
</div>
)}
</div>
{/* Footer */}
</Dialog>
)
}
@@ -21,16 +21,9 @@ import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog } from '@/components/dialog'
interface StatusCodeRiskDialogProps {
open: boolean
@@ -84,73 +77,22 @@ export function StatusCodeRiskDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle className='text-destructive flex items-center gap-2'>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</DialogTitle>
<DialogDescription>
{t('High-risk status code retry risk disclaimer')}
</DialogDescription>
</DialogHeader>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
<DialogFooter>
<Dialog
open={open}
onOpenChange={onOpenChange}
title={
<>
<AlertTriangle className='h-5 w-5' />
{t('High-risk operation confirmation')}
</>
}
description={t('High-risk status code retry risk disclaimer')}
contentClassName='max-w-lg'
titleClassName='text-destructive flex items-center gap-2'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={handleCancel}>
{t('Cancel')}
</Button>
@@ -161,8 +103,62 @@ export function StatusCodeRiskDialog({
>
{t('I confirm enabling high-risk retry')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<div className='space-y-4'>
{detailItems.length > 0 && (
<div className='border-destructive/30 bg-destructive/5 rounded-lg border p-3'>
<p className='mb-2 text-sm font-medium'>
{t('Detected high-risk status code redirect rules')}
</p>
<ul className='list-inside list-disc text-sm'>
{detailItems.map((item) => (
<li key={item} className='font-mono text-xs'>
{item}
</li>
))}
</ul>
</div>
)}
<div className='space-y-2'>
{CHECKLIST_KEYS.map((key, idx) => (
<div key={key} className='flex items-start gap-2'>
<Checkbox
id={`risk-check-${idx}`}
checked={checkedItems.has(idx)}
onCheckedChange={() => toggleCheck(idx)}
/>
<Label
htmlFor={`risk-check-${idx}`}
className='text-sm leading-tight'
>
{t(key)}
</Label>
</div>
))}
</div>
<div className='space-y-1.5'>
<Label className='text-sm'>
{t('Action confirmation')}:{' '}
<code className='bg-muted rounded px-1 text-xs'>
{requiredText}
</code>
</Label>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={t('High-risk status code retry input placeholder')}
/>
{confirmText && !textMatches && (
<p className='text-destructive text-xs'>
{t('High-risk status code retry input mismatch')}
</p>
)}
</div>
</div>
</Dialog>
)
}
@@ -23,18 +23,11 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { Dialog } from '@/components/dialog'
import { MultiSelect } from '@/components/multi-select'
import {
getTagModels,
@@ -190,115 +183,118 @@ export function TagBatchEditDialog({
if (!currentTag) return null
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className='max-h-[90vh] max-w-2xl overflow-y-auto'>
<DialogHeader>
<DialogTitle>{t('Batch Edit by Tag')}</DialogTitle>
<DialogDescription>
{t('Edit all channels with tag:')} <strong>{currentTag}</strong>
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<Dialog
open={open}
onOpenChange={handleClose}
title={t('Batch Edit by Tag')}
description={
<>
{t('Edit all channels with tag:')}
<strong>{currentTag}</strong>
</>
}
contentClassName='max-w-2xl'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
!isLoading ? (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t(
'Select groups (leave empty to keep current)'
)}
/>
<Button variant='outline' onClick={handleClose} disabled={isSaving}>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
) : null}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</>
) : null
}
>
{isLoading ? (
<div className='flex items-center justify-center py-12'>
<Loader2 className='text-muted-foreground h-8 w-8 animate-spin' />
</div>
) : (
<>
<div className='space-y-4 py-4'>
<Alert>
<AlertCircle className='h-4 w-4' />
<AlertDescription>
{t(
'All edits are overwrite operations. Leave fields empty to keep current values unchanged.'
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</AlertDescription>
</Alert>
{/* Tag Name */}
<div className='space-y-2'>
<Label htmlFor='new-tag'>{t('Tag Name')}</Label>
<Input
id='new-tag'
placeholder={t(
'Enter new tag name (leave empty to disband tag)'
)}
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
disabled={isSaving}
/>
<p className='text-muted-foreground text-xs'>
{t('Leave empty to disband the tag')}
</p>
</div>
<DialogFooter>
<Button
variant='outline'
onClick={handleClose}
{/* Models */}
<div className='space-y-2'>
<Label htmlFor='models'>{t('Models')}</Label>
<Textarea
id='models'
placeholder={t(
'Comma-separated model names (leave empty to keep current)'
)}
value={models}
onChange={(e) => setModels(e.target.value)}
disabled={isSaving}
>
{t('Cancel')}
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className='mr-2 h-4 w-4 animate-spin' />}
{isSaving ? t('Saving...') : t('Save Changes')}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
rows={3}
/>
<p className='text-muted-foreground text-xs'>
{t(
'Current models for the longest channel in this tag. May not include all models from all channels.'
)}
</p>
</div>
{/* Model Mapping */}
<div className='space-y-2'>
<Label htmlFor='model-mapping'>{t('Model Mapping')}</Label>
<ModelMappingEditor
value={modelMapping}
onChange={setModelMapping}
disabled={isSaving}
/>
</div>
{/* Groups */}
<div className='space-y-2'>
<Label htmlFor='groups'>{t('Groups')}</Label>
{isLoadingGroups ? (
<Skeleton className='h-10 w-full' />
) : (
<MultiSelect
options={groupOptions}
selected={groups}
onChange={setGroups}
placeholder={t('Select groups (leave empty to keep current)')}
/>
)}
<p className='text-muted-foreground text-xs'>
{t('User groups that can access channels with this tag')}
</p>
</div>
</div>
</>
)}
</Dialog>
)
}
@@ -21,17 +21,11 @@ import { Search } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Dialog } from '@/components/dialog'
import { StatusBadge } from '@/components/status-badge'
interface UpstreamUpdateDialogProps {
@@ -120,157 +114,15 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
return (
<>
<Dialog open={props.open} onOpenChange={(v) => !v && props.onCancel()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{t('Upstream Model Updates')}</DialogTitle>
</DialogHeader>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge
variant='neutral'
className='ml-1'
copyable={false}
>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(
model,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
<DialogFooter>
<Dialog
open={props.open}
onOpenChange={(v) => !v && props.onCancel()}
title={t('Upstream Model Updates')}
contentClassName='sm:max-w-lg'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button variant='outline' onClick={props.onCancel}>
{t('Cancel')}
</Button>
@@ -284,8 +136,139 @@ export function UpstreamUpdateDialog(props: UpstreamUpdateDialogProps) {
>
{t('Confirm')}
</Button>
</DialogFooter>
</DialogContent>
</>
}
>
<p className='text-muted-foreground text-sm'>
{t(
'Select models to process. Unselected "add" models will be ignored.'
)}
</p>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as 'add' | 'remove')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='add' className='gap-1'>
{t('Add Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedAdd.size}/{props.addModels.length}
</StatusBadge>
</TabsTrigger>
<TabsTrigger value='remove' className='gap-1'>
{t('Remove Models')}
<StatusBadge variant='neutral' className='ml-1' copyable={false}>
{selectedRemove.size}/{props.removeModels.length}
</StatusBadge>
</TabsTrigger>
</TabsList>
<TabsContent value='add' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchAdd}
onChange={(e) => setSearchAdd(e.target.value)}
/>
</div>
{filteredAdd.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredAdd.every((m) => selectedAdd.has(m))}
onCheckedChange={() =>
toggleAllVisible(filteredAdd, selectedAdd, setSelectedAdd)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredAdd.length > 0 ? (
<div className='space-y-1'>
{filteredAdd.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedAdd.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedAdd, setSelectedAdd)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.addModels.length === 0
? t('No models to add')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
<TabsContent value='remove' className='space-y-3'>
<div className='relative'>
<Search className='text-muted-foreground absolute top-2.5 left-2.5 h-4 w-4' />
<Input
placeholder={t('Search models...')}
className='pl-8'
value={searchRemove}
onChange={(e) => setSearchRemove(e.target.value)}
/>
</div>
{filteredRemove.length > 0 && (
<div className='flex items-center gap-2'>
<Checkbox
checked={filteredRemove.every((m) => selectedRemove.has(m))}
onCheckedChange={() =>
toggleAllVisible(
filteredRemove,
selectedRemove,
setSelectedRemove
)
}
/>
<span className='text-muted-foreground text-xs'>
{t('Select All Visible')}
</span>
</div>
)}
<ScrollArea className='h-[280px] rounded-md border p-2'>
{filteredRemove.length > 0 ? (
<div className='space-y-1'>
{filteredRemove.map((model) => (
<label
key={model}
className='hover:bg-accent flex cursor-pointer items-center gap-2 rounded px-2 py-1.5'
>
<Checkbox
checked={selectedRemove.has(model)}
onCheckedChange={() =>
toggleModel(model, selectedRemove, setSelectedRemove)
}
/>
<span className='truncate text-sm'>{model}</span>
</label>
))}
</div>
) : (
<p className='text-muted-foreground py-8 text-center text-sm'>
{props.removeModels.length === 0
? t('No models to remove')
: t('No matching results')}
</p>
)}
</ScrollArea>
</TabsContent>
</Tabs>
</Dialog>
<ConfirmDialog