feat: add routing reliability management
This commit is contained in:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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:**
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "Auto Group Chain",
|
||||
"Auto refresh": "Auto refresh",
|
||||
"Auto Sync Upstream Models": "Auto Sync Upstream Models",
|
||||
"Auto-disable rules": "Auto-disable rules",
|
||||
"Auto-disable status codes": "Auto-disable status codes",
|
||||
"Auto-discover": "Auto-discover",
|
||||
"Auto-discovers endpoints from the provider": "Auto-discovers endpoints from the provider",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "Channel Affinity",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.",
|
||||
"Channel Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
|
||||
"Channel health checks": "Channel health checks",
|
||||
"Channel copied successfully": "Channel copied successfully",
|
||||
"Channel created successfully": "Channel created successfully",
|
||||
"Channel deleted successfully": "Channel deleted successfully",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)",
|
||||
"Max Entries": "Max Entries",
|
||||
"Max output": "Max output",
|
||||
"Max Retries": "Max Retries",
|
||||
"Max Requests (incl. failures)": "Max Requests (incl. failures)",
|
||||
"Max Requests (including failures)": "Max Requests (including failures)",
|
||||
"Max requests per period": "Max requests per period",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "Restore defaults",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
|
||||
"Result": "Result",
|
||||
"Request retry": "Request retry",
|
||||
"Retain last N days": "Retain last N days",
|
||||
"Retain last N files": "Retain last N files",
|
||||
"Retention days": "Retention days",
|
||||
"Retry": "Retry",
|
||||
"Retry Chain": "Retry Chain",
|
||||
"Retry Settings": "Retry Settings",
|
||||
"Retry Suggestion": "Retry Suggestion",
|
||||
"Retry Times": "Retry Times",
|
||||
"Return a custom error immediately": "Return a custom error immediately",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
|
||||
"Routes": "Routes",
|
||||
"Routing & Overrides": "Routing & Overrides",
|
||||
"Routing Reliability": "Routing Reliability",
|
||||
"Routing Strategy": "Routing Strategy",
|
||||
"Rows per page": "Rows per page",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "Chaîne de groupes automatique",
|
||||
"Auto refresh": "Actualisation automatique",
|
||||
"Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont",
|
||||
"Auto-disable rules": "Règles de désactivation automatique",
|
||||
"Auto-disable status codes": "Codes de statut de désactivation auto",
|
||||
"Auto-discover": "Découverte automatique",
|
||||
"Auto-discovers endpoints from the provider": "Découvre automatiquement les points de terminaison du fournisseur",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "Affinité de canal",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "L'affinité de canal réutilise le dernier canal ayant réussi, en se basant sur les clés extraites du contexte de la requête ou du corps JSON.",
|
||||
"Channel Affinity: Upstream Cache Hit": "Affinité de canal : hit de cache en amont",
|
||||
"Channel health checks": "Contrôles de santé des canaux",
|
||||
"Channel copied successfully": "Canal copié avec succès",
|
||||
"Channel created successfully": "Canal créé avec succès",
|
||||
"Channel deleted successfully": "Canal supprimé avec succès",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "Taille max du cache disque (Mo)",
|
||||
"Max Entries": "Entrées max",
|
||||
"Max output": "Sortie max",
|
||||
"Max Retries": "Relances max",
|
||||
"Max Requests (incl. failures)": "Max Requêtes (incl. échecs)",
|
||||
"Max Requests (including failures)": "Max Requêtes (incluant les échecs)",
|
||||
"Max requests per period": "Nombre max de requêtes par période",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "Restaurer les paramètres par défaut",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Restreindre la fréquence des requêtes du modèle utilisateur (peut impacter les performances en cas de forte concurrence)",
|
||||
"Result": "Résultat",
|
||||
"Request retry": "Relance des requêtes",
|
||||
"Retain last N days": "Conserver les N derniers jours",
|
||||
"Retain last N files": "Conserver les N derniers fichiers",
|
||||
"Retention days": "Jours de rétention",
|
||||
"Retry": "Réessayer",
|
||||
"Retry Chain": "Chaîne de tentatives",
|
||||
"Retry Settings": "Paramètres de relance",
|
||||
"Retry Suggestion": "Suggestion de relance",
|
||||
"Retry Times": "Nombre de tentatives",
|
||||
"Return a custom error immediately": "Retourner immédiatement une erreur personnalisée",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit",
|
||||
"Routes": "Routes",
|
||||
"Routing & Overrides": "Routage et surcharges",
|
||||
"Routing Reliability": "Fiabilité du routage",
|
||||
"Routing Strategy": "Stratégie de routage",
|
||||
"Rows per page": "Lignes par page",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "自動グループチェーン",
|
||||
"Auto refresh": "自動更新",
|
||||
"Auto Sync Upstream Models": "アップストリームモデルの自動同期",
|
||||
"Auto-disable rules": "自動無効化ルール",
|
||||
"Auto-disable status codes": "自動無効化するステータスコード",
|
||||
"Auto-discover": "自動検出",
|
||||
"Auto-discovers endpoints from the provider": "プロバイダーからエンドポイントを自動検出します",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "チャネルアフィニティ",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。",
|
||||
"Channel Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
|
||||
"Channel health checks": "チャネルヘルスチェック",
|
||||
"Channel copied successfully": "チャネルが正常にコピーされました",
|
||||
"Channel created successfully": "チャネルが正常に作成されました",
|
||||
"Channel deleted successfully": "チャネルが正常に削除されました",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "ディスクキャッシュ最大容量 (MB)",
|
||||
"Max Entries": "最大エントリ数",
|
||||
"Max output": "最大出力",
|
||||
"Max Retries": "最大再試行",
|
||||
"Max Requests (incl. failures)": "最大リクエスト数(失敗を含む)",
|
||||
"Max Requests (including failures)": "最大リクエスト数(失敗を含む)",
|
||||
"Max requests per period": "期間あたりの最大リクエスト数",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "既定に戻す",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "ユーザーモデルのリクエスト頻度を制限する(高並行性パフォーマンスに影響を与える可能性があります)",
|
||||
"Result": "結果",
|
||||
"Request retry": "リクエスト再試行",
|
||||
"Retain last N days": "最新N日間を保持",
|
||||
"Retain last N files": "最新N個のファイルを保持",
|
||||
"Retention days": "保持日数",
|
||||
"Retry": "再試行",
|
||||
"Retry Chain": "リトライチェーン",
|
||||
"Retry Settings": "再試行設定",
|
||||
"Retry Suggestion": "リトライ提案",
|
||||
"Retry Times": "再試行回数",
|
||||
"Return a custom error immediately": "即座にカスタムエラーを返す",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "ルート、認証、残高確認を一か所に集約",
|
||||
"Routes": "ルート",
|
||||
"Routing & Overrides": "ルーティングと上書き",
|
||||
"Routing Reliability": "ルーティング信頼性",
|
||||
"Routing Strategy": "ルーティング戦略",
|
||||
"Rows per page": "ページあたりの行数",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "Автоматическая цепочка групп",
|
||||
"Auto refresh": "Автообновление",
|
||||
"Auto Sync Upstream Models": "Автоматическая синхронизация моделей провайдера",
|
||||
"Auto-disable rules": "Правила автоотключения",
|
||||
"Auto-disable status codes": "Коды автоотключения",
|
||||
"Auto-discover": "Автообнаружение",
|
||||
"Auto-discovers endpoints from the provider": "Автоматически обнаруживает конечные точки от провайдера",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "Привязка к каналу",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.",
|
||||
"Channel Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
|
||||
"Channel health checks": "Проверки состояния каналов",
|
||||
"Channel copied successfully": "Канал успешно скопирован",
|
||||
"Channel created successfully": "Канал успешно создан",
|
||||
"Channel deleted successfully": "Канал успешно удалён",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "Макс. размер дискового кэша (МБ)",
|
||||
"Max Entries": "Макс. записей",
|
||||
"Max output": "Макс. вывод",
|
||||
"Max Retries": "Макс. повторов",
|
||||
"Max Requests (incl. failures)": "Макс. запросов (вкл. сбои)",
|
||||
"Max Requests (including failures)": "Макс. запросов (включая сбои)",
|
||||
"Max requests per period": "Макс. запросов за период",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "Сбросить к значениям по умолчанию",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Ограничить частоту запросов пользовательских моделей (может повлиять на производительность при высокой конкуренции)",
|
||||
"Result": "Результат",
|
||||
"Request retry": "Повтор запросов",
|
||||
"Retain last N days": "Хранить последние N дней",
|
||||
"Retain last N files": "Хранить последние N файлов",
|
||||
"Retention days": "Дней хранения",
|
||||
"Retry": "Повторить попытку",
|
||||
"Retry Chain": "Цепочка повторов",
|
||||
"Retry Settings": "Настройки повторов",
|
||||
"Retry Suggestion": "Рекомендация по повтору",
|
||||
"Retry Times": "Количество повторных попыток",
|
||||
"Return a custom error immediately": "Немедленно вернуть пользовательскую ошибку",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "Маршрут, аутентификация и баланс в одном месте",
|
||||
"Routes": "Маршруты",
|
||||
"Routing & Overrides": "Маршрутизация и переопределения",
|
||||
"Routing Reliability": "Надежность маршрутизации",
|
||||
"Routing Strategy": "Стратегия маршрутизации",
|
||||
"Rows per page": "Строк на страницу",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "Chuỗi nhóm tự động",
|
||||
"Auto refresh": "Tự động làm mới",
|
||||
"Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn",
|
||||
"Auto-disable rules": "Quy tắc tự động tắt",
|
||||
"Auto-disable status codes": "Mã trạng thái tự tắt",
|
||||
"Auto-discover": "Tự động khám phá",
|
||||
"Auto-discovers endpoints from the provider": "Tự động khám phá các điểm cuối từ nhà cung cấp",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "Ưu tiên kênh",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "Ưu tiên kênh sẽ sử dụng lại kênh thành công gần nhất dựa trên các khóa được trích xuất từ ngữ cảnh yêu cầu hoặc JSON body.",
|
||||
"Channel Affinity: Upstream Cache Hit": "Ưu tiên kênh: Cache hit từ upstream",
|
||||
"Channel health checks": "Kiểm tra tình trạng kênh",
|
||||
"Channel copied successfully": "Sao chép kênh thành công",
|
||||
"Channel created successfully": "Tạo kênh thành công",
|
||||
"Channel deleted successfully": "Xóa kênh thành công",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "Dung lượng tối đa bộ nhớ đệm đĩa (MB)",
|
||||
"Max Entries": "Số mục tối đa",
|
||||
"Max output": "Đầu ra tối đa",
|
||||
"Max Retries": "Số lần thử lại tối đa",
|
||||
"Max Requests (incl. failures)": "Maximum number of requests (including errors)",
|
||||
"Max Requests (including failures)": "Số yêu cầu tối đa (bao gồm cả các lỗi)",
|
||||
"Max requests per period": "Yêu cầu tối đa mỗi khoảng thời gian",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "Khôi phục mặc định",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "Hạn chế tần suất yêu cầu mô hình người dùng (có thể ảnh hưởng đến hiệu suất khi có độ đồng thời cao)",
|
||||
"Result": "Kết quả",
|
||||
"Request retry": "Thử lại yêu cầu",
|
||||
"Retain last N days": "Giữ lại N ngày gần nhất",
|
||||
"Retain last N files": "Giữ lại N tệp gần nhất",
|
||||
"Retention days": "Số ngày lưu giữ",
|
||||
"Retry": "Thử lại",
|
||||
"Retry Chain": "Chuỗi thử lại",
|
||||
"Retry Settings": "Cài đặt thử lại",
|
||||
"Retry Suggestion": "Gợi ý thử lại",
|
||||
"Retry Times": "Số lần thử lại",
|
||||
"Return a custom error immediately": "Trả về lỗi tùy chỉnh ngay lập tức",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "Kiểm tra tuyến, xác thực và số dư ở cùng một nơi",
|
||||
"Routes": "Route",
|
||||
"Routing & Overrides": "Định tuyến & ghi đè",
|
||||
"Routing Reliability": "Độ tin cậy định tuyến",
|
||||
"Routing Strategy": "Chiến lược định tuyến",
|
||||
"Rows per page": "Số hàng trên trang",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+6
@@ -473,6 +473,7 @@
|
||||
"Auto Group Chain": "自动分组链",
|
||||
"Auto refresh": "自动刷新",
|
||||
"Auto Sync Upstream Models": "自动同步上游模型",
|
||||
"Auto-disable rules": "自动禁用规则",
|
||||
"Auto-disable status codes": "自动禁用状态码",
|
||||
"Auto-discover": "自动发现",
|
||||
"Auto-discovers endpoints from the provider": "自动从提供商发现端点",
|
||||
@@ -683,6 +684,7 @@
|
||||
"Channel Affinity": "渠道亲和性",
|
||||
"Channel affinity reuses the last successful channel based on keys extracted from the request context or JSON body.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。",
|
||||
"Channel Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
|
||||
"Channel health checks": "渠道健康检查",
|
||||
"Channel copied successfully": "渠道复制成功",
|
||||
"Channel created successfully": "渠道创建成功",
|
||||
"Channel deleted successfully": "渠道删除成功",
|
||||
@@ -2391,6 +2393,7 @@
|
||||
"Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)",
|
||||
"Max Entries": "最大条目数",
|
||||
"Max output": "最大输出",
|
||||
"Max Retries": "最大重试",
|
||||
"Max Requests (incl. failures)": "最大请求数(包括失败)",
|
||||
"Max Requests (including failures)": "最大请求数(包括失败)",
|
||||
"Max requests per period": "每周期最大请求数",
|
||||
@@ -3541,11 +3544,13 @@
|
||||
"Restore defaults": "恢复默认",
|
||||
"Restrict user model request frequency (may impact high concurrency performance)": "限制用户模型请求频率(可能会影响高并发性能)",
|
||||
"Result": "结果",
|
||||
"Request retry": "请求重试",
|
||||
"Retain last N days": "保留最近N天",
|
||||
"Retain last N files": "保留最近 N 个文件",
|
||||
"Retention days": "保留天数",
|
||||
"Retry": "重试",
|
||||
"Retry Chain": "重试链路",
|
||||
"Retry Settings": "重试设置",
|
||||
"Retry Suggestion": "重试建议",
|
||||
"Retry Times": "重试次数",
|
||||
"Return a custom error immediately": "立即返回自定义错误",
|
||||
@@ -3576,6 +3581,7 @@
|
||||
"Route, auth, and balance check in one place": "路由、认证和余额检查集中展示",
|
||||
"Routes": "路由",
|
||||
"Routing & Overrides": "路由与覆盖",
|
||||
"Routing Reliability": "路由可靠性",
|
||||
"Routing Strategy": "路由策略",
|
||||
"Rows per page": "每页行数",
|
||||
"RPM": "RPM",
|
||||
|
||||
Vendored
+4
@@ -30,6 +30,9 @@ export const STATIC_I18N_KEYS = [
|
||||
// Sidebar views (drill-in workspaces)
|
||||
'System Settings',
|
||||
'Back to Dashboard',
|
||||
'Auto-disable rules',
|
||||
'Channel health checks',
|
||||
'Request retry',
|
||||
|
||||
// System settings sidebar
|
||||
'System Administration',
|
||||
@@ -39,6 +42,7 @@ export const STATIC_I18N_KEYS = [
|
||||
'Content',
|
||||
'Integrations',
|
||||
'Models',
|
||||
'Routing Reliability',
|
||||
'Maintenance',
|
||||
|
||||
// Pricing constants
|
||||
|
||||
@@ -27,6 +27,13 @@ export const Route = createFileRoute(
|
||||
'/_authenticated/system-settings/operations/$section'
|
||||
)({
|
||||
beforeLoad: ({ params }) => {
|
||||
if (params.section === 'monitoring') {
|
||||
throw redirect({
|
||||
to: '/system-settings/models/$section',
|
||||
params: { section: 'routing-reliability' },
|
||||
})
|
||||
}
|
||||
|
||||
const validSections = OPERATIONS_SECTION_IDS as unknown as string[]
|
||||
if (!validSections.includes(params.section)) {
|
||||
throw redirect({
|
||||
|
||||
Reference in New Issue
Block a user