From 91ab664c53d636a1008cb56c03156440d44cdfed Mon Sep 17 00:00:00 2001 From: CaIon Date: Sat, 20 Jun 2026 13:55:50 +0800 Subject: [PATCH] feat: add routing reliability management --- AGENTS.md | 1 + CLAUDE.md | 1 + controller/channel.go | 6 + router/api-router.go | 1 + web/default/src/features/channels/api.ts | 9 + web/default/src/features/channels/index.tsx | 66 ++- web/default/src/features/channels/types.ts | 8 + .../drawers/model-mutate-drawer.tsx | 10 + .../components/settings-form-layout.tsx | 2 +- .../content/announcements-section.tsx | 8 +- .../content/api-info-section.tsx | 8 +- .../system-settings/content/faq-section.tsx | 2 +- .../content/uptime-kuma-section.tsx | 2 +- .../general/channel-affinity/index.tsx | 22 +- .../channel-affinity/rule-editor-dialog.tsx | 48 +- .../general/system-behavior-section.tsx | 27 - .../monitoring-settings-section.tsx | 469 +++++----------- .../integrations/waffo-settings-section.tsx | 14 +- .../maintenance/header-navigation-section.tsx | 2 +- .../maintenance/log-settings-section.tsx | 244 ++++++++- .../maintenance/performance-section.tsx | 348 +----------- .../maintenance/sidebar-modules-section.tsx | 4 +- .../features/system-settings/models/index.tsx | 10 + .../models/routing-reliability-section.tsx | 513 ++++++++++++++++++ .../models/section-registry.tsx | 23 + .../system-settings/operations/index.tsx | 10 - .../operations/section-registry.tsx | 30 +- .../src/features/system-settings/types.ts | 18 +- web/default/src/i18n/locales/en.json | 6 + web/default/src/i18n/locales/fr.json | 6 + web/default/src/i18n/locales/ja.json | 6 + web/default/src/i18n/locales/ru.json | 6 + web/default/src/i18n/locales/vi.json | 6 + web/default/src/i18n/locales/zh.json | 6 + web/default/src/i18n/static-keys.ts | 4 + .../system-settings/operations/$section.tsx | 7 + 36 files changed, 1171 insertions(+), 782 deletions(-) create mode 100644 web/default/src/features/system-settings/models/routing-reliability-section.tsx diff --git a/AGENTS.md b/AGENTS.md index 9182cc76..d26ef0f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag - Use `common.UsingPostgreSQL`, `common.UsingSQLite`, and `common.UsingMySQL` flags for DB-specific branches. - Do not use database-specific features without cross-DB fallback, including MySQL-only functions, PostgreSQL-only operators, SQLite-unsupported `ALTER COLUMN`, or database-specific JSON column types without a `TEXT` fallback. - Migrations must work on all three databases. For SQLite, use `ALTER TABLE ... ADD COLUMN` instead of `ALTER COLUMN` (see `model/main.go` for patterns). +- Avoid GORM boolean default tags such as `gorm:"default:true"` when the default is a business rule already enforced by code. MySQL and PostgreSQL can normalize boolean defaults differently, causing GORM `AutoMigrate` to repeatedly issue `ALTER TABLE` on restart. Prefer setting these defaults in request/model normalization, hooks, constructors, or service logic; do not replace `default:true` with `default:1` unless the behavior is verified across SQLite, MySQL, and PostgreSQL. **Relay and provider behavior:** diff --git a/CLAUDE.md b/CLAUDE.md index 4d207ced..8dc697ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag - Use `common.UsingPostgreSQL`, `common.UsingSQLite`, and `common.UsingMySQL` flags for DB-specific branches. - Do not use database-specific features without cross-DB fallback, including MySQL-only functions, PostgreSQL-only operators, SQLite-unsupported `ALTER COLUMN`, or database-specific JSON column types without a `TEXT` fallback. - Migrations must work on all three databases. For SQLite, use `ALTER TABLE ... ADD COLUMN` instead of `ALTER COLUMN` (see `model/main.go` for patterns). +- Avoid GORM boolean default tags such as `gorm:"default:true"` when the default is a business rule already enforced by code. MySQL and PostgreSQL can normalize boolean defaults differently, causing GORM `AutoMigrate` to repeatedly issue `ALTER TABLE` on restart. Prefer setting these defaults in request/model normalization, hooks, constructors, or service logic; do not replace `default:true` with `default:1` unless the behavior is verified across SQLite, MySQL, and PostgreSQL. **Relay and provider behavior:** diff --git a/controller/channel.go b/controller/channel.go index 5c18fb94..5b1cefef 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -89,6 +89,12 @@ func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm return query } +func GetChannelOps(c *gin.Context) { + common.ApiSuccess(c, gin.H{ + "retry_times": common.RetryTimes, + }) +} + func GetAllChannels(c *gin.Context) { pageInfo := common.GetPageQuery(c) channelData := make([]*model.Channel, 0) diff --git a/router/api-router.go b/router/api-router.go index b69005dc..f0174e8c 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -232,6 +232,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.GET("/search", controller.SearchChannels) channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) + channelRoute.GET("/ops", controller.GetChannelOps) channelRoute.GET("/:id", controller.GetChannel) channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) channelRoute.GET("/test", controller.TestAllChannels) diff --git a/web/default/src/features/channels/api.ts b/web/default/src/features/channels/api.ts index 3ec3dbe8..303d97cd 100644 --- a/web/default/src/features/channels/api.ts +++ b/web/default/src/features/channels/api.ts @@ -24,6 +24,7 @@ import type { BatchSetTagParams, Channel, ChannelBalanceResponse, + ChannelOpsResponse, ChannelTestResponse, CopyChannelParams, CopyChannelResponse, @@ -103,6 +104,14 @@ export async function getChannel(id: number): Promise { return res.data } +/** + * Get channel operations summary for administrators + */ +export async function getChannelOps(): Promise { + const res = await api.get('/api/channel/ops', channelActionConfig()) + return res.data +} + /** * Create new channel(s) * Supports single, batch, and multi-key modes diff --git a/web/default/src/features/channels/index.tsx b/web/default/src/features/channels/index.tsx index 11a64402..79b4faa6 100644 --- a/web/default/src/features/channels/index.tsx +++ b/web/default/src/features/channels/index.tsx @@ -17,7 +17,19 @@ along with this program. If not, see . 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 ? ( + + + } + /> + } + > + {retryLabel} + + + +

{t('Retry Settings')}

+
+
+ ) : ( + + {retryLabel} + + ) + } + return ( - {t('Channels')} + + + {t('Channels')} + {retryBadge} + + diff --git a/web/default/src/features/channels/types.ts b/web/default/src/features/channels/types.ts index 4a571d17..54879d89 100644 --- a/web/default/src/features/channels/types.ts +++ b/web/default/src/features/channels/types.ts @@ -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 diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx index fad52971..4ece73ac 100644 --- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -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, diff --git a/web/default/src/features/system-settings/components/settings-form-layout.tsx b/web/default/src/features/system-settings/components/settings-form-layout.tsx index 4d395cfa..67bb7d93 100644 --- a/web/default/src/features/system-settings/components/settings-form-layout.tsx +++ b/web/default/src/features/system-settings/components/settings-form-layout.tsx @@ -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 ( diff --git a/web/default/src/features/system-settings/content/announcements-section.tsx b/web/default/src/features/system-settings/content/announcements-section.tsx index df268b63..b0a656a3 100644 --- a/web/default/src/features/system-settings/content/announcements-section.tsx +++ b/web/default/src/features/system-settings/content/announcements-section.tsx @@ -339,7 +339,7 @@ export function AnnouncementsSection({ checked={isEnabled} onCheckedChange={handleToggleEnabled} label={t('Enabled')} - className='border-b-0 py-0' + className='py-0' /> @@ -529,8 +529,7 @@ export function AnnouncementsSection({ {t('Type')} ({ + items={colorOptions.map((option) => ({ value: option.value, label: (
@@ -486,8 +485,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { {option.label}
), - })), - ]} + }))} onValueChange={field.onChange} value={field.value} > diff --git a/web/default/src/features/system-settings/content/faq-section.tsx b/web/default/src/features/system-settings/content/faq-section.tsx index 96af191a..2602114b 100644 --- a/web/default/src/features/system-settings/content/faq-section.tsx +++ b/web/default/src/features/system-settings/content/faq-section.tsx @@ -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' /> diff --git a/web/default/src/features/system-settings/content/uptime-kuma-section.tsx b/web/default/src/features/system-settings/content/uptime-kuma-section.tsx index e034bb14..3254c25a 100644 --- a/web/default/src/features/system-settings/content/uptime-kuma-section.tsx +++ b/web/default/src/features/system-settings/content/uptime-kuma-section.tsx @@ -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' /> diff --git a/web/default/src/features/system-settings/general/channel-affinity/index.tsx b/web/default/src/features/system-settings/general/channel-affinity/index.tsx index 3aea93a3..7aebcc64 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/index.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/index.tsx @@ -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' />
diff --git a/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx b/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx index dba23d4c..c6544790 100644 --- a/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx +++ b/web/default/src/features/system-settings/general/channel-affinity/rule-editor-dialog.tsx @@ -16,7 +16,7 @@ along with this program. If not, see . 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([ - { type: 'gjson', path: '' }, + const nextKeySourceRowId = useRef(0) + const createKeySourceRow = (source?: Partial): KeySourceRow => ({ + ...normalizeKeySource(source ?? { type: 'gjson', path: '' }), + rowId: String(nextKeySourceRowId.current++), + }) + const [keySources, setKeySources] = useState(() => [ + 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()]) } > @@ -310,21 +323,22 @@ export function RuleEditorDialog(props: Props) {
{keySources.map((src, idx) => (
- - - {t('Number of times to retry failed requests (0-10)')} - - - - )} - /> - . 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 type MonitoringFormInput = z.input +type MonitoringFormValues = z.output + +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( + const baselineRef = useRef( normalizeDefaults(defaultValues) ) + const baselineSerializedRef = useRef( + 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 + Object.keys(normalized) as Array ).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({ -
- ( - - - {t('Scheduled channel tests')} - - {t('Automatically probe all channels in the background')} - - - - - - - )} - /> - - ( - - {t('Test interval (minutes)')} - - - - - {t('How frequently the system tests all channels')} - - - - )} - /> -
- -
- ( - - {t('Disable threshold (seconds)')} - - field.onChange(event.target.value)} - /> - - - {t( - 'Automatically disable channels exceeding this response time' - )} - - - - )} - /> - - ( - - {t('Quota reminder (tokens)')} - - field.onChange(event.target.value)} - /> - - - {t('Send email alerts when a user falls below this quota')} - - - - )} - /> -
- -
- ( - - - {t('Disable on failure')} - - {t('Automatically disable channels when tests fail')} - - - - - - - )} - /> - - ( - - - {t('Re-enable on success')} - - {t('Bring channels back online after successful checks')} - - - - - - - )} - /> -
- ( - {t('Failure keywords')} + {t('Quota reminder (tokens)')} -