From 4add708ebe3b74e02dcf141887da2c81cb9b1526 Mon Sep 17 00:00:00 2001 From: Seefs <40468931+seefs001@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:03:59 +0800 Subject: [PATCH] feat: channel test (#6917) * feat: channel test * fix: code smell --- controller/channel-test.go | 221 ++++++++++++------ controller/channel_test_internal_test.go | 107 +++++++++ model/option.go | 3 + setting/operation_setting/monitor_setting.go | 26 +++ .../operation_setting/monitor_setting_test.go | 34 +++ .../drawers/model-mutate-drawer.tsx | 1 + .../features/system-settings/models/index.tsx | 1 + .../models/routing-reliability-section.tsx | 141 +++++++---- .../models/section-registry.tsx | 2 + web/src/features/system-settings/types.ts | 1 + web/src/i18n/locales/en.json | 5 + web/src/i18n/locales/fr.json | 5 + web/src/i18n/locales/ja.json | 5 + web/src/i18n/locales/ru.json | 5 + web/src/i18n/locales/vi.json | 5 + web/src/i18n/locales/zh-TW.json | 5 + web/src/i18n/locales/zh.json | 5 + 17 files changed, 454 insertions(+), 118 deletions(-) diff --git a/controller/channel-test.go b/controller/channel-test.go index fffc59d2..b294979d 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -12,6 +12,7 @@ import ( "net/http/httptest" "strconv" "strings" + "sync" "time" "github.com/QuantumNous/new-api/common" @@ -908,92 +909,167 @@ type channelTestSummary struct { Enabled int `json:"enabled"` } -// performChannelTests runs the channel test loop synchronously, honoring ctx -// cancellation so a system-task runner that loses its lease stops promptly. When -// report is non-nil it is called after each channel with (processed, total) so -// the system task can surface progress. -func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, report func(processed, total int)) channelTestSummary { +func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, testUserID int, allowDisable bool, disableThreshold int64) channelTestSummary { summary := channelTestSummary{} - var disableThreshold = int64(common.ChannelDisableThreshold * 1000) - if disableThreshold == 0 { - disableThreshold = 10000000 // a impossible value + isChannelEnabled := channel.Status == common.ChannelStatusEnabled + tik := time.Now() + result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel)) + milliseconds := time.Since(tik).Milliseconds() + if ctx.Err() != nil { + return summary } + summary.Tested++ + + shouldBanChannel := false + newAPIError := result.newAPIError + if newAPIError != nil { + shouldBanChannel = service.ShouldDisableChannel(result.newAPIError) + } + + if common.AutomaticDisableChannelEnabled && !shouldBanChannel { + if milliseconds > disableThreshold { + err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) + newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) + shouldBanChannel = true + } + } + + if newAPIError == nil { + summary.Succeeded++ + } else { + summary.Failed++ + } + + if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() { + processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + summary.Disabled++ + } + + if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) { + service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name) + summary.Enabled++ + } + + channel.UpdateResponseTime(milliseconds) + return summary +} + +// runChannelTestWorkers executes independent channel tests with bounded +// concurrency. Results and progress are reduced by the caller goroutine, so +// summary counts and the progress reporter remain serialized. +func runChannelTestWorkers( + ctx context.Context, + channels []*model.Channel, + concurrency int, + run func(context.Context, *model.Channel) channelTestSummary, + report func(processed, total int), +) channelTestSummary { + if ctx == nil { + ctx = context.Background() + } total := len(channels) - for index, channel := range channels { - if ctx != nil && ctx.Err() != nil { - break - } - if report != nil { - report(index, total) // channels completed before this one - } - if channel.Status == common.ChannelStatusManuallyDisabled { - continue - } - isChannelEnabled := channel.Status == common.ChannelStatusEnabled - tik := time.Now() - result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel)) - tok := time.Now() - milliseconds := tok.Sub(tik).Milliseconds() - if ctx != nil && ctx.Err() != nil { - break - } + if report != nil { + report(0, total) + } + if total == 0 { + return channelTestSummary{} + } - summary.Tested++ + workerCount := min(operation_setting.NormalizeChannelTestConcurrency(concurrency), total) + jobs := make(chan *model.Channel) + results := make(chan channelTestSummary) - shouldBanChannel := false - newAPIError := result.newAPIError - // request error disables the channel - if newAPIError != nil { - shouldBanChannel = service.ShouldDisableChannel(result.newAPIError) - } - - // 当错误检查通过,才检查响应时间 - if common.AutomaticDisableChannelEnabled && !shouldBanChannel { - if milliseconds > disableThreshold { - err := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) - newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) - shouldBanChannel = true - } - } - - if newAPIError == nil { - summary.Succeeded++ - } else { - summary.Failed++ - } - - // disable channel - if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() { - processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) - summary.Disabled++ - } - - // enable channel - if result.localErr == nil && !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) { - service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name) - summary.Enabled++ - } - - channel.UpdateResponseTime(milliseconds) - if common.RequestInterval > 0 { - if ctx == nil { - time.Sleep(common.RequestInterval) - } else { + var workers sync.WaitGroup + workers.Add(workerCount) + for range workerCount { + go func() { + defer workers.Done() + for { select { case <-ctx.Done(): - return summary - case <-time.After(common.RequestInterval): + return + case channel, ok := <-jobs: + if !ok { + return + } + if ctx.Err() != nil { + return + } + + result := channelTestSummary{} + if channel != nil && channel.Status != common.ChannelStatusManuallyDisabled { + result = run(ctx, channel) + } + + results <- result + + if common.RequestInterval > 0 { + select { + case <-ctx.Done(): + return + case <-time.After(common.RequestInterval): + } + } } } + }() + } + + go func() { + defer close(jobs) + for _, channel := range channels { + select { + case <-ctx.Done(): + return + case jobs <- channel: + } + } + }() + + go func() { + workers.Wait() + close(results) + }() + + summary := channelTestSummary{} + processed := 0 + for result := range results { + summary.Tested += result.Tested + summary.Succeeded += result.Succeeded + summary.Failed += result.Failed + summary.Disabled += result.Disabled + summary.Enabled += result.Enabled + processed++ + if report != nil && ctx.Err() == nil { + report(processed, total) } } - if report != nil && (ctx == nil || ctx.Err() == nil) { - report(total, total) // mark complete only when the full set was tested - } return summary } +// performChannelTests runs channel health checks with the configured bounded +// concurrency and honors cancellation when a system-task runner loses its +// lease. +func performChannelTests(ctx context.Context, channels []*model.Channel, testUserID int, allowDisable bool, concurrency int, report func(processed, total int)) channelTestSummary { + if ctx == nil { + ctx = context.Background() + } + disableThreshold := int64(common.ChannelDisableThreshold * 1000) + if disableThreshold == 0 { + disableThreshold = 10000000 // an impossible value + } + return runChannelTestWorkers( + ctx, + channels, + concurrency, + func(ctx context.Context, channel *model.Channel) channelTestSummary { + return testChannelForHealthCheck(ctx, channel, testUserID, allowDisable, disableThreshold) + }, + report, + ) +} + // runChannelTestTask runs one synchronous channel test cycle for the system task // runner (both the scheduled job and the manual "test all channels" trigger go // through here). It honors ctx cancellation so a runner that loses its lease @@ -1016,7 +1092,8 @@ func runChannelTestTask(ctx context.Context, mode string, notify bool, report fu } selected := selectChannelsForAutomaticTest(channels, mode) allowDisable := mode != operation_setting.ChannelTestModePassiveRecovery - summary := performChannelTests(ctx, selected, testUserID, allowDisable, report) + concurrency := operation_setting.GetMonitorSetting().ChannelTestConcurrency + summary := performChannelTests(ctx, selected, testUserID, allowDisable, concurrency, report) if notify && (ctx == nil || ctx.Err() == nil) { service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") } diff --git a/controller/channel_test_internal_test.go b/controller/channel_test_internal_test.go index 904e08da..85da7f7b 100644 --- a/controller/channel_test_internal_test.go +++ b/controller/channel_test_internal_test.go @@ -2,9 +2,11 @@ package controller import ( "bytes" + "context" "fmt" "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/QuantumNous/new-api/common" @@ -339,6 +341,111 @@ func TestSelectChannelsForAutomaticTestAutoBanOnlyUsesEligibleChannels(t *testin require.Equal(t, 3, selected[1].Id) } +func TestRunChannelTestWorkersHonorsConfiguredConcurrency(t *testing.T) { + originalInterval := common.RequestInterval + common.RequestInterval = 0 + t.Cleanup(func() { common.RequestInterval = originalInterval }) + + channels := []*model.Channel{ + {Id: 1, Status: common.ChannelStatusEnabled}, + {Id: 2, Status: common.ChannelStatusEnabled}, + {Id: 3, Status: common.ChannelStatusEnabled}, + {Id: 4, Status: common.ChannelStatusEnabled}, + } + started := make(chan struct{}, len(channels)) + release := make(chan struct{}) + var active atomic.Int32 + var maxActive atomic.Int32 + progress := make([]int, 0, len(channels)+1) + summaryResult := make(chan channelTestSummary, 1) + + go func() { + summaryResult <- runChannelTestWorkers( + context.Background(), + channels, + 2, + func(_ context.Context, _ *model.Channel) channelTestSummary { + current := active.Add(1) + defer active.Add(-1) + for { + observed := maxActive.Load() + if current <= observed || maxActive.CompareAndSwap(observed, current) { + break + } + } + started <- struct{}{} + <-release + return channelTestSummary{Tested: 1, Succeeded: 1} + }, + func(processed, _ int) { + progress = append(progress, processed) + }, + ) + }() + + <-started + <-started + select { + case <-started: + t.Fatal("started more channel tests than the configured concurrency") + default: + } + close(release) + + summary := <-summaryResult + + assert.Equal(t, int32(2), maxActive.Load()) + assert.Equal(t, channelTestSummary{Tested: 4, Succeeded: 4}, summary) + assert.Equal(t, []int{0, 1, 2, 3, 4}, progress) +} + +func TestRunChannelTestWorkersStopsAfterCancellation(t *testing.T) { + originalInterval := common.RequestInterval + common.RequestInterval = 0 + t.Cleanup(func() { common.RequestInterval = originalInterval }) + + ctx, cancel := context.WithCancel(context.Background()) + channels := []*model.Channel{ + {Id: 1, Status: common.ChannelStatusEnabled}, + {Id: 2, Status: common.ChannelStatusEnabled}, + {Id: 3, Status: common.ChannelStatusEnabled}, + {Id: 4, Status: common.ChannelStatusEnabled}, + } + started := make(chan struct{}, len(channels)) + progress := make([]int, 0, 1) + summaryResult := make(chan channelTestSummary, 1) + + go func() { + summaryResult <- runChannelTestWorkers( + ctx, + channels, + 2, + func(ctx context.Context, _ *model.Channel) channelTestSummary { + started <- struct{}{} + <-ctx.Done() + return channelTestSummary{Tested: 1, Succeeded: 1} + }, + func(processed, _ int) { + progress = append(progress, processed) + }, + ) + }() + + <-started + <-started + cancel() + + summary := <-summaryResult + + select { + case <-started: + t.Fatal("started another channel test after cancellation") + default: + } + assert.Equal(t, channelTestSummary{Tested: 2, Succeeded: 2}, summary) + assert.Equal(t, []int{0}, progress) +} + func TestTestAllChannelsRejectsExistingActiveTask(t *testing.T) { db := setupModelListControllerTestDB(t) require.NoError(t, db.AutoMigrate(&model.SystemTask{}, &model.SystemTaskLock{})) diff --git a/model/option.go b/model/option.go index e7fda523..d7870653 100644 --- a/model/option.go +++ b/model/option.go @@ -209,6 +209,9 @@ func validateOptionValue(key string, value string) error { if key == operation_setting.ToolPriceOptionKey { return operation_setting.ValidateToolPricesJSON(value) } + if key == operation_setting.ChannelTestConcurrencyOptionKey { + return operation_setting.ValidateChannelTestConcurrency(value) + } if key == "MaxTokenAutoGroups" { return setting.ValidateMaxTokenAutoGroups(value) } diff --git a/setting/operation_setting/monitor_setting.go b/setting/operation_setting/monitor_setting.go index a88087f2..858cc0c7 100644 --- a/setting/operation_setting/monitor_setting.go +++ b/setting/operation_setting/monitor_setting.go @@ -1,6 +1,7 @@ package operation_setting import ( + "fmt" "os" "strconv" @@ -11,12 +12,17 @@ type MonitorSetting struct { AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` AutoTestChannelMinutes float64 `json:"auto_test_channel_minutes"` ChannelTestMode string `json:"channel_test_mode"` + ChannelTestConcurrency int `json:"channel_test_concurrency"` } const ( ChannelTestModeScheduledAll = "scheduled_all" ChannelTestModeAutoBanOnly = "auto_ban_only" ChannelTestModePassiveRecovery = "passive_recovery" + + ChannelTestConcurrencyOptionKey = "monitor_setting.channel_test_concurrency" + DefaultChannelTestConcurrency = 1 + MaxChannelTestConcurrency = 32 ) // 默认配置 @@ -24,6 +30,7 @@ var monitorSetting = MonitorSetting{ AutoTestChannelEnabled: false, AutoTestChannelMinutes: 10, ChannelTestMode: ChannelTestModeScheduledAll, + ChannelTestConcurrency: DefaultChannelTestConcurrency, } func init() { @@ -51,5 +58,24 @@ func GetMonitorSetting() *MonitorSetting { default: monitorSetting.ChannelTestMode = ChannelTestModeScheduledAll } + monitorSetting.ChannelTestConcurrency = NormalizeChannelTestConcurrency(monitorSetting.ChannelTestConcurrency) return &monitorSetting } + +func NormalizeChannelTestConcurrency(concurrency int) int { + if concurrency < 1 { + return DefaultChannelTestConcurrency + } + if concurrency > MaxChannelTestConcurrency { + return MaxChannelTestConcurrency + } + return concurrency +} + +func ValidateChannelTestConcurrency(value string) error { + concurrency, err := strconv.Atoi(value) + if err != nil || concurrency < 1 || concurrency > MaxChannelTestConcurrency { + return fmt.Errorf("channel test concurrency must be between 1 and %d", MaxChannelTestConcurrency) + } + return nil +} diff --git a/setting/operation_setting/monitor_setting_test.go b/setting/operation_setting/monitor_setting_test.go index c31023e8..78e6f7c0 100644 --- a/setting/operation_setting/monitor_setting_test.go +++ b/setting/operation_setting/monitor_setting_test.go @@ -55,3 +55,37 @@ func TestGetMonitorSettingPreservesAutoBanOnlyMode(t *testing.T) { require.NotNil(t, setting) assert.Equal(t, ChannelTestModeAutoBanOnly, setting.ChannelTestMode) } + +func TestGetMonitorSettingNormalizesChannelTestConcurrency(t *testing.T) { + orig := monitorSetting + t.Cleanup(func() { monitorSetting = orig }) + + tests := []struct { + name string + concurrency int + want int + }{ + {name: "missing uses safe default", concurrency: 0, want: DefaultChannelTestConcurrency}, + {name: "configured value is preserved", concurrency: 8, want: 8}, + {name: "oversized value is capped", concurrency: MaxChannelTestConcurrency + 1, want: MaxChannelTestConcurrency}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + monitorSetting = MonitorSetting{ChannelTestConcurrency: test.concurrency} + + setting := GetMonitorSetting() + + require.NotNil(t, setting) + assert.Equal(t, test.want, setting.ChannelTestConcurrency) + }) + } +} + +func TestValidateChannelTestConcurrency(t *testing.T) { + require.NoError(t, ValidateChannelTestConcurrency("1")) + require.NoError(t, ValidateChannelTestConcurrency("32")) + assert.Error(t, ValidateChannelTestConcurrency("0")) + assert.Error(t, ValidateChannelTestConcurrency("33")) + assert.Error(t, ValidateChannelTestConcurrency("1.5")) +} diff --git a/web/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/src/features/models/components/drawers/model-mutate-drawer.tsx index 532b8931..d9d7ccad 100644 --- a/web/src/features/models/components/drawers/model-mutate-drawer.tsx +++ b/web/src/features/models/components/drawers/model-mutate-drawer.tsx @@ -335,6 +335,7 @@ export function ModelMutateDrawer({ '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, + 'monitor_setting.channel_test_concurrency': 1, 'monitor_setting.channel_test_mode': 'scheduled_all', 'channel_affinity_setting.enabled': false, 'channel_affinity_setting.switch_on_success': true, diff --git a/web/src/features/system-settings/models/index.tsx b/web/src/features/system-settings/models/index.tsx index 0448720a..0bc0f992 100644 --- a/web/src/features/system-settings/models/index.tsx +++ b/web/src/features/system-settings/models/index.tsx @@ -73,6 +73,7 @@ const defaultModelSettings: ModelSettings = { '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, + 'monitor_setting.channel_test_concurrency': 1, 'monitor_setting.channel_test_mode': 'scheduled_all', 'channel_affinity_setting.enabled': false, 'channel_affinity_setting.switch_on_success': true, diff --git a/web/src/features/system-settings/models/routing-reliability-section.tsx b/web/src/features/system-settings/models/routing-reliability-section.tsx index 1b8527ec..dec74437 100644 --- a/web/src/features/system-settings/models/routing-reliability-section.tsx +++ b/web/src/features/system-settings/models/routing-reliability-section.tsx @@ -69,55 +69,70 @@ const channelTestModes = [ 'passive_recovery', ] as const type ChannelTestMode = (typeof channelTestModes)[number] +const MAX_CHANNEL_TEST_CONCURRENCY = 32 -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'), - channel_test_mode: z.enum(channelTestModes), - }), - }) - .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 createRoutingReliabilitySchema = ( + t: (key: string, options?: Record) => string +) => + 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, t('Interval must be at least 1 minute')), + channel_test_concurrency: z.coerce + .number() + .int(t('Enter a positive integer')) + .min(1, t('Channel test concurrency must be between 1 and 32')) + .max( + MAX_CHANNEL_TEST_CONCURRENCY, + t('Channel test concurrency must be between 1 and 32') + ), + channel_test_mode: z.enum(channelTestModes), + }), + }) + .superRefine((values, ctx) => { + const disableParsed = parseHttpStatusCodeRules( + values.AutomaticDisableStatusCodes + ) + if (!disableParsed.ok) { + ctx.addIssue({ + code: 'custom', + path: ['AutomaticDisableStatusCodes'], + message: t('Invalid status code rules: {{tokens}}', { + tokens: 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( - ', ' - )}`, - }) - } - }) + const retryParsed = parseHttpStatusCodeRules( + values.AutomaticRetryStatusCodes + ) + if (!retryParsed.ok) { + ctx.addIssue({ + code: 'custom', + path: ['AutomaticRetryStatusCodes'], + message: t('Invalid status code rules: {{tokens}}', { + tokens: retryParsed.invalidTokens.join(', '), + }), + }) + } + }) -type RoutingReliabilityFormValues = z.output -type RoutingReliabilityFormInput = z.input +type RoutingReliabilitySchema = ReturnType< + typeof createRoutingReliabilitySchema +> +type RoutingReliabilityFormValues = z.output +type RoutingReliabilityFormInput = z.input type RoutingReliabilitySectionProps = { defaultValues: { @@ -130,6 +145,7 @@ type RoutingReliabilitySectionProps = { AutomaticRetryStatusCodes: string 'monitor_setting.auto_test_channel_enabled': boolean 'monitor_setting.auto_test_channel_minutes': number + 'monitor_setting.channel_test_concurrency': number 'monitor_setting.channel_test_mode': ChannelTestMode } } @@ -148,6 +164,7 @@ type NormalizedRoutingReliabilityValues = { AutomaticRetryStatusCodes: string 'monitor_setting.auto_test_channel_enabled': boolean 'monitor_setting.auto_test_channel_minutes': number + 'monitor_setting.channel_test_concurrency': number 'monitor_setting.channel_test_mode': ChannelTestMode } @@ -175,6 +192,8 @@ const buildFormDefaults = ( defaults['monitor_setting.auto_test_channel_enabled'], auto_test_channel_minutes: defaults['monitor_setting.auto_test_channel_minutes'], + channel_test_concurrency: + defaults['monitor_setting.channel_test_concurrency'], channel_test_mode: normalizeChannelTestMode( defaults['monitor_setting.channel_test_mode'] ), @@ -201,6 +220,8 @@ const normalizeDefaults = ( defaults['monitor_setting.auto_test_channel_enabled'], 'monitor_setting.auto_test_channel_minutes': defaults['monitor_setting.auto_test_channel_minutes'], + 'monitor_setting.channel_test_concurrency': + defaults['monitor_setting.channel_test_concurrency'], 'monitor_setting.channel_test_mode': normalizeChannelTestMode( defaults['monitor_setting.channel_test_mode'] ), @@ -226,6 +247,8 @@ const normalizeFormValues = ( values.monitor_setting.auto_test_channel_enabled, 'monitor_setting.auto_test_channel_minutes': values.monitor_setting.auto_test_channel_minutes, + 'monitor_setting.channel_test_concurrency': + values.monitor_setting.channel_test_concurrency, 'monitor_setting.channel_test_mode': values.monitor_setting.channel_test_mode, }) @@ -234,6 +257,7 @@ export function RoutingReliabilitySection({ }: RoutingReliabilitySectionProps) { const { t } = useTranslation() const updateOption = useUpdateOption() + const routingReliabilitySchema = createRoutingReliabilitySchema(t) const baselineRef = useRef( normalizeDefaults(defaultValues) ) @@ -484,6 +508,31 @@ export function RoutingReliabilitySection({ )} /> + ( + + {t('Channel test concurrency')} + + + + + {t( + 'Maximum number of channels tested at the same time (1-32)' + )} + + + + )} + /> +