feat: add routing reliability management

This commit is contained in:
CaIon
2026-06-20 13:55:50 +08:00
parent dfcb74b52d
commit 91ab664c53
36 changed files with 1171 additions and 782 deletions
+9
View File
@@ -24,6 +24,7 @@ import type {
BatchSetTagParams,
Channel,
ChannelBalanceResponse,
ChannelOpsResponse,
ChannelTestResponse,
CopyChannelParams,
CopyChannelResponse,
@@ -103,6 +104,14 @@ export async function getChannel(id: number): Promise<GetChannelResponse> {
return res.data
}
/**
* Get channel operations summary for administrators
*/
export async function getChannelOps(): Promise<ChannelOpsResponse> {
const res = await api.get('/api/channel/ops', channelActionConfig())
return res.data
}
/**
* Create new channel(s)
* Supports single, batch, and multi-key modes
+65 -1
View File
@@ -17,7 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { Settings2 } from 'lucide-react'
import { SectionPageLayout } from '@/components/layout'
import { Badge } from '@/components/ui/badge'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { ROLE } from '@/lib/roles'
import { useAuthStore } from '@/stores/auth-store'
import { getChannelOps } from './api'
import { ChannelsDialogs } from './components/channels-dialogs'
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
import { ChannelsProvider } from './components/channels-provider'
@@ -25,10 +37,62 @@ import { ChannelsTable } from './components/channels-table'
export function Channels() {
const { t } = useTranslation()
const isRoot = useAuthStore(
(state) => state.auth.user?.role === ROLE.SUPER_ADMIN
)
const channelOpsQuery = useQuery({
queryKey: ['channel-ops'],
queryFn: getChannelOps,
retry: false,
staleTime: 5 * 60 * 1000,
})
const retryTimes = channelOpsQuery.data?.data?.retry_times
const retryLabel =
typeof retryTimes === 'number'
? `${t('Max Retries')}: ${retryTimes}`
: null
let retryBadge = null
if (retryLabel) {
retryBadge = isRoot ? (
<Tooltip>
<TooltipTrigger
render={
<Badge
variant='outline'
className='shrink-0 cursor-pointer'
aria-label={t('Retry Settings')}
render={
<Link
to='/system-settings/models/$section'
params={{ section: 'routing-reliability' }}
/>
}
/>
}
>
<span>{retryLabel}</span>
<Settings2 data-icon='inline-end' />
</TooltipTrigger>
<TooltipContent>
<p>{t('Retry Settings')}</p>
</TooltipContent>
</Tooltip>
) : (
<Badge variant='outline' className='shrink-0'>
{retryLabel}
</Badge>
)
}
return (
<ChannelsProvider>
<SectionPageLayout fixedContent>
<SectionPageLayout.Title>{t('Channels')}</SectionPageLayout.Title>
<SectionPageLayout.Title>
<span className='flex min-w-0 items-center gap-2'>
<span className='truncate'>{t('Channels')}</span>
{retryBadge}
</span>
</SectionPageLayout.Title>
<SectionPageLayout.Actions>
<ChannelsPrimaryButtons />
</SectionPageLayout.Actions>
+8
View File
@@ -167,6 +167,14 @@ export interface GetChannelResponse {
data?: Channel
}
export interface ChannelOpsResponse {
success: boolean
message?: string
data?: {
retry_times: number
}
}
export interface ChannelTestResponse {
success: boolean
message?: string
@@ -200,6 +200,16 @@ export function ModelMutateDrawer({
'group_ratio_setting.group_special_usable_group': '{}',
'grok.violation_deduction_enabled': false,
'grok.violation_deduction_amount': 0,
RetryTimes: 0,
ChannelDisableThreshold: '',
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false,
@@ -44,7 +44,7 @@ type SettingsSwitchFieldProps = SettingsSwitchRowProps & {
}
const settingsSwitchRowClassName =
'flex min-w-0 flex-row items-center justify-between gap-4 border-b py-2.5 last:border-b-0'
'flex min-w-0 flex-row items-center justify-between gap-4 py-2.5'
export function SettingsFormGrid(props: SettingsFormGridProps) {
return (
@@ -339,7 +339,7 @@ export function AnnouncementsSection({
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
@@ -529,8 +529,7 @@ export function AnnouncementsSection({
<FormItem>
<FormLabel>{t('Type')}</FormLabel>
<Select
items={[
...typeOptions.map((option) => ({
items={typeOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
@@ -540,8 +539,7 @@ export function AnnouncementsSection({
{option.label}
</div>
),
})),
]}
}))}
onValueChange={field.onChange}
value={field.value}
>
@@ -291,7 +291,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
@@ -475,8 +475,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
<FormItem>
<FormLabel>{t('Badge Color')}</FormLabel>
<Select
items={[
...colorOptions.map((option) => ({
items={colorOptions.map((option) => ({
value: option.value,
label: (
<div className='flex items-center gap-2'>
@@ -486,8 +485,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
{option.label}
</div>
),
})),
]}
}))}
onValueChange={field.onChange}
value={field.value}
>
@@ -258,7 +258,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
@@ -267,7 +267,7 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
checked={isEnabled}
onCheckedChange={handleToggleEnabled}
label={t('Enabled')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
@@ -255,42 +255,42 @@ export function ChannelAffinitySection(props: Props) {
const updates: { key: string; value: string }[] = []
if (enabled !== props.defaultValues['channel_affinity_setting.enabled'])
updates.push({
{updates.push({
key: 'channel_affinity_setting.enabled',
value: String(enabled),
})
})}
if (
switchOnSuccess !==
props.defaultValues['channel_affinity_setting.switch_on_success']
)
updates.push({
{updates.push({
key: 'channel_affinity_setting.switch_on_success',
value: String(switchOnSuccess),
})
})}
if (
keepOnChannelDisabled !==
props.defaultValues['channel_affinity_setting.keep_on_channel_disabled']
)
updates.push({
{updates.push({
key: 'channel_affinity_setting.keep_on_channel_disabled',
value: String(keepOnChannelDisabled),
})
})}
if (
maxEntries !==
props.defaultValues['channel_affinity_setting.max_entries']
)
updates.push({
{updates.push({
key: 'channel_affinity_setting.max_entries',
value: String(maxEntries),
})
})}
if (
defaultTtl !==
props.defaultValues['channel_affinity_setting.default_ttl_seconds']
)
updates.push({
{updates.push({
key: 'channel_affinity_setting.default_ttl_seconds',
value: String(defaultTtl),
})
})}
const origRules = props.defaultValues['channel_affinity_setting.rules']
const origSerialized = (() => {
@@ -411,7 +411,7 @@ export function ChannelAffinitySection(props: Props) {
checked={enabled}
onCheckedChange={setEnabled}
label={t('Enable')}
className='border-b-0 py-0'
className='py-0'
/>
<div className='grid gap-1.5'>
<Label>{t('Max Entries')}</Label>
@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
@@ -44,6 +44,10 @@ import { SettingsSwitchField } from '../../components/settings-form-layout'
import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types'
type KeySourceRow = KeySource & {
rowId: string
}
const KEY_SOURCE_TYPES = [
'context_int',
'context_string',
@@ -103,8 +107,13 @@ interface Props {
export function RuleEditorDialog(props: Props) {
const { t } = useTranslation()
const isEdit = !!props.rule?.name
const [keySources, setKeySources] = useState<KeySource[]>([
{ type: 'gjson', path: '' },
const nextKeySourceRowId = useRef(0)
const createKeySourceRow = (source?: Partial<KeySource>): KeySourceRow => ({
...normalizeKeySource(source ?? { type: 'gjson', path: '' }),
rowId: String(nextKeySourceRowId.current++),
})
const [keySources, setKeySources] = useState<KeySourceRow[]>(() => [
createKeySourceRow(),
])
const [advancedOpen, setAdvancedOpen] = useState(false)
@@ -141,7 +150,11 @@ export function RuleEditorDialog(props: Props) {
: '',
})
const sources = (r.key_sources || []).map(normalizeKeySource)
setKeySources(sources.length > 0 ? sources : [{ type: 'gjson', path: '' }])
setKeySources(
sources.length > 0
? sources.map(createKeySourceRow)
: [createKeySourceRow()]
)
if (r.param_override_template) setAdvancedOpen(true)
}
@@ -166,7 +179,7 @@ export function RuleEditorDialog(props: Props) {
include_rule_name: true,
param_override_template_json: '',
})
setKeySources([{ type: 'gjson', path: '' }])
setKeySources([createKeySourceRow()])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.rule, props.templateKey])
@@ -297,7 +310,7 @@ export function RuleEditorDialog(props: Props) {
variant='outline'
size='sm'
onClick={() =>
setKeySources((prev) => [...prev, { type: 'gjson', path: '' }])
setKeySources((prev) => [...prev, createKeySourceRow()])
}
>
<Plus className='mr-1 h-3 w-3' />
@@ -310,21 +323,22 @@ export function RuleEditorDialog(props: Props) {
<div className='space-y-2'>
{keySources.map((src, idx) => (
<div
key={idx}
key={src.rowId}
className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center'
>
<Select
items={[
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
items={KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t }))}
value={src.type}
onValueChange={(v) => {
if (v === null) return
const next = [...keySources]
next[idx] = normalizeKeySource({
...src,
type: v as KeySource['type'],
})
next[idx] = {
...normalizeKeySource({
...src,
type: v as KeySource['type'],
}),
rowId: src.rowId,
}
setKeySources(next)
}}
>
@@ -432,19 +446,19 @@ export function RuleEditorDialog(props: Props) {
checked={form.watch('include_using_group')}
onCheckedChange={(v) => form.setValue('include_using_group', v)}
label={t('Include Group')}
className='border-b-0 py-0'
className='py-0'
/>
<SettingsSwitchField
checked={form.watch('include_model_name')}
onCheckedChange={(v) => form.setValue('include_model_name', v)}
label={t('Include Model')}
className='border-b-0 py-0'
className='py-0'
/>
<SettingsSwitchField
checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
</CollapsibleContent>
@@ -25,11 +25,8 @@ import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
SettingsForm,
@@ -40,10 +37,8 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
import { safeNumberFieldProps } from '../utils/numeric-field'
const behaviorSchema = z.object({
RetryTimes: z.coerce.number().min(0).max(10),
DefaultCollapseSidebar: z.boolean(),
DemoSiteEnabled: z.boolean(),
SelfUseModeEnabled: z.boolean(),
@@ -86,28 +81,6 @@ export function SystemBehaviorSection({
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<FormField
control={form.control}
name='RetryTimes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Retry Times')}</FormLabel>
<FormControl>
<Input
type='number'
min='0'
max='10'
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Number of times to retry failed requests (0-10)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='DefaultCollapseSidebar'
@@ -16,13 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useMemo, useRef } from 'react'
import { useEffect, useMemo, useRef } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
import {
Form,
FormControl,
@@ -33,8 +32,15 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
@@ -52,146 +58,65 @@ const numericString = z.string().refine((value) => {
return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0
}, 'Enter a non-negative number or leave empty')
const monitoringSchema = z
.object({
ChannelDisableThreshold: numericString,
QuotaRemindThreshold: numericString,
AutomaticDisableChannelEnabled: z.boolean(),
AutomaticEnableChannelEnabled: z.boolean(),
AutomaticDisableKeywords: z.string(),
AutomaticDisableStatusCodes: z.string(),
AutomaticRetryStatusCodes: z.string(),
monitor_setting: z.object({
auto_test_channel_enabled: z.boolean(),
auto_test_channel_minutes: z.coerce
.number()
.int()
.min(1, 'Interval must be at least 1 minute'),
}),
})
.superRefine((values, ctx) => {
const disableParsed = parseHttpStatusCodeRules(
values.AutomaticDisableStatusCodes
)
if (!disableParsed.ok) {
ctx.addIssue({
code: 'custom',
path: ['AutomaticDisableStatusCodes'],
message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
', '
)}`,
})
}
const monitoringSchema = z.object({
QuotaRemindThreshold: numericString,
perf_metrics_setting: z.object({
enabled: z.boolean(),
flush_interval: z.coerce.number().min(1),
bucket_time: z.enum(['minute', '5min', 'hour']),
retention_days: z.coerce.number().min(0),
}),
})
const retryParsed = parseHttpStatusCodeRules(
values.AutomaticRetryStatusCodes
)
if (!retryParsed.ok) {
ctx.addIssue({
code: 'custom',
path: ['AutomaticRetryStatusCodes'],
message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
', '
)}`,
})
}
})
type MonitoringFormValues = z.output<typeof monitoringSchema>
type MonitoringFormInput = z.input<typeof monitoringSchema>
type MonitoringFormValues = z.output<typeof monitoringSchema>
type FlatMonitoringDefaults = {
QuotaRemindThreshold: string
'perf_metrics_setting.enabled': boolean
'perf_metrics_setting.flush_interval': number
'perf_metrics_setting.bucket_time': 'minute' | '5min' | 'hour'
'perf_metrics_setting.retention_days': number
}
type MonitoringSettingsSectionProps = {
defaultValues: {
ChannelDisableThreshold: string
QuotaRemindThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
}
}
function normalizeLineEndings(value: string) {
return value.replace(/\r\n/g, '\n')
}
type NormalizedMonitoringValues = {
ChannelDisableThreshold: string
QuotaRemindThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
defaultValues: FlatMonitoringDefaults
}
const buildFormDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues']
): MonitoringFormInput => ({
ChannelDisableThreshold: defaults.ChannelDisableThreshold ?? '',
QuotaRemindThreshold: defaults.QuotaRemindThreshold ?? '',
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
defaults.AutomaticDisableKeywords ?? ''
),
AutomaticDisableStatusCodes: defaults.AutomaticDisableStatusCodes ?? '',
AutomaticRetryStatusCodes: defaults.AutomaticRetryStatusCodes ?? '',
monitor_setting: {
auto_test_channel_enabled:
defaults['monitor_setting.auto_test_channel_enabled'],
auto_test_channel_minutes:
defaults['monitor_setting.auto_test_channel_minutes'],
perf_metrics_setting: {
enabled: defaults['perf_metrics_setting.enabled'],
flush_interval: defaults['perf_metrics_setting.flush_interval'],
bucket_time: defaults['perf_metrics_setting.bucket_time'],
retention_days: defaults['perf_metrics_setting.retention_days'],
},
})
const normalizeDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues']
): NormalizedMonitoringValues => ({
ChannelDisableThreshold: (defaults.ChannelDisableThreshold ?? '').trim(),
): FlatMonitoringDefaults => ({
QuotaRemindThreshold: (defaults.QuotaRemindThreshold ?? '').trim(),
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
defaults.AutomaticDisableKeywords ?? ''
),
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
defaults.AutomaticDisableStatusCodes ?? ''
).normalized,
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
defaults.AutomaticRetryStatusCodes ?? ''
).normalized,
'monitor_setting.auto_test_channel_enabled':
defaults['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
defaults['monitor_setting.auto_test_channel_minutes'],
'perf_metrics_setting.enabled': defaults['perf_metrics_setting.enabled'],
'perf_metrics_setting.flush_interval':
defaults['perf_metrics_setting.flush_interval'],
'perf_metrics_setting.bucket_time': defaults['perf_metrics_setting.bucket_time'],
'perf_metrics_setting.retention_days':
defaults['perf_metrics_setting.retention_days'],
})
const normalizeFormValues = (
values: MonitoringFormValues
): NormalizedMonitoringValues => ({
ChannelDisableThreshold: values.ChannelDisableThreshold.trim(),
): FlatMonitoringDefaults => ({
QuotaRemindThreshold: values.QuotaRemindThreshold.trim(),
AutomaticDisableChannelEnabled: values.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: values.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
values.AutomaticDisableKeywords
),
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
values.AutomaticDisableStatusCodes
).normalized,
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
values.AutomaticRetryStatusCodes
).normalized,
'monitor_setting.auto_test_channel_enabled':
values.monitor_setting.auto_test_channel_enabled,
'monitor_setting.auto_test_channel_minutes':
values.monitor_setting.auto_test_channel_minutes,
'perf_metrics_setting.enabled': values.perf_metrics_setting.enabled,
'perf_metrics_setting.flush_interval':
values.perf_metrics_setting.flush_interval,
'perf_metrics_setting.bucket_time': values.perf_metrics_setting.bucket_time,
'perf_metrics_setting.retention_days':
values.perf_metrics_setting.retention_days,
})
export function MonitoringSettingsSection({
@@ -199,9 +124,12 @@ export function MonitoringSettingsSection({
}: MonitoringSettingsSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const baselineRef = useRef<NormalizedMonitoringValues>(
const baselineRef = useRef<FlatMonitoringDefaults>(
normalizeDefaults(defaultValues)
)
const baselineSerializedRef = useRef<string>(
JSON.stringify(normalizeDefaults(defaultValues))
)
const formDefaults = useMemo(
() => buildFormDefaults(defaultValues),
@@ -215,21 +143,20 @@ export function MonitoringSettingsSection({
useResetForm(form, formDefaults)
const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes')
const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes')
const autoDisableParsed = useMemo(
() => parseHttpStatusCodeRules(autoDisableStatusCodes),
[autoDisableStatusCodes]
)
const autoRetryParsed = useMemo(
() => parseHttpStatusCodeRules(autoRetryStatusCodes),
[autoRetryStatusCodes]
)
useEffect(() => {
const normalized = normalizeDefaults(defaultValues)
const serialized = JSON.stringify(normalized)
if (serialized === baselineSerializedRef.current) return
baselineRef.current = normalized
baselineSerializedRef.current = serialized
}, [defaultValues])
const perfMetricsEnabled = form.watch('perf_metrics_setting.enabled')
const onSubmit = async (values: MonitoringFormValues) => {
const normalized = normalizeFormValues(values)
const updates = (
Object.keys(normalized) as Array<keyof NormalizedMonitoringValues>
Object.keys(normalized) as Array<keyof FlatMonitoringDefaults>
).filter((key) => normalized[key] !== baselineRef.current[key])
if (updates.length === 0) {
@@ -238,14 +165,14 @@ export function MonitoringSettingsSection({
}
for (const key of updates) {
const value = normalized[key]
await updateOption.mutateAsync({
key,
value,
value: normalized[key],
})
}
baselineRef.current = normalized
baselineSerializedRef.current = JSON.stringify(normalized)
}
return (
@@ -255,226 +182,128 @@ export function MonitoringSettingsSection({
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save monitoring rules'
/>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='monitor_setting.auto_test_channel_enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Scheduled channel tests')}</FormLabel>
<FormDescription>
{t('Automatically probe all channels in the background')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='monitor_setting.auto_test_channel_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Test interval (minutes)')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('How frequently the system tests all channels')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='ChannelDisableThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Disable threshold (seconds)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Automatically disable channels exceeding this response time'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='QuotaRemindThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Quota reminder (tokens)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t('Send email alerts when a user falls below this quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='grid gap-6 md:grid-cols-2'>
<FormField
control={form.control}
name='AutomaticDisableChannelEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Disable on failure')}</FormLabel>
<FormDescription>
{t('Automatically disable channels when tests fail')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='AutomaticEnableChannelEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Re-enable on success')}</FormLabel>
<FormDescription>
{t('Bring channels back online after successful checks')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
<FormField
control={form.control}
name='AutomaticDisableKeywords'
name='QuotaRemindThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Failure keywords')}</FormLabel>
<FormLabel>{t('Quota reminder (tokens)')}</FormLabel>
<FormControl>
<Textarea
rows={6}
placeholder={t('one keyword per line')}
{...field}
<Input
type='number'
min={0}
step={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'If an upstream error contains any of these keywords (case insensitive), the channel will be disabled automatically.'
)}
{t('Send email alerts when a user falls below this quota')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='grid gap-6 md:grid-cols-2'>
<div>
<h4 className='font-medium'>{t('Model performance metrics')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Collect relay latency and success-rate metrics for the model square.'
)}
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<FormField
control={form.control}
name='AutomaticDisableStatusCodes'
name='perf_metrics_setting.enabled'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto-disable status codes')}</FormLabel>
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Enable model performance metrics')}
</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='perf_metrics_setting.flush_interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Flush interval (minutes)')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. 401, 403, 429, 500-599')}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
type='number'
min={1}
step={1}
{...safeNumberFieldProps(field)}
disabled={!perfMetricsEnabled}
/>
</FormControl>
<FormDescription>
{t(
'Accepts comma-separated status codes and inclusive ranges.'
)}{' '}
{autoDisableParsed.ok &&
autoDisableParsed.normalized &&
autoDisableParsed.normalized !== field.value.trim() && (
<span className='text-muted-foreground'>
{t('Normalized:')} {autoDisableParsed.normalized}
</span>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticRetryStatusCodes'
name='perf_metrics_setting.bucket_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto-retry status codes')}</FormLabel>
<FormLabel>{t('Aggregation bucket')}</FormLabel>
<Select
items={[
{ value: 'minute', label: t('1 minute') },
{ value: '5min', label: t('5 minutes') },
{ value: 'hour', label: t('1 hour') },
]}
value={field.value}
onValueChange={field.onChange}
disabled={!perfMetricsEnabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='minute'>{t('1 minute')}</SelectItem>
<SelectItem value='5min'>{t('5 minutes')}</SelectItem>
<SelectItem value='hour'>{t('1 hour')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='perf_metrics_setting.retention_days'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Retention days')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. 401, 403, 429, 500-599')}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
type='number'
min={0}
step={1}
{...safeNumberFieldProps(field)}
disabled={!perfMetricsEnabled}
/>
</FormControl>
<FormDescription>
{t(
'Accepts comma-separated status codes and inclusive ranges.'
)}{' '}
{autoRetryParsed.ok &&
autoRetryParsed.normalized &&
autoRetryParsed.normalized !== field.value.trim() && (
<span className='text-muted-foreground'>
{t('Normalized:')} {autoRetryParsed.normalized}
</span>
)}
{t('0 means data is kept permanently')}
</FormDescription>
<FormMessage />
</FormItem>
@@ -98,7 +98,7 @@ export function WaffoSettingsSection({
const saveMethod = () => {
if (!methodForm.name.trim())
return toast.error(t('Payment method name is required'))
{return toast.error(t('Payment method name is required'))}
if (editingIdx === -1) {
onPayMethodsChange((prev) => [...prev, methodForm])
} else {
@@ -125,15 +125,13 @@ export function WaffoSettingsSection({
}
const reader = new FileReader()
reader.onload = (loadEvent) => {
reader.addEventListener('load', () => {
setMethodForm((previous) => ({
...previous,
icon:
typeof loadEvent.target?.result === 'string'
? loadEvent.target.result
: '',
typeof reader.result === 'string' ? reader.result : '',
}))
}
})
reader.readAsDataURL(file)
event.target.value = ''
}
@@ -164,13 +162,13 @@ export function WaffoSettingsSection({
checked={values.WaffoEnabled}
onCheckedChange={(v) => onValueChange('WaffoEnabled', v)}
label={t('Enable Waffo')}
className='border-b-0 py-0'
className='py-0'
/>
<SettingsSwitchField
checked={values.WaffoSandbox}
onCheckedChange={(v) => onValueChange('WaffoSandbox', v)}
label={t('Sandbox mode')}
className='border-b-0 py-0'
className='py-0'
/>
</div>
@@ -270,7 +270,7 @@ export function HeaderNavigationSection({
name={module.requireAuthKey}
render={({ field }) => (
<SettingsControlChildren>
<SettingsSwitchItem className='border-b-0 py-2'>
<SettingsSwitchItem className='py-2'>
<SettingsSwitchContent>
<FormLabel>{module.requireAuthTitle}</FormLabel>
<FormDescription>
@@ -16,13 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
@@ -32,6 +35,7 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
@@ -42,6 +46,17 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { DateTimePicker } from '@/components/datetime-picker'
import { deleteLogsBefore } from '../api'
@@ -65,8 +80,30 @@ type LogSettingsSectionProps = {
defaultEnabled: boolean
}
type ServerLogInfo = {
enabled: boolean
log_dir: string
file_count: number
total_size: number
oldest_time?: string
newest_time?: string
}
const HOURS_IN_DAY = 24
function formatBytes(bytes: number, decimals = 2): string {
if (!bytes || Number.isNaN(bytes)) return '0 Bytes'
if (bytes === 0) return '0 Bytes'
if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
if (i < 0 || i >= sizes.length) return `${bytes} Bytes`
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${
sizes[i]
}`
}
const getDateHoursAgo = (hours: number) => {
const date = new Date()
date.setHours(date.getHours() - hours)
@@ -107,11 +144,30 @@ export function LogSettingsSection({
)
const [isCleaning, setIsCleaning] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [serverLogInfo, setServerLogInfo] = useState<ServerLogInfo | null>(
null
)
const [serverLogCleanupMode, setServerLogCleanupMode] = useState('by_count')
const [serverLogCleanupValue, setServerLogCleanupValue] = useState(10)
const [serverLogCleanupLoading, setServerLogCleanupLoading] = useState(false)
const fetchServerLogInfo = useCallback(async () => {
try {
const res = await api.get('/api/performance/logs')
if (res.data.success) setServerLogInfo(res.data.data)
} catch {
/* ignore */
}
}, [])
useEffect(() => {
form.reset({ LogConsumeEnabled: defaultEnabled })
}, [defaultEnabled, form])
useEffect(() => {
fetchServerLogInfo()
}, [fetchServerLogInfo])
const purgeTimestamp = useMemo(() => {
if (!purgeDate) return null
return Math.floor(purgeDate.getTime() / 1000)
@@ -166,6 +222,40 @@ export function LogSettingsSection({
}
}
const cleanupServerLogFiles = async () => {
if (
!serverLogCleanupValue ||
Number.isNaN(serverLogCleanupValue) ||
serverLogCleanupValue < 1
) {
toast.error(t('Please enter a valid number'))
return
}
setServerLogCleanupLoading(true)
try {
const res = await api.delete(
`/api/performance/logs?mode=${serverLogCleanupMode}&value=${serverLogCleanupValue}`
)
if (res.data.success) {
const { deleted_count, freed_bytes } = res.data.data
toast.success(
t('Cleaned up {{count}} log files, freed {{size}}', {
count: deleted_count,
size: formatBytes(freed_bytes),
})
)
} else {
toast.error(res.data.message || t('Cleanup failed'))
}
fetchServerLogInfo()
} catch {
toast.error(t('Cleanup failed'))
} finally {
setServerLogCleanupLoading(false)
}
}
return (
<SettingsSection title={t('Log Maintenance')}>
<Form {...form}>
@@ -232,6 +322,158 @@ export function LogSettingsSection({
</SettingsControlGroup>
</SettingsForm>
</Form>
<Separator />
<div className='space-y-4'>
<div>
<h4 className='font-medium'>{t('Server Log Management')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.'
)}
</p>
</div>
{serverLogInfo !== null &&
(serverLogInfo.enabled ? (
<div className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2 text-sm md:grid-cols-4'>
<div>
<span className='text-muted-foreground'>
{t('Log Directory')}:
</span>{' '}
<span className='font-mono text-xs'>
{serverLogInfo.log_dir}
</span>
</div>
<div>
<span className='text-muted-foreground'>
{t('Log File Count')}:
</span>{' '}
{serverLogInfo.file_count}
</div>
<div>
<span className='text-muted-foreground'>
{t('Total Log Size')}:
</span>{' '}
{formatBytes(serverLogInfo.total_size)}
</div>
{serverLogInfo.oldest_time && serverLogInfo.newest_time && (
<div>
<span className='text-muted-foreground'>
{t('Date Range')}:
</span>{' '}
{dayjs(serverLogInfo.oldest_time).format('YYYY-MM-DD')} ~{' '}
{dayjs(serverLogInfo.newest_time).format('YYYY-MM-DD')}
</div>
)}
</div>
</div>
<div className='flex flex-wrap items-end gap-3'>
<div className='grid gap-1.5'>
<Label className='text-xs'>{t('Cleanup Mode')}</Label>
<Select
items={[
{ value: 'by_count', label: t('Retain last N files') },
{ value: 'by_days', label: t('Retain last N days') },
]}
value={serverLogCleanupMode}
onValueChange={(value) =>
value !== null && setServerLogCleanupMode(value)
}
>
<SelectTrigger className='w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='by_count'>
{t('Retain last N files')}
</SelectItem>
<SelectItem value='by_days'>
{t('Retain last N days')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label className='text-xs'>
{serverLogCleanupMode === 'by_count'
? t('Files to Retain')
: t('Days to Retain')}
</Label>
<Input
type='number'
min={1}
max={serverLogCleanupMode === 'by_count' ? 1000 : 3650}
value={serverLogCleanupValue}
onChange={(event) =>
setServerLogCleanupValue(Number(event.target.value))
}
className='w-[120px]'
/>
</div>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type='button'
variant='destructive'
size='sm'
disabled={serverLogCleanupLoading}
/>
}
>
{serverLogCleanupLoading
? t('Cleaning...')
: t('Clean Up Log Files')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Confirm log file cleanup?')}
</AlertDialogTitle>
<AlertDialogDescription>
{serverLogCleanupMode === 'by_count'
? t(
'Only the last {{value}} log files will be retained; the rest will be deleted.',
{
value: serverLogCleanupValue,
}
)
: t(
'Log files older than {{value}} days will be deleted.',
{
value: serverLogCleanupValue,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={cleanupServerLogFiles}>
{t('Confirm Cleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
) : (
<Alert>
<AlertDescription>
{t(
'Server logging is not enabled (log directory not configured)'
)}
</AlertDescription>
</Alert>
))}
</div>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -23,7 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { api } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
@@ -47,16 +46,7 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { StatusBadge } from '@/components/status-badge'
@@ -89,12 +79,6 @@ const perfSchema = z.object({
monitor_memory_threshold: z.coerce.number().min(0).max(100),
monitor_disk_threshold: z.coerce.number().min(0).max(100),
}),
perf_metrics_setting: z.object({
enabled: z.boolean(),
flush_interval: z.coerce.number().min(1),
bucket_time: z.enum(['minute', '5min', 'hour']),
retention_days: z.coerce.number().min(0),
}),
})
type PerfFormInput = z.input<typeof perfSchema>
@@ -109,10 +93,6 @@ type FlatPerfDefaults = {
'performance_setting.monitor_cpu_threshold': number
'performance_setting.monitor_memory_threshold': number
'performance_setting.monitor_disk_threshold': number
'perf_metrics_setting.enabled': boolean
'perf_metrics_setting.flush_interval': number
'perf_metrics_setting.bucket_time': 'minute' | '5min' | 'hour'
'perf_metrics_setting.retention_days': number
}
const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
@@ -131,12 +111,6 @@ const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
monitor_disk_threshold:
defaults['performance_setting.monitor_disk_threshold'],
},
perf_metrics_setting: {
enabled: defaults['perf_metrics_setting.enabled'],
flush_interval: defaults['perf_metrics_setting.flush_interval'],
bucket_time: defaults['perf_metrics_setting.bucket_time'],
retention_days: defaults['perf_metrics_setting.retention_days'],
},
})
const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
@@ -156,38 +130,25 @@ const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
values.performance_setting.monitor_memory_threshold,
'performance_setting.monitor_disk_threshold':
values.performance_setting.monitor_disk_threshold,
'perf_metrics_setting.enabled': values.perf_metrics_setting.enabled,
'perf_metrics_setting.flush_interval':
values.perf_metrics_setting.flush_interval,
'perf_metrics_setting.bucket_time': values.perf_metrics_setting.bucket_time,
'perf_metrics_setting.retention_days':
values.perf_metrics_setting.retention_days,
})
function formatBytes(bytes: number, decimals = 2): string {
if (!bytes || isNaN(bytes)) return '0 Bytes'
if (!bytes || Number.isNaN(bytes)) return '0 Bytes'
if (bytes === 0) return '0 Bytes'
if (bytes < 0) return '-' + formatBytes(-bytes, decimals)
if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
if (i < 0 || i >= sizes.length) return bytes + ' Bytes'
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
if (i < 0 || i >= sizes.length) return `${bytes} Bytes`
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${
sizes[i]
}`
}
interface Props {
defaultValues: FlatPerfDefaults
}
type LogInfo = {
enabled: boolean
log_dir: string
file_count: number
total_size: number
oldest_time?: string
newest_time?: string
}
type PerformanceStats = {
cache_stats?: {
current_disk_usage_bytes: number
@@ -225,10 +186,6 @@ export function PerformanceSection(props: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [stats, setStats] = useState<PerformanceStats | null>(null)
const [logInfo, setLogInfo] = useState<LogInfo | null>(null)
const [logCleanupMode, setLogCleanupMode] = useState('by_count')
const [logCleanupValue, setLogCleanupValue] = useState(10)
const [logCleanupLoading, setLogCleanupLoading] = useState(false)
const formDefaults = useMemo(
() => buildFormDefaults(props.defaultValues),
@@ -262,19 +219,9 @@ export function PerformanceSection(props: Props) {
}
}, [])
const fetchLogInfo = useCallback(async () => {
try {
const res = await api.get('/api/performance/logs')
if (res.data.success) setLogInfo(res.data.data)
} catch {
/* ignore */
}
}, [])
useEffect(() => {
fetchStats()
fetchLogInfo()
}, [fetchStats, fetchLogInfo])
}, [fetchStats])
const onSubmit = async (values: PerfFormValues) => {
const normalized = normalizeFormValues(values)
@@ -336,38 +283,8 @@ export function PerformanceSection(props: Props) {
}
}
const cleanupLogFiles = async () => {
if (!logCleanupValue || isNaN(logCleanupValue) || logCleanupValue < 1) {
toast.error(t('Please enter a valid number'))
return
}
setLogCleanupLoading(true)
try {
const res = await api.delete(
`/api/performance/logs?mode=${logCleanupMode}&value=${logCleanupValue}`
)
if (res.data.success) {
const { deleted_count, freed_bytes } = res.data.data
toast.success(
t('Cleaned up {{count}} log files, freed {{size}}', {
count: deleted_count,
size: formatBytes(freed_bytes),
})
)
} else {
toast.error(res.data.message || t('Cleanup failed'))
}
fetchLogInfo()
} catch {
toast.error(t('Cleanup failed'))
} finally {
setLogCleanupLoading(false)
}
}
const diskEnabled = form.watch('performance_setting.disk_cache_enabled')
const monitorEnabled = form.watch('performance_setting.monitor_enabled')
const perfMetricsEnabled = form.watch('perf_metrics_setting.enabled')
const maxCacheSizeRaw = form.watch(
'performance_setting.disk_cache_max_size_mb'
)
@@ -607,262 +524,11 @@ export function PerformanceSection(props: Props) {
)}
/>
</div>
<Separator />
<div>
<h4 className='font-medium'>{t('Model performance metrics')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Collect relay latency and success-rate metrics for the model square.'
)}
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<FormField
control={form.control}
name='perf_metrics_setting.enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>
{t('Enable model performance metrics')}
</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='perf_metrics_setting.flush_interval'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Flush interval (minutes)')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
{...safeNumberFieldProps(field)}
disabled={!perfMetricsEnabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='perf_metrics_setting.bucket_time'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Aggregation bucket')}</FormLabel>
<Select
items={[
{ value: 'minute', label: t('1 minute') },
{ value: '5min', label: t('5 minutes') },
{ value: 'hour', label: t('1 hour') },
]}
value={field.value}
onValueChange={field.onChange}
disabled={!perfMetricsEnabled}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='minute'>{t('1 minute')}</SelectItem>
<SelectItem value='5min'>{t('5 minutes')}</SelectItem>
<SelectItem value='hour'>{t('1 hour')}</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='perf_metrics_setting.retention_days'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Retention days')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
{...safeNumberFieldProps(field)}
disabled={!perfMetricsEnabled}
/>
</FormControl>
<FormDescription>
{t('0 means data is kept permanently')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsForm>
</Form>
<Separator />
{/* Server Log Management */}
<div className='space-y-4'>
<div>
<h4 className='font-medium'>{t('Server Log Management')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.'
)}
</p>
</div>
{logInfo === null ? null : logInfo.enabled ? (
<div className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2 text-sm md:grid-cols-4'>
<div>
<span className='text-muted-foreground'>
{t('Log Directory')}:
</span>{' '}
<span className='font-mono text-xs'>{logInfo.log_dir}</span>
</div>
<div>
<span className='text-muted-foreground'>
{t('Log File Count')}:
</span>{' '}
{logInfo.file_count}
</div>
<div>
<span className='text-muted-foreground'>
{t('Total Log Size')}:
</span>{' '}
{formatBytes(logInfo.total_size)}
</div>
{logInfo.oldest_time && logInfo.newest_time && (
<div>
<span className='text-muted-foreground'>
{t('Date Range')}:
</span>{' '}
{dayjs(logInfo.oldest_time).format('YYYY-MM-DD')} ~{' '}
{dayjs(logInfo.newest_time).format('YYYY-MM-DD')}
</div>
)}
</div>
</div>
<div className='flex flex-wrap items-end gap-3'>
<div className='grid gap-1.5'>
<Label className='text-xs'>{t('Cleanup Mode')}</Label>
<Select
items={[
{ value: 'by_count', label: t('Retain last N files') },
{ value: 'by_days', label: t('Retain last N days') },
]}
value={logCleanupMode}
onValueChange={(v) => v !== null && setLogCleanupMode(v)}
>
<SelectTrigger className='w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='by_count'>
{t('Retain last N files')}
</SelectItem>
<SelectItem value='by_days'>
{t('Retain last N days')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label className='text-xs'>
{logCleanupMode === 'by_count'
? t('Files to Retain')
: t('Days to Retain')}
</Label>
<Input
type='number'
min={1}
max={logCleanupMode === 'by_count' ? 1000 : 3650}
value={logCleanupValue}
onChange={(e) => setLogCleanupValue(Number(e.target.value))}
className='w-[120px]'
/>
</div>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant='destructive'
size='sm'
disabled={logCleanupLoading}
/>
}
>
{logCleanupLoading
? t('Cleaning...')
: t('Clean Up Log Files')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Confirm log file cleanup?')}
</AlertDialogTitle>
<AlertDialogDescription>
{logCleanupMode === 'by_count'
? t(
'Only the last {{value}} log files will be retained; the rest will be deleted.',
{
value: logCleanupValue,
}
)
: t(
'Log files older than {{value}} days will be deleted.',
{
value: logCleanupValue,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={cleanupLogFiles}>
{t('Confirm Cleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
) : (
<Alert>
<AlertDescription>
{t(
'Server logging is not enabled (log directory not configured)'
)}
</AlertDescription>
</Alert>
)}
</div>
<Separator />
{/* Performance Stats Dashboard */}
<div className='space-y-4'>
<div className='flex items-center gap-2'>
@@ -51,7 +51,7 @@ type SidebarModulesSectionProps = {
type SidebarFormValues = SidebarModulesAdminConfig
const toTitleCase = (value: string) =>
value.replace(/[_-]+/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase())
value.replaceAll(/[_-]+/g, ' ').replaceAll(/\b\w/g, (char) => char.toUpperCase())
export function SidebarModulesSection({
config,
@@ -237,7 +237,7 @@ export function SidebarModulesSection({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.${moduleKey}` as any}
render={({ field }) => (
<SettingsSwitchItem className='border-b-0 py-2'>
<SettingsSwitchItem className='py-2'>
<SettingsSwitchContent>
<FormLabel>{moduleInfo.title}</FormLabel>
<FormDescription>
@@ -62,6 +62,16 @@ const defaultModelSettings: ModelSettings = {
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
RetryTimes: 0,
ChannelDisableThreshold: '',
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false,
@@ -0,0 +1,513 @@
/*
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 { useMemo, useRef } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option'
import { safeNumberFieldProps } from '../utils/numeric-field'
const numericString = z.string().refine((value) => {
const trimmed = value.trim()
if (!trimmed) return true
return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0
}, 'Enter a non-negative number or leave empty')
const routingReliabilitySchema = z
.object({
RetryTimes: z.coerce.number().min(0).max(10),
ChannelDisableThreshold: numericString,
AutomaticDisableChannelEnabled: z.boolean(),
AutomaticEnableChannelEnabled: z.boolean(),
AutomaticDisableKeywords: z.string(),
AutomaticDisableStatusCodes: z.string(),
AutomaticRetryStatusCodes: z.string(),
monitor_setting: z.object({
auto_test_channel_enabled: z.boolean(),
auto_test_channel_minutes: z.coerce
.number()
.int()
.min(1, 'Interval must be at least 1 minute'),
}),
})
.superRefine((values, ctx) => {
const disableParsed = parseHttpStatusCodeRules(
values.AutomaticDisableStatusCodes
)
if (!disableParsed.ok) {
ctx.addIssue({
code: 'custom',
path: ['AutomaticDisableStatusCodes'],
message: `Invalid status code rules: ${disableParsed.invalidTokens.join(
', '
)}`,
})
}
const retryParsed = parseHttpStatusCodeRules(
values.AutomaticRetryStatusCodes
)
if (!retryParsed.ok) {
ctx.addIssue({
code: 'custom',
path: ['AutomaticRetryStatusCodes'],
message: `Invalid status code rules: ${retryParsed.invalidTokens.join(
', '
)}`,
})
}
})
type RoutingReliabilityFormValues = z.output<typeof routingReliabilitySchema>
type RoutingReliabilityFormInput = z.input<typeof routingReliabilitySchema>
type RoutingReliabilitySectionProps = {
defaultValues: {
RetryTimes: number
ChannelDisableThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
}
}
function normalizeLineEndings(value: string) {
return value.replaceAll('\r\n', '\n')
}
type NormalizedRoutingReliabilityValues = {
RetryTimes: number
ChannelDisableThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
}
const buildFormDefaults = (
defaults: RoutingReliabilitySectionProps['defaultValues']
): RoutingReliabilityFormInput => ({
RetryTimes: defaults.RetryTimes ?? 0,
ChannelDisableThreshold: defaults.ChannelDisableThreshold ?? '',
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
defaults.AutomaticDisableKeywords ?? ''
),
AutomaticDisableStatusCodes: defaults.AutomaticDisableStatusCodes ?? '',
AutomaticRetryStatusCodes: defaults.AutomaticRetryStatusCodes ?? '',
monitor_setting: {
auto_test_channel_enabled:
defaults['monitor_setting.auto_test_channel_enabled'],
auto_test_channel_minutes:
defaults['monitor_setting.auto_test_channel_minutes'],
},
})
const normalizeDefaults = (
defaults: RoutingReliabilitySectionProps['defaultValues']
): NormalizedRoutingReliabilityValues => ({
RetryTimes: defaults.RetryTimes ?? 0,
ChannelDisableThreshold: (defaults.ChannelDisableThreshold ?? '').trim(),
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
defaults.AutomaticDisableKeywords ?? ''
),
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
defaults.AutomaticDisableStatusCodes ?? ''
).normalized,
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
defaults.AutomaticRetryStatusCodes ?? ''
).normalized,
'monitor_setting.auto_test_channel_enabled':
defaults['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
defaults['monitor_setting.auto_test_channel_minutes'],
})
const normalizeFormValues = (
values: RoutingReliabilityFormValues
): NormalizedRoutingReliabilityValues => ({
RetryTimes: values.RetryTimes,
ChannelDisableThreshold: values.ChannelDisableThreshold.trim(),
AutomaticDisableChannelEnabled: values.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: values.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: normalizeLineEndings(
values.AutomaticDisableKeywords
),
AutomaticDisableStatusCodes: parseHttpStatusCodeRules(
values.AutomaticDisableStatusCodes
).normalized,
AutomaticRetryStatusCodes: parseHttpStatusCodeRules(
values.AutomaticRetryStatusCodes
).normalized,
'monitor_setting.auto_test_channel_enabled':
values.monitor_setting.auto_test_channel_enabled,
'monitor_setting.auto_test_channel_minutes':
values.monitor_setting.auto_test_channel_minutes,
})
export function RoutingReliabilitySection({
defaultValues,
}: RoutingReliabilitySectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const baselineRef = useRef<NormalizedRoutingReliabilityValues>(
normalizeDefaults(defaultValues)
)
const formDefaults = useMemo(
() => buildFormDefaults(defaultValues),
[defaultValues]
)
const form = useForm<
RoutingReliabilityFormInput,
unknown,
RoutingReliabilityFormValues
>({
resolver: zodResolver(routingReliabilitySchema),
defaultValues: formDefaults,
})
useResetForm(form, formDefaults)
const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes')
const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes')
const autoDisableParsed = useMemo(
() => parseHttpStatusCodeRules(autoDisableStatusCodes),
[autoDisableStatusCodes]
)
const autoRetryParsed = useMemo(
() => parseHttpStatusCodeRules(autoRetryStatusCodes),
[autoRetryStatusCodes]
)
const onSubmit = async (values: RoutingReliabilityFormValues) => {
const normalized = normalizeFormValues(values)
const updates = (
Object.keys(normalized) as Array<keyof NormalizedRoutingReliabilityValues>
).filter((key) => normalized[key] !== baselineRef.current[key])
if (updates.length === 0) {
toast.info(t('No changes to save'))
return
}
for (const key of updates) {
const value = normalized[key]
await updateOption.mutateAsync({
key,
value,
})
}
baselineRef.current = normalized
}
return (
<SettingsSection title={t('Routing Reliability')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
<div className='flex min-w-0 flex-col gap-4'>
<div className='flex flex-col gap-1'>
<h4 className='text-sm font-medium'>{t('Request retry')}</h4>
</div>
<div className='grid min-w-0 gap-6 xl:grid-cols-[minmax(12rem,24rem)_minmax(0,1fr)]'>
<FormField
control={form.control}
name='RetryTimes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Retry Times')}</FormLabel>
<FormControl>
<Input
type='number'
min='0'
max='10'
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('Number of times to retry failed requests (0-10)')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticRetryStatusCodes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto-retry status codes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. 401, 403, 429, 500-599')}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Accepts comma-separated status codes and inclusive ranges.'
)}{' '}
{autoRetryParsed.ok &&
autoRetryParsed.normalized &&
autoRetryParsed.normalized !== field.value.trim() && (
<span className='text-muted-foreground'>
{t('Normalized:')} {autoRetryParsed.normalized}
</span>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<Separator />
<div className='flex min-w-0 flex-col gap-4'>
<div className='flex flex-col gap-1'>
<h4 className='text-sm font-medium'>
{t('Channel health checks')}
</h4>
</div>
<div className='grid min-w-0 gap-6 lg:grid-cols-3'>
<FormField
control={form.control}
name='monitor_setting.auto_test_channel_enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Scheduled channel tests')}</FormLabel>
<FormDescription>
{t('Automatically probe all channels in the background')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='monitor_setting.auto_test_channel_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Test interval (minutes)')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t('How frequently the system tests all channels')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticEnableChannelEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Re-enable on success')}</FormLabel>
<FormDescription>
{t('Bring channels back online after successful checks')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
</div>
</div>
<Separator />
<div className='flex min-w-0 flex-col gap-4'>
<div className='flex flex-col gap-1'>
<h4 className='text-sm font-medium'>
{t('Auto-disable rules')}
</h4>
</div>
<div className='grid min-w-0 gap-6 lg:grid-cols-2'>
<FormField
control={form.control}
name='AutomaticDisableChannelEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Disable on failure')}</FormLabel>
<FormDescription>
{t('Automatically disable channels when tests fail')}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='ChannelDisableThreshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Disable threshold (seconds)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Automatically disable channels exceeding this response time'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticDisableStatusCodes'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Auto-disable status codes')}</FormLabel>
<FormControl>
<Input
placeholder={t('e.g. 401, 403, 429, 500-599')}
value={field.value}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'Accepts comma-separated status codes and inclusive ranges.'
)}{' '}
{autoDisableParsed.ok &&
autoDisableParsed.normalized &&
autoDisableParsed.normalized !== field.value.trim() && (
<span className='text-muted-foreground'>
{t('Normalized:')} {autoDisableParsed.normalized}
</span>
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticDisableKeywords'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Failure keywords')}</FormLabel>
<FormControl>
<Textarea
rows={6}
placeholder={t('one keyword per line')}
{...field}
onChange={(event) => field.onChange(event.target.value)}
/>
</FormControl>
<FormDescription>
{t(
'If an upstream error contains any of these keywords (case insensitive), the channel will be disabled automatically.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</SettingsForm>
</Form>
</SettingsSection>
)
}
@@ -24,6 +24,7 @@ import { ClaudeSettingsCard } from './claude-settings-card'
import { GeminiSettingsCard } from './gemini-settings-card'
import { GlobalSettingsCard } from './global-settings-card'
import { GrokSettingsCard } from './grok-settings-card'
import { RoutingReliabilitySection } from './routing-reliability-section'
function formatJsonForEditor(value: string, fallback: string) {
const raw = (value ?? '').toString().trim()
@@ -64,6 +65,28 @@ const MODELS_SECTIONS = [
/>
),
},
{
id: 'routing-reliability',
titleKey: 'Routing Reliability',
build: (settings: ModelSettings) => (
<RoutingReliabilitySection
defaultValues={{
RetryTimes: settings.RetryTimes,
ChannelDisableThreshold: settings.ChannelDisableThreshold,
AutomaticDisableChannelEnabled:
settings.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
'monitor_setting.auto_test_channel_enabled':
settings['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
}}
/>
),
},
{
id: 'gemini',
titleKey: 'Gemini',
@@ -26,20 +26,10 @@ import {
} from './section-registry.tsx'
const defaultOperationsSettings: OperationsSettings = {
RetryTimes: 0,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
ChannelDisableThreshold: '',
QuotaRemindThreshold: '',
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
@@ -33,7 +33,6 @@ const OPERATIONS_SECTIONS = [
build: (settings: OperationsSettings) => (
<SystemBehaviorSection
defaultValues={{
RetryTimes: settings.RetryTimes,
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
DemoSiteEnabled: settings.DemoSiteEnabled,
SelfUseModeEnabled: settings.SelfUseModeEnabled,
@@ -42,23 +41,20 @@ const OPERATIONS_SECTIONS = [
),
},
{
id: 'monitoring',
id: 'alerts',
titleKey: 'Monitoring & Alerts',
build: (settings: OperationsSettings) => (
<MonitoringSettingsSection
defaultValues={{
ChannelDisableThreshold: settings.ChannelDisableThreshold,
QuotaRemindThreshold: settings.QuotaRemindThreshold,
AutomaticDisableChannelEnabled:
settings.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
'monitor_setting.auto_test_channel_enabled':
settings['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
'perf_metrics_setting.enabled':
settings['perf_metrics_setting.enabled'] ?? true,
'perf_metrics_setting.flush_interval':
settings['perf_metrics_setting.flush_interval'] ?? 5,
'perf_metrics_setting.bucket_time':
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
'perf_metrics_setting.retention_days':
settings['perf_metrics_setting.retention_days'] ?? 0,
}}
/>
),
@@ -125,14 +121,6 @@ const OPERATIONS_SECTIONS = [
settings['performance_setting.monitor_memory_threshold'] ?? 90,
'performance_setting.monitor_disk_threshold':
settings['performance_setting.monitor_disk_threshold'] ?? 95,
'perf_metrics_setting.enabled':
settings['perf_metrics_setting.enabled'] ?? true,
'perf_metrics_setting.flush_interval':
settings['perf_metrics_setting.flush_interval'] ?? 5,
'perf_metrics_setting.bucket_time':
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
'perf_metrics_setting.retention_days':
settings['perf_metrics_setting.retention_days'] ?? 0,
}}
/>
),
+9 -9
View File
@@ -174,6 +174,15 @@ export type ModelSettings = {
AutoGroups: string
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
RetryTimes: number
ChannelDisableThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
'channel_affinity_setting.enabled': boolean
'channel_affinity_setting.switch_on_success': boolean
'channel_affinity_setting.keep_on_channel_disabled': boolean
@@ -270,19 +279,10 @@ export type BillingSettings = {
}
export type OperationsSettings = {
RetryTimes: number
DefaultCollapseSidebar: boolean
DemoSiteEnabled: boolean
SelfUseModeEnabled: boolean
ChannelDisableThreshold: string
QuotaRemindThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
SMTPServer: string
SMTPPort: string
SMTPAccount: string