feat: channel test (#6917)

* feat: channel test

* fix: code smell
This commit is contained in:
Seefs
2026-08-18 18:03:59 +08:00
committed by GitHub
parent 2b0efd8484
commit 4add708ebe
17 changed files with 454 additions and 118 deletions
+149 -72
View File
@@ -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, "通道测试完成", "所有通道测试已完成")
}
+107
View File
@@ -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{}))
+3
View File
@@ -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)
}
@@ -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
}
@@ -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"))
}
@@ -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,
@@ -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,
@@ -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, unknown>) => 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<typeof routingReliabilitySchema>
type RoutingReliabilityFormInput = z.input<typeof routingReliabilitySchema>
type RoutingReliabilitySchema = ReturnType<
typeof createRoutingReliabilitySchema
>
type RoutingReliabilityFormValues = z.output<RoutingReliabilitySchema>
type RoutingReliabilityFormInput = z.input<RoutingReliabilitySchema>
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<NormalizedRoutingReliabilityValues>(
normalizeDefaults(defaultValues)
)
@@ -484,6 +508,31 @@ export function RoutingReliabilitySection({
)}
/>
<FormField
control={form.control}
name='monitor_setting.channel_test_concurrency'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Channel test concurrency')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
max={MAX_CHANNEL_TEST_CONCURRENCY}
step={1}
{...safeNumberFieldProps(field)}
/>
</FormControl>
<FormDescription>
{t(
'Maximum number of channels tested at the same time (1-32)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='AutomaticEnableChannelEnabled'
@@ -83,6 +83,8 @@ const MODELS_SECTIONS = [
settings['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
'monitor_setting.channel_test_concurrency':
settings['monitor_setting.channel_test_concurrency'],
'monitor_setting.channel_test_mode':
settings['monitor_setting.channel_test_mode'],
}}
@@ -235,6 +235,7 @@ export type ModelSettings = {
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':
| 'scheduled_all'
| 'auto_ban_only'
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "Channel models",
"Channel name is required": "Channel name is required",
"Channel test completed": "Channel test completed",
"Channel test concurrency": "Channel test concurrency",
"Channel test concurrency must be between 1 and 32": "Channel test concurrency must be between 1 and 32",
"Channel test mode": "Channel test mode",
"Channel type is required": "Channel type is required",
"Channel updated successfully": "Channel updated successfully",
@@ -2377,6 +2379,7 @@
"Internal Notes": "Internal Notes",
"Internal notes (not shown to users)": "Internal notes (not shown to users)",
"Internal Server Error!": "Internal Server Error!",
"Interval must be at least 1 minute": "Interval must be at least 1 minute",
"Invalid (NaN)": "Invalid (NaN)",
"Invalid chat link. Please contact the administrator.": "Invalid chat link. Please contact the administrator.",
"Invalid chat link. Please contact your administrator.": "Invalid chat link. Please contact your administrator.",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "Invalid reset link, please request a new password reset.",
"Invalid rules JSON format": "Invalid rules JSON format",
"Invalid status code mapping entries: {{entries}}": "Invalid status code mapping entries: {{entries}}",
"Invalid status code rules: {{tokens}}": "Invalid status code rules: {{tokens}}",
"Invalidate": "Invalidate",
"Invalidated": "Invalidated",
"Invert match": "Invert match",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "Maximum check-in quota",
"Maximum custom groups per token": "Maximum custom groups per token",
"Maximum input window": "Maximum input window",
"Maximum number of channels tested at the same time (1-32)": "Maximum number of channels tested at the same time (1-32)",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.",
"Maximum number of tokens in the response": "Maximum number of tokens in the response",
"Maximum quota amount awarded for check-in": "Maximum quota amount awarded for check-in",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "Modèles de canaux",
"Channel name is required": "Le nom du canal est requis",
"Channel test completed": "Test du canal terminé",
"Channel test concurrency": "Parallélisme des tests de canaux",
"Channel test concurrency must be between 1 and 32": "Le parallélisme des tests de canaux doit être compris entre 1 et 32",
"Channel test mode": "Mode de test des canaux",
"Channel type is required": "Le type de canal est requis",
"Channel updated successfully": "Canal mis à jour avec succès",
@@ -2377,6 +2379,7 @@
"Internal Notes": "Notes internes",
"Internal notes (not shown to users)": "Notes internes (non visibles par les utilisateurs)",
"Internal Server Error!": "Erreur interne du serveur !",
"Interval must be at least 1 minute": "L'intervalle doit être d'au moins 1 minute",
"Invalid (NaN)": "Invalide (NaN)",
"Invalid chat link. Please contact the administrator.": "Lien de chat invalide. Veuillez contacter l'administrateur.",
"Invalid chat link. Please contact your administrator.": "Lien de chat invalide. Veuillez contacter votre administrateur.",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "Lien de réinitialisation invalide, veuillez demander une nouvelle réinitialisation du mot de passe.",
"Invalid rules JSON format": "Format JSON des règles invalide",
"Invalid status code mapping entries: {{entries}}": "Entrées de mappage de code d'état invalides : {{entries}}",
"Invalid status code rules: {{tokens}}": "Règles de code de statut invalides : {{tokens}}",
"Invalidate": "Invalider",
"Invalidated": "Invalidé",
"Invert match": "Inverser la correspondance",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "Quota maximum de connexion",
"Maximum custom groups per token": "Nombre maximal de groupes personnalisés par jeton",
"Maximum input window": "Fenêtre d'entrée maximale",
"Maximum number of channels tested at the same time (1-32)": "Nombre maximal de canaux testés simultanément (1 à 32)",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Nombre maximum de jetons que chaque utilisateur peut créer. Par défaut 1000. Une valeur trop élevée peut affecter les performances.",
"Maximum number of tokens in the response": "Nombre maximum de jetons dans la réponse",
"Maximum quota amount awarded for check-in": "Montant maximum de quota attribué pour la connexion",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "チャネルモデル",
"Channel name is required": "チャネル名が必要です",
"Channel test completed": "チャネルテストが完了しました",
"Channel test concurrency": "チャンネルテストの同時実行数",
"Channel test concurrency must be between 1 and 32": "チャンネルテストの同時実行数は1~32にしてください",
"Channel test mode": "チャネルテストモード",
"Channel type is required": "チャネルタイプが必要です",
"Channel updated successfully": "チャネルが正常に更新されました",
@@ -2377,6 +2379,7 @@
"Internal Notes": "内部メモ",
"Internal notes (not shown to users)": ":内部メモ(ユーザーには表示されません)",
"Internal Server Error!": "内部サーバーエラー!",
"Interval must be at least 1 minute": "間隔は1分以上にしてください",
"Invalid (NaN)": "無効 (NaN)",
"Invalid chat link. Please contact the administrator.": "無効なチャットリンクです。管理者に連絡してください。",
"Invalid chat link. Please contact your administrator.": "無効なチャットリンクです。管理者に連絡してください。",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "無効なリセットリンクです。新しいパスワードリセットをリクエストしてください。",
"Invalid rules JSON format": "ルール JSON の形式が不正です",
"Invalid status code mapping entries: {{entries}}": "無効なステータスコードマッピング:{{entries}}",
"Invalid status code rules: {{tokens}}": "無効なステータスコードルール:{{tokens}}",
"Invalidate": "無効化",
"Invalidated": "無効化済み",
"Invert match": "一致を反転",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "最大チェックインクォータ",
"Maximum custom groups per token": "トークンごとのカスタムグループ上限",
"Maximum input window": "最大入力ウィンドウ",
"Maximum number of channels tested at the same time (1-32)": "同時にテストするチャンネルの最大数(1~32)",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "各ユーザーが作成できる最大トークン数。デフォルトは 1000。大きすぎる値はパフォーマンスに影響を与える可能性があります。",
"Maximum number of tokens in the response": "レスポンスの最大トークン数",
"Maximum quota amount awarded for check-in": "チェックインで付与される最大クォータ量",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "Модели каналов",
"Channel name is required": "Имя канала обязательно",
"Channel test completed": "Тест канала завершён",
"Channel test concurrency": "Параллельность проверки каналов",
"Channel test concurrency must be between 1 and 32": "Параллельность проверки каналов должна быть от 1 до 32",
"Channel test mode": "Режим проверки каналов",
"Channel type is required": "Тип канала обязателен",
"Channel updated successfully": "Канал успешно обновлён",
@@ -2377,6 +2379,7 @@
"Internal Notes": "Внутренние заметки",
"Internal notes (not shown to users)": "Внутренние заметки (не показываются пользователям)",
"Internal Server Error!": "Внутренняя ошибка сервера!",
"Interval must be at least 1 minute": "Интервал должен быть не менее 1 минуты",
"Invalid (NaN)": "Недопустимо (NaN)",
"Invalid chat link. Please contact the administrator.": "Неверная ссылка на чат. Пожалуйста, обратитесь к администратору.",
"Invalid chat link. Please contact your administrator.": "Недействительная ссылка чата. Обратитесь к администратору.",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "Недействительная ссылка для сброса, пожалуйста, запросите новый сброс пароля.",
"Invalid rules JSON format": "Неверный формат JSON правил",
"Invalid status code mapping entries: {{entries}}": "Недопустимые записи маппинга кодов состояния: {{entries}}",
"Invalid status code rules: {{tokens}}": "Недопустимые правила кодов состояния: {{tokens}}",
"Invalidate": "Аннулировать",
"Invalidated": "Аннулирована",
"Invert match": "Инвертировать совпадение",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "Максимальная квота регистрации",
"Maximum custom groups per token": "Максимум пользовательских групп на токен",
"Maximum input window": "Максимальное окно ввода",
"Maximum number of channels tested at the same time (1-32)": "Максимальное число одновременно проверяемых каналов (1–32)",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Максимальное количество токенов, которое может создать каждый пользователь. По умолчанию 1000. Слишком большое значение может повлиять на производительность.",
"Maximum number of tokens in the response": "Максимальное число токенов в ответе",
"Maximum quota amount awarded for check-in": "Максимальная сумма квоты, присуждаемая за регистрацию",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "Mô hình kênh",
"Channel name is required": "Tên kênh là bắt buộc",
"Channel test completed": "Kiểm tra kênh hoàn tất",
"Channel test concurrency": "Mức đồng thời khi kiểm tra kênh",
"Channel test concurrency must be between 1 and 32": "Mức đồng thời khi kiểm tra kênh phải từ 1 đến 32",
"Channel test mode": "Chế độ kiểm tra kênh",
"Channel type is required": "Loại kênh là bắt buộc",
"Channel updated successfully": "Kênh đã được cập nhật thành công",
@@ -2377,6 +2379,7 @@
"Internal Notes": "Ghi chú nội bộ",
"Internal notes (not shown to users)": "Ghi chú nội bộ (không hiển thị cho người dùng)",
"Internal Server Error!": "Lỗi máy chủ nội bộ!",
"Interval must be at least 1 minute": "Khoảng thời gian phải ít nhất 1 phút",
"Invalid (NaN)": "Không hợp lệ (NaN)",
"Invalid chat link. Please contact the administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ quản trị viên.",
"Invalid chat link. Please contact your administrator.": "Liên kết trò chuyện không hợp lệ. Vui lòng liên hệ với quản trị viên của bạn.",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "Liên kết đặt lại không hợp lệ, vui lòng yêu cầu đặt lại mật khẩu mới.",
"Invalid rules JSON format": "Định dạng JSON quy tắc không hợp lệ",
"Invalid status code mapping entries: {{entries}}": "Mục ánh xạ mã trạng thái không hợp lệ: {{entries}}",
"Invalid status code rules: {{tokens}}": "Quy tắc mã trạng thái không hợp lệ: {{tokens}}",
"Invalidate": "Vô hiệu hóa",
"Invalidated": "Đã vô hiệu",
"Invert match": "Đảo điều kiện khớp",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "Hạn ngạch điểm danh tối đa",
"Maximum custom groups per token": "Số nhóm tùy chỉnh tối đa cho mỗi token",
"Maximum input window": "Cửa sổ nhập tối đa",
"Maximum number of channels tested at the same time (1-32)": "Số kênh tối đa được kiểm tra cùng lúc (132)",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "Số lượng token tối đa mỗi người dùng có thể tạo. Mặc định là 1000. Đặt quá lớn có thể ảnh hưởng đến hiệu suất.",
"Maximum number of tokens in the response": "Số token tối đa trong phản hồi",
"Maximum quota amount awarded for check-in": "Số lượng hạn ngạch tối đa được trao cho điểm danh",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "渠道模型",
"Channel name is required": "渠道名稱是必填的",
"Channel test completed": "渠道測試完成",
"Channel test concurrency": "渠道測試並行數",
"Channel test concurrency must be between 1 and 32": "渠道測試並行數必須介於 1 到 32 之間",
"Channel test mode": "渠道測試模式",
"Channel type is required": "渠道類型是必填的",
"Channel updated successfully": "渠道更新成功",
@@ -2377,6 +2379,7 @@
"Internal Notes": "內部備註",
"Internal notes (not shown to users)": "內部備註(不顯示給用戶)",
"Internal Server Error!": "內部伺服器錯誤!",
"Interval must be at least 1 minute": "間隔必須至少為 1 分鐘",
"Invalid (NaN)": "無效 (NaN)",
"Invalid chat link. Please contact the administrator.": "無效的聊天連結。請聯絡管理員。",
"Invalid chat link. Please contact your administrator.": "無效的聊天連結。請聯絡您的管理員。",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "無效的重置連結,請請求新的密碼重置。",
"Invalid rules JSON format": "規則 JSON 格式不正確",
"Invalid status code mapping entries: {{entries}}": "無效的狀態碼映射條目:{{entries}}",
"Invalid status code rules: {{tokens}}": "無效的狀態碼規則:{{tokens}}",
"Invalidate": "作廢",
"Invalidated": "已作廢",
"Invert match": "反向匹配",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "簽到最大額度",
"Maximum custom groups per token": "每個令牌的最大自訂分組數",
"Maximum input window": "最大輸入窗口",
"Maximum number of channels tested at the same time (1-32)": "同時測試的最大渠道數(1-32",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每個用戶可建立的最大令牌數量。預設 1000。設定過大可能會影響效能。",
"Maximum number of tokens in the response": "回應中最大 token 數",
"Maximum quota amount awarded for check-in": "簽到獎勵的最大額度",
+5
View File
@@ -776,6 +776,8 @@
"Channel models": "渠道模型",
"Channel name is required": "渠道名称是必填的",
"Channel test completed": "渠道测试完成",
"Channel test concurrency": "渠道测试并发数",
"Channel test concurrency must be between 1 and 32": "渠道测试并发数必须在 1 到 32 之间",
"Channel test mode": "渠道测试模式",
"Channel type is required": "渠道类型是必填的",
"Channel updated successfully": "渠道更新成功",
@@ -2377,6 +2379,7 @@
"Internal Notes": "内部备注",
"Internal notes (not shown to users)": "内部备注(不显示给用户)",
"Internal Server Error!": "内部服务器错误!",
"Interval must be at least 1 minute": "间隔必须至少为 1 分钟",
"Invalid (NaN)": "无效 (NaN)",
"Invalid chat link. Please contact the administrator.": "无效的聊天链接。请联系管理员。",
"Invalid chat link. Please contact your administrator.": "无效的聊天链接。请联系您的管理员。",
@@ -2394,6 +2397,7 @@
"Invalid reset link, please request a new password reset.": "无效的重置链接,请请求新的密码重置。",
"Invalid rules JSON format": "规则 JSON 格式不正确",
"Invalid status code mapping entries: {{entries}}": "无效的状态码映射条目:{{entries}}",
"Invalid status code rules: {{tokens}}": "无效的状态码规则:{{tokens}}",
"Invalidate": "作废",
"Invalidated": "已作废",
"Invert match": "反向匹配",
@@ -2650,6 +2654,7 @@
"Maximum check-in quota": "签到最大额度",
"Maximum custom groups per token": "每个令牌的最大自定义分组数",
"Maximum input window": "最大输入窗口",
"Maximum number of channels tested at the same time (1-32)": "同时测试的最大渠道数(1-32",
"Maximum number of tokens each user can create. Default 1000. Setting too large may affect performance.": "每个用户可创建的最大令牌数量。默认 1000。设置过大可能会影响性能。",
"Maximum number of tokens in the response": "响应中最大 token 数",
"Maximum quota amount awarded for check-in": "签到奖励的最大额度",