Refactor the usage log filter toolbar into a shared reusable component for common, drawing, and task logs. Optimize desktop filters with a responsive grid, move secondary filters into a mobile drawer, standardize filter typography, remove redundant filter icons, and add the missing i18n translations for the new drawer description.
656 lines
21 KiB
TypeScript
Vendored
656 lines
21 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { Edit, FileText, Plus, RefreshCw, Trash2, X } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { StatusBadge, StatusBadgeList } from '@/components/status-badge'
|
|
import { SettingsSwitchField } from '../../components/settings-form-layout'
|
|
import { SettingsPageActionsPortal } from '../../components/settings-page-context'
|
|
import { SettingsSection } from '../../components/settings-section'
|
|
import { useUpdateOption } from '../../hooks/use-update-option'
|
|
import { getCacheStats, clearAllCache, clearRuleCache } from './api'
|
|
import { RULE_TEMPLATES, cloneTemplate, makeUniqueName } from './constants'
|
|
import { RuleEditorDialog } from './rule-editor-dialog'
|
|
import type { AffinityRule, CacheStats, ChannelAffinitySettings } from './types'
|
|
|
|
function parseRules(jsonStr: string): AffinityRule[] {
|
|
try {
|
|
const arr = JSON.parse(jsonStr || '[]')
|
|
if (!Array.isArray(arr)) return []
|
|
return arr.map(
|
|
(r: Record<string, unknown>, i: number) =>
|
|
({ id: i, ...r }) as AffinityRule
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
function RuleBadgeList(props: { items: string[] }) {
|
|
return (
|
|
<StatusBadgeList
|
|
items={props.items}
|
|
max={2}
|
|
getKey={(item) => item}
|
|
renderItem={(item) => (
|
|
<StatusBadge
|
|
label={item}
|
|
variant='neutral'
|
|
size='sm'
|
|
copyable={false}
|
|
/>
|
|
)}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function serializeRules(rules: AffinityRule[]): string {
|
|
return JSON.stringify(rules.map(({ id: _, ...rest }) => rest))
|
|
}
|
|
|
|
interface Props {
|
|
defaultValues: ChannelAffinitySettings
|
|
}
|
|
|
|
export function ChannelAffinitySection(props: Props) {
|
|
const { t } = useTranslation()
|
|
const updateOption = useUpdateOption()
|
|
|
|
const [enabled, setEnabled] = useState(
|
|
props.defaultValues['channel_affinity_setting.enabled']
|
|
)
|
|
const [switchOnSuccess, setSwitchOnSuccess] = useState(
|
|
props.defaultValues['channel_affinity_setting.switch_on_success']
|
|
)
|
|
const [maxEntries, setMaxEntries] = useState(
|
|
props.defaultValues['channel_affinity_setting.max_entries']
|
|
)
|
|
const [defaultTtl, setDefaultTtl] = useState(
|
|
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
|
)
|
|
const [rules, setRules] = useState<AffinityRule[]>(() =>
|
|
parseRules(props.defaultValues['channel_affinity_setting.rules'])
|
|
)
|
|
|
|
const [editMode, setEditMode] = useState<'visual' | 'json'>('visual')
|
|
const [jsonText, setJsonText] = useState(() =>
|
|
JSON.stringify(
|
|
parseRules(props.defaultValues['channel_affinity_setting.rules']).map(
|
|
({ id: _, ...r }) => r
|
|
),
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
const [cacheStats, setCacheStats] = useState<CacheStats | null>(null)
|
|
const [cacheLoading, setCacheLoading] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const [ruleEditorOpen, setRuleEditorOpen] = useState(false)
|
|
const [editingRule, setEditingRule] = useState<AffinityRule | null>(null)
|
|
const [ruleTemplateKey, setRuleTemplateKey] = useState<string | null>(null)
|
|
const [clearAllDialogOpen, setClearAllDialogOpen] = useState(false)
|
|
const [clearRuleName, setClearRuleName] = useState<string | null>(null)
|
|
const [fillTemplateDialogOpen, setFillTemplateDialogOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setEnabled(props.defaultValues['channel_affinity_setting.enabled'])
|
|
setSwitchOnSuccess(
|
|
props.defaultValues['channel_affinity_setting.switch_on_success']
|
|
)
|
|
setMaxEntries(props.defaultValues['channel_affinity_setting.max_entries'])
|
|
setDefaultTtl(
|
|
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
|
)
|
|
const parsed = parseRules(
|
|
props.defaultValues['channel_affinity_setting.rules']
|
|
)
|
|
setRules(parsed)
|
|
setJsonText(
|
|
JSON.stringify(
|
|
parsed.map(({ id: _, ...r }) => r),
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
}, [props.defaultValues])
|
|
|
|
const refreshCache = useCallback(async () => {
|
|
setCacheLoading(true)
|
|
try {
|
|
const res = await getCacheStats()
|
|
if (res.success) setCacheStats(res.data || null)
|
|
} catch {
|
|
toast.error(t('Failed to refresh cache stats'))
|
|
} finally {
|
|
setCacheLoading(false)
|
|
}
|
|
}, [t])
|
|
|
|
useEffect(() => {
|
|
refreshCache()
|
|
}, [refreshCache])
|
|
|
|
const appendCliTemplates = () => {
|
|
const existingNames = new Set(
|
|
rules.map((r) => (r.name || '').trim()).filter((x) => x.length > 0)
|
|
)
|
|
|
|
const templates = Object.values(RULE_TEMPLATES).map((tpl) => {
|
|
const base = cloneTemplate(tpl)
|
|
const name = makeUniqueName(existingNames, tpl.name)
|
|
existingNames.add(name)
|
|
return { ...base, name }
|
|
})
|
|
|
|
setRules((prev) =>
|
|
[...prev, ...templates].map((r, idx) => ({ ...r, id: idx }))
|
|
)
|
|
toast.success(t('Templates appended'))
|
|
setFillTemplateDialogOpen(false)
|
|
}
|
|
|
|
const handleFillTemplates = () => {
|
|
if (rules.length === 0) {
|
|
appendCliTemplates()
|
|
} else {
|
|
setFillTemplateDialogOpen(true)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
let rulesJson: string
|
|
if (editMode === 'json') {
|
|
try {
|
|
const parsed = JSON.parse(jsonText)
|
|
if (!Array.isArray(parsed)) {
|
|
toast.error(t('Rules JSON must be an array'))
|
|
return
|
|
}
|
|
rulesJson = JSON.stringify(parsed)
|
|
} catch {
|
|
toast.error(t('Invalid rules JSON format'))
|
|
return
|
|
}
|
|
} else {
|
|
rulesJson = serializeRules(rules)
|
|
}
|
|
|
|
setSaving(true)
|
|
try {
|
|
const updates: { key: string; value: string }[] = []
|
|
|
|
if (enabled !== props.defaultValues['channel_affinity_setting.enabled'])
|
|
updates.push({
|
|
key: 'channel_affinity_setting.enabled',
|
|
value: String(enabled),
|
|
})
|
|
if (
|
|
switchOnSuccess !==
|
|
props.defaultValues['channel_affinity_setting.switch_on_success']
|
|
)
|
|
updates.push({
|
|
key: 'channel_affinity_setting.switch_on_success',
|
|
value: String(switchOnSuccess),
|
|
})
|
|
if (
|
|
maxEntries !==
|
|
props.defaultValues['channel_affinity_setting.max_entries']
|
|
)
|
|
updates.push({
|
|
key: 'channel_affinity_setting.max_entries',
|
|
value: String(maxEntries),
|
|
})
|
|
if (
|
|
defaultTtl !==
|
|
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
|
|
)
|
|
updates.push({
|
|
key: 'channel_affinity_setting.default_ttl_seconds',
|
|
value: String(defaultTtl),
|
|
})
|
|
|
|
const origRules = props.defaultValues['channel_affinity_setting.rules']
|
|
const origSerialized = (() => {
|
|
try {
|
|
return JSON.stringify(JSON.parse(origRules || '[]'))
|
|
} catch {
|
|
return '[]'
|
|
}
|
|
})()
|
|
if (rulesJson !== origSerialized) {
|
|
updates.push({
|
|
key: 'channel_affinity_setting.rules',
|
|
value: rulesJson,
|
|
})
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
toast.info(t('No changes'))
|
|
return
|
|
}
|
|
|
|
for (const u of updates) {
|
|
await updateOption.mutateAsync(u)
|
|
}
|
|
toast.success(t('Saved successfully'))
|
|
} catch {
|
|
toast.error(t('Failed to save'))
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleRuleSave = (rule: AffinityRule) => {
|
|
setRules((prev) => {
|
|
const existIdx = prev.findIndex(
|
|
(r) => r.id === rule.id || (rule.name && r.name === editingRule?.name)
|
|
)
|
|
if (existIdx >= 0) {
|
|
const next = [...prev]
|
|
next[existIdx] = { ...rule, id: existIdx }
|
|
return next
|
|
}
|
|
return [...prev, { ...rule, id: prev.length }]
|
|
})
|
|
setEditingRule(null)
|
|
}
|
|
|
|
const handleDeleteRule = (idx: number) => {
|
|
setRules((prev) =>
|
|
prev.filter((_, i) => i !== idx).map((r, i) => ({ ...r, id: i }))
|
|
)
|
|
toast.success(t('Deleted successfully'))
|
|
}
|
|
|
|
const handleClearAll = async () => {
|
|
const res = await clearAllCache()
|
|
if (res.success) {
|
|
toast.success(t('Cleared'))
|
|
refreshCache()
|
|
}
|
|
setClearAllDialogOpen(false)
|
|
}
|
|
|
|
const handleClearRule = async () => {
|
|
if (!clearRuleName) return
|
|
const res = await clearRuleCache(clearRuleName)
|
|
if (res.success) {
|
|
toast.success(t('Cleared'))
|
|
refreshCache()
|
|
}
|
|
setClearRuleName(null)
|
|
}
|
|
|
|
const switchToJsonMode = () => {
|
|
setJsonText(
|
|
JSON.stringify(
|
|
rules.map(({ id: _, ...r }) => r),
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
setEditMode('json')
|
|
}
|
|
|
|
const switchToVisualMode = () => {
|
|
try {
|
|
const parsed = JSON.parse(jsonText)
|
|
if (!Array.isArray(parsed)) {
|
|
toast.error(t('Rules JSON must be an array'))
|
|
return
|
|
}
|
|
setRules(
|
|
parsed.map(
|
|
(r: Record<string, unknown>, i: number) =>
|
|
({ id: i, ...r }) as AffinityRule
|
|
)
|
|
)
|
|
setEditMode('visual')
|
|
} catch {
|
|
toast.error(t('Invalid rules JSON format'))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection title={t('Channel Affinity')}>
|
|
<Alert>
|
|
<AlertDescription className='text-xs'>
|
|
{t(
|
|
'Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.'
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* Basic Settings */}
|
|
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
|
|
<SettingsSwitchField
|
|
checked={enabled}
|
|
onCheckedChange={setEnabled}
|
|
label={t('Enable')}
|
|
className='border-b-0 py-0'
|
|
/>
|
|
<div className='grid gap-1.5'>
|
|
<Label>{t('Max Entries')}</Label>
|
|
<Input
|
|
type='number'
|
|
min={0}
|
|
value={maxEntries}
|
|
onChange={(e) => setMaxEntries(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<div className='grid gap-1.5'>
|
|
<Label>{t('Default TTL (seconds)')}</Label>
|
|
<Input
|
|
type='number'
|
|
min={0}
|
|
value={defaultTtl}
|
|
onChange={(e) => setDefaultTtl(Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<SettingsSwitchField
|
|
checked={switchOnSuccess}
|
|
onCheckedChange={setSwitchOnSuccess}
|
|
label={t('Switch affinity on success')}
|
|
description={t(
|
|
'If the affinity channel fails and retry succeeds on another channel, update affinity to the successful channel.'
|
|
)}
|
|
/>
|
|
|
|
<Separator />
|
|
|
|
<SettingsPageActionsPortal>
|
|
<Button
|
|
variant={editMode === 'visual' ? 'default' : 'outline'}
|
|
size='sm'
|
|
onClick={editMode === 'json' ? switchToVisualMode : undefined}
|
|
>
|
|
{t('Visual')}
|
|
</Button>
|
|
<Button
|
|
variant={editMode === 'json' ? 'default' : 'outline'}
|
|
size='sm'
|
|
onClick={editMode === 'visual' ? switchToJsonMode : undefined}
|
|
>
|
|
JSON
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<Button variant='outline' size='sm' />}
|
|
>
|
|
<Plus className='mr-1 h-3 w-3' />
|
|
{t('Add Rule')}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setEditingRule(null)
|
|
setRuleTemplateKey(null)
|
|
setRuleEditorOpen(true)
|
|
}}
|
|
>
|
|
{t('Blank Rule')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setEditingRule(null)
|
|
setRuleTemplateKey('codexCli')
|
|
setRuleEditorOpen(true)
|
|
}}
|
|
>
|
|
Codex CLI
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setEditingRule(null)
|
|
setRuleTemplateKey('claudeCli')
|
|
setRuleEditorOpen(true)
|
|
}}
|
|
>
|
|
Claude CLI
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button variant='outline' size='sm' onClick={handleFillTemplates}>
|
|
<FileText className='mr-1 h-3 w-3' />
|
|
{t('Fill Templates')}
|
|
</Button>
|
|
<Button size='sm' onClick={handleSave} disabled={saving}>
|
|
{saving ? t('Saving...') : t('Save')}
|
|
</Button>
|
|
<Button
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={refreshCache}
|
|
disabled={cacheLoading}
|
|
>
|
|
<RefreshCw
|
|
className={`mr-1 h-3 w-3 ${cacheLoading ? 'animate-spin' : ''}`}
|
|
/>
|
|
{t('Refresh Cache')}
|
|
</Button>
|
|
<Button
|
|
variant='destructive'
|
|
size='sm'
|
|
onClick={() => setClearAllDialogOpen(true)}
|
|
>
|
|
{t('Clear All Cache')}
|
|
</Button>
|
|
{cacheStats && (
|
|
<span className='text-muted-foreground text-xs'>
|
|
{t('Cache Entries')}: {cacheStats.total} /{' '}
|
|
{cacheStats.cache_capacity}
|
|
</span>
|
|
)}
|
|
</SettingsPageActionsPortal>
|
|
|
|
{/* Rules Table or JSON Editor */}
|
|
{editMode === 'visual' ? (
|
|
<div className='overflow-x-auto rounded-md border'>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('Name')}</TableHead>
|
|
<TableHead>{t('Model Regex')}</TableHead>
|
|
<TableHead>{t('Key Sources')}</TableHead>
|
|
<TableHead>{t('TTL')}</TableHead>
|
|
<TableHead>{t('Retry')}</TableHead>
|
|
<TableHead>{t('Scope')}</TableHead>
|
|
<TableHead>{t('Cache')}</TableHead>
|
|
<TableHead className='text-right'>{t('Actions')}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rules.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={8}
|
|
className='text-muted-foreground py-8 text-center'
|
|
>
|
|
{t('No rules yet')}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
rules.map((rule, idx) => (
|
|
<TableRow key={idx}>
|
|
<TableCell className='font-medium'>
|
|
{rule.name || '-'}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RuleBadgeList items={rule.model_regex || []} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<RuleBadgeList
|
|
items={(rule.key_sources || []).map(
|
|
(src) =>
|
|
`${src.type}:${src.type === 'gjson' ? src.path : src.key}`
|
|
)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{rule.ttl_seconds || '-'}</TableCell>
|
|
<TableCell>
|
|
<StatusBadge
|
|
label={
|
|
rule.skip_retry_on_failure
|
|
? t('No Retry')
|
|
: t('Retry')
|
|
}
|
|
variant={
|
|
rule.skip_retry_on_failure ? 'danger' : 'neutral'
|
|
}
|
|
copyable={false}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
{(() => {
|
|
const scopeItems = [
|
|
rule.include_using_group && t('Group'),
|
|
rule.include_model_name && t('Model'),
|
|
rule.include_rule_name && t('Rule'),
|
|
].filter(Boolean) as string[]
|
|
if (scopeItems.length === 0) return '-'
|
|
return <RuleBadgeList items={scopeItems} />
|
|
})()}
|
|
</TableCell>
|
|
<TableCell>
|
|
{rule.include_rule_name && cacheStats?.by_rule_name
|
|
? cacheStats.by_rule_name[rule.name] || 0
|
|
: 'N/A'}
|
|
</TableCell>
|
|
<TableCell className='text-right'>
|
|
<div className='flex justify-end gap-1'>
|
|
{rule.include_rule_name && (
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='h-7 w-7'
|
|
onClick={() => setClearRuleName(rule.name)}
|
|
title={t('Clear cache for this rule')}
|
|
>
|
|
<X className='h-3 w-3' />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='h-7 w-7'
|
|
onClick={() => {
|
|
setEditingRule(rule)
|
|
setRuleTemplateKey(null)
|
|
setRuleEditorOpen(true)
|
|
}}
|
|
>
|
|
<Edit className='h-3 w-3' />
|
|
</Button>
|
|
<Button
|
|
variant='ghost'
|
|
size='icon'
|
|
className='h-7 w-7'
|
|
onClick={() => handleDeleteRule(idx)}
|
|
>
|
|
<Trash2 className='h-3 w-3' />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<div className='grid gap-1.5'>
|
|
<Label>{t('Rules JSON')}</Label>
|
|
<Textarea
|
|
className='min-h-[300px] font-mono text-xs'
|
|
value={jsonText}
|
|
onChange={(e) => setJsonText(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</SettingsSection>
|
|
|
|
<RuleEditorDialog
|
|
open={ruleEditorOpen}
|
|
onOpenChange={setRuleEditorOpen}
|
|
rule={editingRule}
|
|
onSave={handleRuleSave}
|
|
templateKey={ruleTemplateKey}
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
open={clearAllDialogOpen}
|
|
onOpenChange={setClearAllDialogOpen}
|
|
title={t('Confirm clearing all channel affinity cache')}
|
|
desc={t(
|
|
'This will delete all channel affinity cache entries still in memory.'
|
|
)}
|
|
handleConfirm={handleClearAll}
|
|
destructive
|
|
/>
|
|
|
|
{clearRuleName !== null && (
|
|
<ConfirmDialog
|
|
open
|
|
onOpenChange={(v) => !v && setClearRuleName(null)}
|
|
title={t('Confirm clearing cache for this rule')}
|
|
desc={`${t('Rule')}: ${clearRuleName}`}
|
|
handleConfirm={handleClearRule}
|
|
destructive
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={fillTemplateDialogOpen}
|
|
onOpenChange={setFillTemplateDialogOpen}
|
|
title={t('Fill Codex CLI / Claude CLI Templates')}
|
|
desc={t(
|
|
'This will append 2 template rules (Codex CLI and Claude CLI) to the existing rule list.'
|
|
)}
|
|
handleConfirm={appendCliTemplates}
|
|
/>
|
|
</>
|
|
)
|
|
}
|