feat: add routing reliability management
This commit is contained in:
+9
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+11
-11
@@ -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>
|
||||
|
||||
+31
-17
@@ -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'
|
||||
|
||||
+149
-320
@@ -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>
|
||||
|
||||
+6
-8
@@ -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>
|
||||
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+243
-1
@@ -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>
|
||||
|
||||
+7
-341
@@ -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'>
|
||||
|
||||
+2
-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,
|
||||
|
||||
+513
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user