Commit 91ab664c by CaIon

feat: add routing reliability management

parent dfcb74b5
...@@ -85,6 +85,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag ...@@ -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. - 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. - 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). - 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:** **Relay and provider behavior:**
......
...@@ -85,6 +85,7 @@ Do NOT directly import or call `encoding/json` in business code. `json.RawMessag ...@@ -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. - 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. - 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). - 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:** **Relay and provider behavior:**
......
...@@ -89,6 +89,12 @@ func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm ...@@ -89,6 +89,12 @@ func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm
return query return query
} }
func GetChannelOps(c *gin.Context) {
common.ApiSuccess(c, gin.H{
"retry_times": common.RetryTimes,
})
}
func GetAllChannels(c *gin.Context) { func GetAllChannels(c *gin.Context) {
pageInfo := common.GetPageQuery(c) pageInfo := common.GetPageQuery(c)
channelData := make([]*model.Channel, 0) channelData := make([]*model.Channel, 0)
......
...@@ -232,6 +232,7 @@ func SetApiRouter(router *gin.Engine) { ...@@ -232,6 +232,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.GET("/search", controller.SearchChannels) channelRoute.GET("/search", controller.SearchChannels)
channelRoute.GET("/models", controller.ChannelListModels) channelRoute.GET("/models", controller.ChannelListModels)
channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels)
channelRoute.GET("/ops", controller.GetChannelOps)
channelRoute.GET("/:id", controller.GetChannel) channelRoute.GET("/:id", controller.GetChannel)
channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey)
channelRoute.GET("/test", controller.TestAllChannels) channelRoute.GET("/test", controller.TestAllChannels)
......
...@@ -24,6 +24,7 @@ import type { ...@@ -24,6 +24,7 @@ import type {
BatchSetTagParams, BatchSetTagParams,
Channel, Channel,
ChannelBalanceResponse, ChannelBalanceResponse,
ChannelOpsResponse,
ChannelTestResponse, ChannelTestResponse,
CopyChannelParams, CopyChannelParams,
CopyChannelResponse, CopyChannelResponse,
...@@ -104,6 +105,14 @@ export async function getChannel(id: number): Promise<GetChannelResponse> { ...@@ -104,6 +105,14 @@ export async function getChannel(id: number): Promise<GetChannelResponse> {
} }
/** /**
* 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) * Create new channel(s)
* Supports single, batch, and multi-key modes * Supports single, batch, and multi-key modes
*/ */
......
...@@ -17,7 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -17,7 +17,19 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { useTranslation } from 'react-i18next' 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 { 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 { ChannelsDialogs } from './components/channels-dialogs'
import { ChannelsPrimaryButtons } from './components/channels-primary-buttons' import { ChannelsPrimaryButtons } from './components/channels-primary-buttons'
import { ChannelsProvider } from './components/channels-provider' import { ChannelsProvider } from './components/channels-provider'
...@@ -25,10 +37,62 @@ import { ChannelsTable } from './components/channels-table' ...@@ -25,10 +37,62 @@ import { ChannelsTable } from './components/channels-table'
export function Channels() { export function Channels() {
const { t } = useTranslation() 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 ( return (
<ChannelsProvider> <ChannelsProvider>
<SectionPageLayout fixedContent> <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> <SectionPageLayout.Actions>
<ChannelsPrimaryButtons /> <ChannelsPrimaryButtons />
</SectionPageLayout.Actions> </SectionPageLayout.Actions>
......
...@@ -167,6 +167,14 @@ export interface GetChannelResponse { ...@@ -167,6 +167,14 @@ export interface GetChannelResponse {
data?: Channel data?: Channel
} }
export interface ChannelOpsResponse {
success: boolean
message?: string
data?: {
retry_times: number
}
}
export interface ChannelTestResponse { export interface ChannelTestResponse {
success: boolean success: boolean
message?: string message?: string
......
...@@ -200,6 +200,16 @@ export function ModelMutateDrawer({ ...@@ -200,6 +200,16 @@ export function ModelMutateDrawer({
'group_ratio_setting.group_special_usable_group': '{}', 'group_ratio_setting.group_special_usable_group': '{}',
'grok.violation_deduction_enabled': false, 'grok.violation_deduction_enabled': false,
'grok.violation_deduction_amount': 0, '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.enabled': false,
'channel_affinity_setting.switch_on_success': true, 'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false, 'channel_affinity_setting.keep_on_channel_disabled': false,
......
...@@ -44,7 +44,7 @@ type SettingsSwitchFieldProps = SettingsSwitchRowProps & { ...@@ -44,7 +44,7 @@ type SettingsSwitchFieldProps = SettingsSwitchRowProps & {
} }
const settingsSwitchRowClassName = 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) { export function SettingsFormGrid(props: SettingsFormGridProps) {
return ( return (
......
...@@ -339,7 +339,7 @@ export function AnnouncementsSection({ ...@@ -339,7 +339,7 @@ export function AnnouncementsSection({
checked={isEnabled} checked={isEnabled}
onCheckedChange={handleToggleEnabled} onCheckedChange={handleToggleEnabled}
label={t('Enabled')} label={t('Enabled')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
...@@ -529,8 +529,7 @@ export function AnnouncementsSection({ ...@@ -529,8 +529,7 @@ export function AnnouncementsSection({
<FormItem> <FormItem>
<FormLabel>{t('Type')}</FormLabel> <FormLabel>{t('Type')}</FormLabel>
<Select <Select
items={[ items={typeOptions.map((option) => ({
...typeOptions.map((option) => ({
value: option.value, value: option.value,
label: ( label: (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
...@@ -540,8 +539,7 @@ export function AnnouncementsSection({ ...@@ -540,8 +539,7 @@ export function AnnouncementsSection({
{option.label} {option.label}
</div> </div>
), ),
})), }))}
]}
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
> >
......
...@@ -291,7 +291,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { ...@@ -291,7 +291,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
checked={isEnabled} checked={isEnabled}
onCheckedChange={handleToggleEnabled} onCheckedChange={handleToggleEnabled}
label={t('Enabled')} label={t('Enabled')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
...@@ -475,8 +475,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { ...@@ -475,8 +475,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
<FormItem> <FormItem>
<FormLabel>{t('Badge Color')}</FormLabel> <FormLabel>{t('Badge Color')}</FormLabel>
<Select <Select
items={[ items={colorOptions.map((option) => ({
...colorOptions.map((option) => ({
value: option.value, value: option.value,
label: ( label: (
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
...@@ -486,8 +485,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) { ...@@ -486,8 +485,7 @@ export function ApiInfoSection({ enabled, data }: ApiInfoSectionProps) {
{option.label} {option.label}
</div> </div>
), ),
})), }))}
]}
onValueChange={field.onChange} onValueChange={field.onChange}
value={field.value} value={field.value}
> >
......
...@@ -258,7 +258,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) { ...@@ -258,7 +258,7 @@ export function FAQSection({ enabled, data }: FAQSectionProps) {
checked={isEnabled} checked={isEnabled}
onCheckedChange={handleToggleEnabled} onCheckedChange={handleToggleEnabled}
label={t('Enabled')} label={t('Enabled')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
......
...@@ -267,7 +267,7 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) { ...@@ -267,7 +267,7 @@ export function UptimeKumaSection({ enabled, data }: UptimeKumaSectionProps) {
checked={isEnabled} checked={isEnabled}
onCheckedChange={handleToggleEnabled} onCheckedChange={handleToggleEnabled}
label={t('Enabled')} label={t('Enabled')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
......
...@@ -255,42 +255,42 @@ export function ChannelAffinitySection(props: Props) { ...@@ -255,42 +255,42 @@ export function ChannelAffinitySection(props: Props) {
const updates: { key: string; value: string }[] = [] const updates: { key: string; value: string }[] = []
if (enabled !== props.defaultValues['channel_affinity_setting.enabled']) if (enabled !== props.defaultValues['channel_affinity_setting.enabled'])
updates.push({ {updates.push({
key: 'channel_affinity_setting.enabled', key: 'channel_affinity_setting.enabled',
value: String(enabled), value: String(enabled),
}) })}
if ( if (
switchOnSuccess !== switchOnSuccess !==
props.defaultValues['channel_affinity_setting.switch_on_success'] props.defaultValues['channel_affinity_setting.switch_on_success']
) )
updates.push({ {updates.push({
key: 'channel_affinity_setting.switch_on_success', key: 'channel_affinity_setting.switch_on_success',
value: String(switchOnSuccess), value: String(switchOnSuccess),
}) })}
if ( if (
keepOnChannelDisabled !== keepOnChannelDisabled !==
props.defaultValues['channel_affinity_setting.keep_on_channel_disabled'] props.defaultValues['channel_affinity_setting.keep_on_channel_disabled']
) )
updates.push({ {updates.push({
key: 'channel_affinity_setting.keep_on_channel_disabled', key: 'channel_affinity_setting.keep_on_channel_disabled',
value: String(keepOnChannelDisabled), value: String(keepOnChannelDisabled),
}) })}
if ( if (
maxEntries !== maxEntries !==
props.defaultValues['channel_affinity_setting.max_entries'] props.defaultValues['channel_affinity_setting.max_entries']
) )
updates.push({ {updates.push({
key: 'channel_affinity_setting.max_entries', key: 'channel_affinity_setting.max_entries',
value: String(maxEntries), value: String(maxEntries),
}) })}
if ( if (
defaultTtl !== defaultTtl !==
props.defaultValues['channel_affinity_setting.default_ttl_seconds'] props.defaultValues['channel_affinity_setting.default_ttl_seconds']
) )
updates.push({ {updates.push({
key: 'channel_affinity_setting.default_ttl_seconds', key: 'channel_affinity_setting.default_ttl_seconds',
value: String(defaultTtl), value: String(defaultTtl),
}) })}
const origRules = props.defaultValues['channel_affinity_setting.rules'] const origRules = props.defaultValues['channel_affinity_setting.rules']
const origSerialized = (() => { const origSerialized = (() => {
...@@ -411,7 +411,7 @@ export function ChannelAffinitySection(props: Props) { ...@@ -411,7 +411,7 @@ export function ChannelAffinitySection(props: Props) {
checked={enabled} checked={enabled}
onCheckedChange={setEnabled} onCheckedChange={setEnabled}
label={t('Enable')} label={t('Enable')}
className='border-b-0 py-0' className='py-0'
/> />
<div className='grid gap-1.5'> <div className='grid gap-1.5'>
<Label>{t('Max Entries')}</Label> <Label>{t('Max Entries')}</Label>
......
...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { useForm } from 'react-hook-form'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
...@@ -44,6 +44,10 @@ import { SettingsSwitchField } from '../../components/settings-form-layout' ...@@ -44,6 +44,10 @@ import { SettingsSwitchField } from '../../components/settings-form-layout'
import { RULE_TEMPLATES } from './constants' import { RULE_TEMPLATES } from './constants'
import type { AffinityRule, KeySource } from './types' import type { AffinityRule, KeySource } from './types'
type KeySourceRow = KeySource & {
rowId: string
}
const KEY_SOURCE_TYPES = [ const KEY_SOURCE_TYPES = [
'context_int', 'context_int',
'context_string', 'context_string',
...@@ -103,8 +107,13 @@ interface Props { ...@@ -103,8 +107,13 @@ interface Props {
export function RuleEditorDialog(props: Props) { export function RuleEditorDialog(props: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const isEdit = !!props.rule?.name const isEdit = !!props.rule?.name
const [keySources, setKeySources] = useState<KeySource[]>([ const nextKeySourceRowId = useRef(0)
{ type: 'gjson', path: '' }, 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) const [advancedOpen, setAdvancedOpen] = useState(false)
...@@ -141,7 +150,11 @@ export function RuleEditorDialog(props: Props) { ...@@ -141,7 +150,11 @@ export function RuleEditorDialog(props: Props) {
: '', : '',
}) })
const sources = (r.key_sources || []).map(normalizeKeySource) 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) if (r.param_override_template) setAdvancedOpen(true)
} }
...@@ -166,7 +179,7 @@ export function RuleEditorDialog(props: Props) { ...@@ -166,7 +179,7 @@ export function RuleEditorDialog(props: Props) {
include_rule_name: true, include_rule_name: true,
param_override_template_json: '', param_override_template_json: '',
}) })
setKeySources([{ type: 'gjson', path: '' }]) setKeySources([createKeySourceRow()])
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open, props.rule, props.templateKey]) }, [props.open, props.rule, props.templateKey])
...@@ -297,7 +310,7 @@ export function RuleEditorDialog(props: Props) { ...@@ -297,7 +310,7 @@ export function RuleEditorDialog(props: Props) {
variant='outline' variant='outline'
size='sm' size='sm'
onClick={() => onClick={() =>
setKeySources((prev) => [...prev, { type: 'gjson', path: '' }]) setKeySources((prev) => [...prev, createKeySourceRow()])
} }
> >
<Plus className='mr-1 h-3 w-3' /> <Plus className='mr-1 h-3 w-3' />
...@@ -310,21 +323,22 @@ export function RuleEditorDialog(props: Props) { ...@@ -310,21 +323,22 @@ export function RuleEditorDialog(props: Props) {
<div className='space-y-2'> <div className='space-y-2'>
{keySources.map((src, idx) => ( {keySources.map((src, idx) => (
<div <div
key={idx} key={src.rowId}
className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center' className='flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center'
> >
<Select <Select
items={[ items={KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t }))}
...KEY_SOURCE_TYPES.map((t) => ({ value: t, label: t })),
]}
value={src.type} value={src.type}
onValueChange={(v) => { onValueChange={(v) => {
if (v === null) return if (v === null) return
const next = [...keySources] const next = [...keySources]
next[idx] = normalizeKeySource({ next[idx] = {
...src, ...normalizeKeySource({
type: v as KeySource['type'], ...src,
}) type: v as KeySource['type'],
}),
rowId: src.rowId,
}
setKeySources(next) setKeySources(next)
}} }}
> >
...@@ -432,19 +446,19 @@ export function RuleEditorDialog(props: Props) { ...@@ -432,19 +446,19 @@ export function RuleEditorDialog(props: Props) {
checked={form.watch('include_using_group')} checked={form.watch('include_using_group')}
onCheckedChange={(v) => form.setValue('include_using_group', v)} onCheckedChange={(v) => form.setValue('include_using_group', v)}
label={t('Include Group')} label={t('Include Group')}
className='border-b-0 py-0' className='py-0'
/> />
<SettingsSwitchField <SettingsSwitchField
checked={form.watch('include_model_name')} checked={form.watch('include_model_name')}
onCheckedChange={(v) => form.setValue('include_model_name', v)} onCheckedChange={(v) => form.setValue('include_model_name', v)}
label={t('Include Model')} label={t('Include Model')}
className='border-b-0 py-0' className='py-0'
/> />
<SettingsSwitchField <SettingsSwitchField
checked={form.watch('include_rule_name')} checked={form.watch('include_rule_name')}
onCheckedChange={(v) => form.setValue('include_rule_name', v)} onCheckedChange={(v) => form.setValue('include_rule_name', v)}
label={t('Include Rule Name')} label={t('Include Rule Name')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
</CollapsibleContent> </CollapsibleContent>
......
...@@ -25,11 +25,8 @@ import { ...@@ -25,11 +25,8 @@ import {
FormControl, FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem,
FormLabel, FormLabel,
FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { import {
SettingsForm, SettingsForm,
...@@ -40,10 +37,8 @@ import { SettingsPageFormActions } from '../components/settings-page-context' ...@@ -40,10 +37,8 @@ import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section' import { SettingsSection } from '../components/settings-section'
import { useResetForm } from '../hooks/use-reset-form' import { useResetForm } from '../hooks/use-reset-form'
import { useUpdateOption } from '../hooks/use-update-option' import { useUpdateOption } from '../hooks/use-update-option'
import { safeNumberFieldProps } from '../utils/numeric-field'
const behaviorSchema = z.object({ const behaviorSchema = z.object({
RetryTimes: z.coerce.number().min(0).max(10),
DefaultCollapseSidebar: z.boolean(), DefaultCollapseSidebar: z.boolean(),
DemoSiteEnabled: z.boolean(), DemoSiteEnabled: z.boolean(),
SelfUseModeEnabled: z.boolean(), SelfUseModeEnabled: z.boolean(),
...@@ -88,28 +83,6 @@ export function SystemBehaviorSection({ ...@@ -88,28 +83,6 @@ export function SystemBehaviorSection({
/> />
<FormField <FormField
control={form.control} 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' name='DefaultCollapseSidebar'
render={({ field }) => ( render={({ field }) => (
<SettingsSwitchItem> <SettingsSwitchItem>
......
...@@ -16,13 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,13 +16,12 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { parseHttpStatusCodeRules } from '@/lib/http-status-code-rules'
import { import {
Form, Form,
FormControl, FormControl,
...@@ -33,8 +32,15 @@ import { ...@@ -33,8 +32,15 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { import {
SettingsForm, SettingsForm,
SettingsSwitchContent, SettingsSwitchContent,
...@@ -52,146 +58,65 @@ const numericString = z.string().refine((value) => { ...@@ -52,146 +58,65 @@ const numericString = z.string().refine((value) => {
return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0 return !Number.isNaN(Number(trimmed)) && Number(trimmed) >= 0
}, 'Enter a non-negative number or leave empty') }, 'Enter a non-negative number or leave empty')
const monitoringSchema = z const monitoringSchema = z.object({
.object({ QuotaRemindThreshold: numericString,
ChannelDisableThreshold: numericString, perf_metrics_setting: z.object({
QuotaRemindThreshold: numericString, enabled: z.boolean(),
AutomaticDisableChannelEnabled: z.boolean(), flush_interval: z.coerce.number().min(1),
AutomaticEnableChannelEnabled: z.boolean(), bucket_time: z.enum(['minute', '5min', 'hour']),
AutomaticDisableKeywords: z.string(), retention_days: z.coerce.number().min(0),
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 MonitoringFormValues = z.output<typeof monitoringSchema>
type MonitoringFormInput = z.input<typeof monitoringSchema> type MonitoringFormInput = z.input<typeof monitoringSchema>
type MonitoringFormValues = z.output<typeof monitoringSchema>
type MonitoringSettingsSectionProps = { type FlatMonitoringDefaults = {
defaultValues: { QuotaRemindThreshold: string
ChannelDisableThreshold: string 'perf_metrics_setting.enabled': boolean
QuotaRemindThreshold: string 'perf_metrics_setting.flush_interval': number
AutomaticDisableChannelEnabled: boolean 'perf_metrics_setting.bucket_time': 'minute' | '5min' | 'hour'
AutomaticEnableChannelEnabled: boolean 'perf_metrics_setting.retention_days': number
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 = { type MonitoringSettingsSectionProps = {
ChannelDisableThreshold: string defaultValues: FlatMonitoringDefaults
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
} }
const buildFormDefaults = ( const buildFormDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues'] defaults: MonitoringSettingsSectionProps['defaultValues']
): MonitoringFormInput => ({ ): MonitoringFormInput => ({
ChannelDisableThreshold: defaults.ChannelDisableThreshold ?? '',
QuotaRemindThreshold: defaults.QuotaRemindThreshold ?? '', QuotaRemindThreshold: defaults.QuotaRemindThreshold ?? '',
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled, perf_metrics_setting: {
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled, enabled: defaults['perf_metrics_setting.enabled'],
AutomaticDisableKeywords: normalizeLineEndings( flush_interval: defaults['perf_metrics_setting.flush_interval'],
defaults.AutomaticDisableKeywords ?? '' bucket_time: defaults['perf_metrics_setting.bucket_time'],
), retention_days: defaults['perf_metrics_setting.retention_days'],
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 = ( const normalizeDefaults = (
defaults: MonitoringSettingsSectionProps['defaultValues'] defaults: MonitoringSettingsSectionProps['defaultValues']
): NormalizedMonitoringValues => ({ ): FlatMonitoringDefaults => ({
ChannelDisableThreshold: (defaults.ChannelDisableThreshold ?? '').trim(),
QuotaRemindThreshold: (defaults.QuotaRemindThreshold ?? '').trim(), QuotaRemindThreshold: (defaults.QuotaRemindThreshold ?? '').trim(),
AutomaticDisableChannelEnabled: defaults.AutomaticDisableChannelEnabled, 'perf_metrics_setting.enabled': defaults['perf_metrics_setting.enabled'],
AutomaticEnableChannelEnabled: defaults.AutomaticEnableChannelEnabled, 'perf_metrics_setting.flush_interval':
AutomaticDisableKeywords: normalizeLineEndings( defaults['perf_metrics_setting.flush_interval'],
defaults.AutomaticDisableKeywords ?? '' 'perf_metrics_setting.bucket_time': defaults['perf_metrics_setting.bucket_time'],
), 'perf_metrics_setting.retention_days':
AutomaticDisableStatusCodes: parseHttpStatusCodeRules( defaults['perf_metrics_setting.retention_days'],
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 = ( const normalizeFormValues = (
values: MonitoringFormValues values: MonitoringFormValues
): NormalizedMonitoringValues => ({ ): FlatMonitoringDefaults => ({
ChannelDisableThreshold: values.ChannelDisableThreshold.trim(),
QuotaRemindThreshold: values.QuotaRemindThreshold.trim(), QuotaRemindThreshold: values.QuotaRemindThreshold.trim(),
AutomaticDisableChannelEnabled: values.AutomaticDisableChannelEnabled, 'perf_metrics_setting.enabled': values.perf_metrics_setting.enabled,
AutomaticEnableChannelEnabled: values.AutomaticEnableChannelEnabled, 'perf_metrics_setting.flush_interval':
AutomaticDisableKeywords: normalizeLineEndings( values.perf_metrics_setting.flush_interval,
values.AutomaticDisableKeywords 'perf_metrics_setting.bucket_time': values.perf_metrics_setting.bucket_time,
), 'perf_metrics_setting.retention_days':
AutomaticDisableStatusCodes: parseHttpStatusCodeRules( values.perf_metrics_setting.retention_days,
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 MonitoringSettingsSection({ export function MonitoringSettingsSection({
...@@ -199,9 +124,12 @@ export function MonitoringSettingsSection({ ...@@ -199,9 +124,12 @@ export function MonitoringSettingsSection({
}: MonitoringSettingsSectionProps) { }: MonitoringSettingsSectionProps) {
const { t } = useTranslation() const { t } = useTranslation()
const updateOption = useUpdateOption() const updateOption = useUpdateOption()
const baselineRef = useRef<NormalizedMonitoringValues>( const baselineRef = useRef<FlatMonitoringDefaults>(
normalizeDefaults(defaultValues) normalizeDefaults(defaultValues)
) )
const baselineSerializedRef = useRef<string>(
JSON.stringify(normalizeDefaults(defaultValues))
)
const formDefaults = useMemo( const formDefaults = useMemo(
() => buildFormDefaults(defaultValues), () => buildFormDefaults(defaultValues),
...@@ -215,21 +143,20 @@ export function MonitoringSettingsSection({ ...@@ -215,21 +143,20 @@ export function MonitoringSettingsSection({
useResetForm(form, formDefaults) useResetForm(form, formDefaults)
const autoDisableStatusCodes = form.watch('AutomaticDisableStatusCodes') useEffect(() => {
const autoRetryStatusCodes = form.watch('AutomaticRetryStatusCodes') const normalized = normalizeDefaults(defaultValues)
const autoDisableParsed = useMemo( const serialized = JSON.stringify(normalized)
() => parseHttpStatusCodeRules(autoDisableStatusCodes), if (serialized === baselineSerializedRef.current) return
[autoDisableStatusCodes] baselineRef.current = normalized
) baselineSerializedRef.current = serialized
const autoRetryParsed = useMemo( }, [defaultValues])
() => parseHttpStatusCodeRules(autoRetryStatusCodes),
[autoRetryStatusCodes] const perfMetricsEnabled = form.watch('perf_metrics_setting.enabled')
)
const onSubmit = async (values: MonitoringFormValues) => { const onSubmit = async (values: MonitoringFormValues) => {
const normalized = normalizeFormValues(values) const normalized = normalizeFormValues(values)
const updates = ( const updates = (
Object.keys(normalized) as Array<keyof NormalizedMonitoringValues> Object.keys(normalized) as Array<keyof FlatMonitoringDefaults>
).filter((key) => normalized[key] !== baselineRef.current[key]) ).filter((key) => normalized[key] !== baselineRef.current[key])
if (updates.length === 0) { if (updates.length === 0) {
...@@ -238,14 +165,14 @@ export function MonitoringSettingsSection({ ...@@ -238,14 +165,14 @@ export function MonitoringSettingsSection({
} }
for (const key of updates) { for (const key of updates) {
const value = normalized[key]
await updateOption.mutateAsync({ await updateOption.mutateAsync({
key, key,
value, value: normalized[key],
}) })
} }
baselineRef.current = normalized baselineRef.current = normalized
baselineSerializedRef.current = JSON.stringify(normalized)
} }
return ( return (
...@@ -255,19 +182,49 @@ export function MonitoringSettingsSection({ ...@@ -255,19 +182,49 @@ export function MonitoringSettingsSection({
<SettingsPageFormActions <SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)} onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending} isSaving={updateOption.isPending}
saveLabel='Save monitoring rules'
/> />
<div className='grid gap-6 md:grid-cols-2'> <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>
<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 <FormField
control={form.control} control={form.control}
name='monitor_setting.auto_test_channel_enabled' name='perf_metrics_setting.enabled'
render={({ field }) => ( render={({ field }) => (
<SettingsSwitchItem> <SettingsSwitchItem>
<SettingsSwitchContent> <SettingsSwitchContent>
<FormLabel>{t('Scheduled channel tests')}</FormLabel> <FormLabel>
<FormDescription> {t('Enable model performance metrics')}
{t('Automatically probe all channels in the background')} </FormLabel>
</FormDescription>
</SettingsSwitchContent> </SettingsSwitchContent>
<FormControl> <FormControl>
<Switch <Switch
...@@ -278,203 +235,75 @@ export function MonitoringSettingsSection({ ...@@ -278,203 +235,75 @@ export function MonitoringSettingsSection({
</SettingsSwitchItem> </SettingsSwitchItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='monitor_setting.auto_test_channel_minutes' name='perf_metrics_setting.flush_interval'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Test interval (minutes)')}</FormLabel> <FormLabel>{t('Flush interval (minutes)')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='number' type='number'
min={1} min={1}
step={1} step={1}
{...safeNumberFieldProps(field)} {...safeNumberFieldProps(field)}
disabled={!perfMetricsEnabled}
/> />
</FormControl> </FormControl>
<FormDescription>
{t('How frequently the system tests all channels')}
</FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
<div className='grid gap-6 md:grid-cols-2'>
<FormField <FormField
control={form.control} control={form.control}
name='ChannelDisableThreshold' name='perf_metrics_setting.bucket_time'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Disable threshold (seconds)')}</FormLabel> <FormLabel>{t('Aggregation bucket')}</FormLabel>
<FormControl> <Select
<Input items={[
type='number' { value: 'minute', label: t('1 minute') },
min={0} { value: '5min', label: t('5 minutes') },
step={1} { value: 'hour', label: t('1 hour') },
value={field.value} ]}
onChange={(event) => field.onChange(event.target.value)} value={field.value}
/> onValueChange={field.onChange}
</FormControl> disabled={!perfMetricsEnabled}
<FormDescription> >
{t( <FormControl>
'Automatically disable channels exceeding this response time' <SelectTrigger>
)} <SelectValue />
</FormDescription> </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 /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name='QuotaRemindThreshold' name='perf_metrics_setting.retention_days'
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t('Quota reminder (tokens)')}</FormLabel> <FormLabel>{t('Retention days')}</FormLabel>
<FormControl> <FormControl>
<Input <Input
type='number' type='number'
min={0} min={0}
step={1} step={1}
value={field.value} {...safeNumberFieldProps(field)}
onChange={(event) => field.onChange(event.target.value)} disabled={!perfMetricsEnabled}
/>
</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'
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 className='grid gap-6 md:grid-cols-2'>
<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='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> </FormControl>
<FormDescription> <FormDescription>
{t( {t('0 means data is kept permanently')}
'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> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
......
...@@ -98,7 +98,7 @@ export function WaffoSettingsSection({ ...@@ -98,7 +98,7 @@ export function WaffoSettingsSection({
const saveMethod = () => { const saveMethod = () => {
if (!methodForm.name.trim()) 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) { if (editingIdx === -1) {
onPayMethodsChange((prev) => [...prev, methodForm]) onPayMethodsChange((prev) => [...prev, methodForm])
} else { } else {
...@@ -125,15 +125,13 @@ export function WaffoSettingsSection({ ...@@ -125,15 +125,13 @@ export function WaffoSettingsSection({
} }
const reader = new FileReader() const reader = new FileReader()
reader.onload = (loadEvent) => { reader.addEventListener('load', () => {
setMethodForm((previous) => ({ setMethodForm((previous) => ({
...previous, ...previous,
icon: icon:
typeof loadEvent.target?.result === 'string' typeof reader.result === 'string' ? reader.result : '',
? loadEvent.target.result
: '',
})) }))
} })
reader.readAsDataURL(file) reader.readAsDataURL(file)
event.target.value = '' event.target.value = ''
} }
...@@ -164,13 +162,13 @@ export function WaffoSettingsSection({ ...@@ -164,13 +162,13 @@ export function WaffoSettingsSection({
checked={values.WaffoEnabled} checked={values.WaffoEnabled}
onCheckedChange={(v) => onValueChange('WaffoEnabled', v)} onCheckedChange={(v) => onValueChange('WaffoEnabled', v)}
label={t('Enable Waffo')} label={t('Enable Waffo')}
className='border-b-0 py-0' className='py-0'
/> />
<SettingsSwitchField <SettingsSwitchField
checked={values.WaffoSandbox} checked={values.WaffoSandbox}
onCheckedChange={(v) => onValueChange('WaffoSandbox', v)} onCheckedChange={(v) => onValueChange('WaffoSandbox', v)}
label={t('Sandbox mode')} label={t('Sandbox mode')}
className='border-b-0 py-0' className='py-0'
/> />
</div> </div>
......
...@@ -270,7 +270,7 @@ export function HeaderNavigationSection({ ...@@ -270,7 +270,7 @@ export function HeaderNavigationSection({
name={module.requireAuthKey} name={module.requireAuthKey}
render={({ field }) => ( render={({ field }) => (
<SettingsControlChildren> <SettingsControlChildren>
<SettingsSwitchItem className='border-b-0 py-2'> <SettingsSwitchItem className='py-2'>
<SettingsSwitchContent> <SettingsSwitchContent>
<FormLabel>{module.requireAuthTitle}</FormLabel> <FormLabel>{module.requireAuthTitle}</FormLabel>
<FormDescription> <FormDescription>
......
...@@ -16,13 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>. ...@@ -16,13 +16,16 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 * as z from 'zod'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format' import { formatTimestampToDate } from '@/lib/format'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
...@@ -32,6 +35,7 @@ import { ...@@ -32,6 +35,7 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
...@@ -42,6 +46,17 @@ import { ...@@ -42,6 +46,17 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } 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 { Switch } from '@/components/ui/switch'
import { DateTimePicker } from '@/components/datetime-picker' import { DateTimePicker } from '@/components/datetime-picker'
import { deleteLogsBefore } from '../api' import { deleteLogsBefore } from '../api'
...@@ -65,8 +80,30 @@ type LogSettingsSectionProps = { ...@@ -65,8 +80,30 @@ type LogSettingsSectionProps = {
defaultEnabled: boolean 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 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 getDateHoursAgo = (hours: number) => {
const date = new Date() const date = new Date()
date.setHours(date.getHours() - hours) date.setHours(date.getHours() - hours)
...@@ -107,11 +144,30 @@ export function LogSettingsSection({ ...@@ -107,11 +144,30 @@ export function LogSettingsSection({
) )
const [isCleaning, setIsCleaning] = useState(false) const [isCleaning, setIsCleaning] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = 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(() => { useEffect(() => {
form.reset({ LogConsumeEnabled: defaultEnabled }) form.reset({ LogConsumeEnabled: defaultEnabled })
}, [defaultEnabled, form]) }, [defaultEnabled, form])
useEffect(() => {
fetchServerLogInfo()
}, [fetchServerLogInfo])
const purgeTimestamp = useMemo(() => { const purgeTimestamp = useMemo(() => {
if (!purgeDate) return null if (!purgeDate) return null
return Math.floor(purgeDate.getTime() / 1000) return Math.floor(purgeDate.getTime() / 1000)
...@@ -166,6 +222,40 @@ export function LogSettingsSection({ ...@@ -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 ( return (
<SettingsSection title={t('Log Maintenance')}> <SettingsSection title={t('Log Maintenance')}>
<Form {...form}> <Form {...form}>
...@@ -232,6 +322,158 @@ export function LogSettingsSection({ ...@@ -232,6 +322,158 @@ export function LogSettingsSection({
</SettingsControlGroup> </SettingsControlGroup>
</SettingsForm> </SettingsForm>
</Form> </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}> <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
......
...@@ -23,7 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod' ...@@ -23,7 +23,6 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { Alert, AlertDescription } from '@/components/ui/alert' import { Alert, AlertDescription } from '@/components/ui/alert'
import { import {
AlertDialog, AlertDialog,
...@@ -47,16 +46,7 @@ import { ...@@ -47,16 +46,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form' } from '@/components/ui/form'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
...@@ -89,12 +79,6 @@ const perfSchema = z.object({ ...@@ -89,12 +79,6 @@ const perfSchema = z.object({
monitor_memory_threshold: z.coerce.number().min(0).max(100), monitor_memory_threshold: z.coerce.number().min(0).max(100),
monitor_disk_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> type PerfFormInput = z.input<typeof perfSchema>
...@@ -109,10 +93,6 @@ type FlatPerfDefaults = { ...@@ -109,10 +93,6 @@ type FlatPerfDefaults = {
'performance_setting.monitor_cpu_threshold': number 'performance_setting.monitor_cpu_threshold': number
'performance_setting.monitor_memory_threshold': number 'performance_setting.monitor_memory_threshold': number
'performance_setting.monitor_disk_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 => ({ const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
...@@ -131,12 +111,6 @@ const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({ ...@@ -131,12 +111,6 @@ const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
monitor_disk_threshold: monitor_disk_threshold:
defaults['performance_setting.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 => ({ const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
...@@ -156,38 +130,25 @@ const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({ ...@@ -156,38 +130,25 @@ const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
values.performance_setting.monitor_memory_threshold, values.performance_setting.monitor_memory_threshold,
'performance_setting.monitor_disk_threshold': 'performance_setting.monitor_disk_threshold':
values.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 { 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 '0 Bytes'
if (bytes < 0) return '-' + formatBytes(-bytes, decimals) if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`
const k = 1024 const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k)) const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
if (i < 0 || i >= sizes.length) return bytes + ' Bytes' if (i < 0 || i >= sizes.length) return `${bytes} Bytes`
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i] return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${
sizes[i]
}`
} }
interface Props { interface Props {
defaultValues: FlatPerfDefaults defaultValues: FlatPerfDefaults
} }
type LogInfo = {
enabled: boolean
log_dir: string
file_count: number
total_size: number
oldest_time?: string
newest_time?: string
}
type PerformanceStats = { type PerformanceStats = {
cache_stats?: { cache_stats?: {
current_disk_usage_bytes: number current_disk_usage_bytes: number
...@@ -225,10 +186,6 @@ export function PerformanceSection(props: Props) { ...@@ -225,10 +186,6 @@ export function PerformanceSection(props: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const updateOption = useUpdateOption() const updateOption = useUpdateOption()
const [stats, setStats] = useState<PerformanceStats | null>(null) 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( const formDefaults = useMemo(
() => buildFormDefaults(props.defaultValues), () => buildFormDefaults(props.defaultValues),
...@@ -262,19 +219,9 @@ export function PerformanceSection(props: Props) { ...@@ -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(() => { useEffect(() => {
fetchStats() fetchStats()
fetchLogInfo() }, [fetchStats])
}, [fetchStats, fetchLogInfo])
const onSubmit = async (values: PerfFormValues) => { const onSubmit = async (values: PerfFormValues) => {
const normalized = normalizeFormValues(values) const normalized = normalizeFormValues(values)
...@@ -336,38 +283,8 @@ export function PerformanceSection(props: Props) { ...@@ -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 diskEnabled = form.watch('performance_setting.disk_cache_enabled')
const monitorEnabled = form.watch('performance_setting.monitor_enabled') const monitorEnabled = form.watch('performance_setting.monitor_enabled')
const perfMetricsEnabled = form.watch('perf_metrics_setting.enabled')
const maxCacheSizeRaw = form.watch( const maxCacheSizeRaw = form.watch(
'performance_setting.disk_cache_max_size_mb' 'performance_setting.disk_cache_max_size_mb'
) )
...@@ -607,262 +524,11 @@ export function PerformanceSection(props: Props) { ...@@ -607,262 +524,11 @@ export function PerformanceSection(props: Props) {
)} )}
/> />
</div> </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> </SettingsForm>
</Form> </Form>
<Separator /> <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 */} {/* Performance Stats Dashboard */}
<div className='space-y-4'> <div className='space-y-4'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
......
...@@ -51,7 +51,7 @@ type SidebarModulesSectionProps = { ...@@ -51,7 +51,7 @@ type SidebarModulesSectionProps = {
type SidebarFormValues = SidebarModulesAdminConfig type SidebarFormValues = SidebarModulesAdminConfig
const toTitleCase = (value: string) => 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({ export function SidebarModulesSection({
config, config,
...@@ -237,7 +237,7 @@ export function SidebarModulesSection({ ...@@ -237,7 +237,7 @@ export function SidebarModulesSection({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.${moduleKey}` as any} name={`${sectionKey}.${moduleKey}` as any}
render={({ field }) => ( render={({ field }) => (
<SettingsSwitchItem className='border-b-0 py-2'> <SettingsSwitchItem className='py-2'>
<SettingsSwitchContent> <SettingsSwitchContent>
<FormLabel>{moduleInfo.title}</FormLabel> <FormLabel>{moduleInfo.title}</FormLabel>
<FormDescription> <FormDescription>
......
...@@ -62,6 +62,16 @@ const defaultModelSettings: ModelSettings = { ...@@ -62,6 +62,16 @@ const defaultModelSettings: ModelSettings = {
AutoGroups: '', AutoGroups: '',
DefaultUseAutoGroup: false, DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}', '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.enabled': false,
'channel_affinity_setting.switch_on_success': true, 'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.keep_on_channel_disabled': false, 'channel_affinity_setting.keep_on_channel_disabled': false,
......
/*
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' ...@@ -24,6 +24,7 @@ import { ClaudeSettingsCard } from './claude-settings-card'
import { GeminiSettingsCard } from './gemini-settings-card' import { GeminiSettingsCard } from './gemini-settings-card'
import { GlobalSettingsCard } from './global-settings-card' import { GlobalSettingsCard } from './global-settings-card'
import { GrokSettingsCard } from './grok-settings-card' import { GrokSettingsCard } from './grok-settings-card'
import { RoutingReliabilitySection } from './routing-reliability-section'
function formatJsonForEditor(value: string, fallback: string) { function formatJsonForEditor(value: string, fallback: string) {
const raw = (value ?? '').toString().trim() const raw = (value ?? '').toString().trim()
...@@ -65,6 +66,28 @@ const MODELS_SECTIONS = [ ...@@ -65,6 +66,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', id: 'gemini',
titleKey: 'Gemini', titleKey: 'Gemini',
build: (settings: ModelSettings) => ( build: (settings: ModelSettings) => (
......
...@@ -26,20 +26,10 @@ import { ...@@ -26,20 +26,10 @@ import {
} from './section-registry.tsx' } from './section-registry.tsx'
const defaultOperationsSettings: OperationsSettings = { const defaultOperationsSettings: OperationsSettings = {
RetryTimes: 0,
DefaultCollapseSidebar: false, DefaultCollapseSidebar: false,
DemoSiteEnabled: false, DemoSiteEnabled: false,
SelfUseModeEnabled: false, SelfUseModeEnabled: false,
ChannelDisableThreshold: '',
QuotaRemindThreshold: '', 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: '', SMTPServer: '',
SMTPPort: '', SMTPPort: '',
SMTPAccount: '', SMTPAccount: '',
......
...@@ -33,7 +33,6 @@ const OPERATIONS_SECTIONS = [ ...@@ -33,7 +33,6 @@ const OPERATIONS_SECTIONS = [
build: (settings: OperationsSettings) => ( build: (settings: OperationsSettings) => (
<SystemBehaviorSection <SystemBehaviorSection
defaultValues={{ defaultValues={{
RetryTimes: settings.RetryTimes,
DefaultCollapseSidebar: settings.DefaultCollapseSidebar, DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
DemoSiteEnabled: settings.DemoSiteEnabled, DemoSiteEnabled: settings.DemoSiteEnabled,
SelfUseModeEnabled: settings.SelfUseModeEnabled, SelfUseModeEnabled: settings.SelfUseModeEnabled,
...@@ -42,23 +41,20 @@ const OPERATIONS_SECTIONS = [ ...@@ -42,23 +41,20 @@ const OPERATIONS_SECTIONS = [
), ),
}, },
{ {
id: 'monitoring', id: 'alerts',
titleKey: 'Monitoring & Alerts', titleKey: 'Monitoring & Alerts',
build: (settings: OperationsSettings) => ( build: (settings: OperationsSettings) => (
<MonitoringSettingsSection <MonitoringSettingsSection
defaultValues={{ defaultValues={{
ChannelDisableThreshold: settings.ChannelDisableThreshold,
QuotaRemindThreshold: settings.QuotaRemindThreshold, QuotaRemindThreshold: settings.QuotaRemindThreshold,
AutomaticDisableChannelEnabled: 'perf_metrics_setting.enabled':
settings.AutomaticDisableChannelEnabled, settings['perf_metrics_setting.enabled'] ?? true,
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled, 'perf_metrics_setting.flush_interval':
AutomaticDisableKeywords: settings.AutomaticDisableKeywords, settings['perf_metrics_setting.flush_interval'] ?? 5,
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes, 'perf_metrics_setting.bucket_time':
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes, settings['perf_metrics_setting.bucket_time'] ?? 'hour',
'monitor_setting.auto_test_channel_enabled': 'perf_metrics_setting.retention_days':
settings['monitor_setting.auto_test_channel_enabled'], settings['perf_metrics_setting.retention_days'] ?? 0,
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
}} }}
/> />
), ),
...@@ -125,14 +121,6 @@ const OPERATIONS_SECTIONS = [ ...@@ -125,14 +121,6 @@ const OPERATIONS_SECTIONS = [
settings['performance_setting.monitor_memory_threshold'] ?? 90, settings['performance_setting.monitor_memory_threshold'] ?? 90,
'performance_setting.monitor_disk_threshold': 'performance_setting.monitor_disk_threshold':
settings['performance_setting.monitor_disk_threshold'] ?? 95, 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,
}} }}
/> />
), ),
......
...@@ -174,6 +174,15 @@ export type ModelSettings = { ...@@ -174,6 +174,15 @@ export type ModelSettings = {
AutoGroups: string AutoGroups: string
DefaultUseAutoGroup: boolean DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string '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.enabled': boolean
'channel_affinity_setting.switch_on_success': boolean 'channel_affinity_setting.switch_on_success': boolean
'channel_affinity_setting.keep_on_channel_disabled': boolean 'channel_affinity_setting.keep_on_channel_disabled': boolean
...@@ -270,19 +279,10 @@ export type BillingSettings = { ...@@ -270,19 +279,10 @@ export type BillingSettings = {
} }
export type OperationsSettings = { export type OperationsSettings = {
RetryTimes: number
DefaultCollapseSidebar: boolean DefaultCollapseSidebar: boolean
DemoSiteEnabled: boolean DemoSiteEnabled: boolean
SelfUseModeEnabled: boolean SelfUseModeEnabled: boolean
ChannelDisableThreshold: string
QuotaRemindThreshold: 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 SMTPServer: string
SMTPPort: string SMTPPort: string
SMTPAccount: string SMTPAccount: string
......
...@@ -473,6 +473,7 @@ ...@@ -473,6 +473,7 @@
"Auto Group Chain": "Auto Group Chain", "Auto Group Chain": "Auto Group Chain",
"Auto refresh": "Auto refresh", "Auto refresh": "Auto refresh",
"Auto Sync Upstream Models": "Auto Sync Upstream Models", "Auto Sync Upstream Models": "Auto Sync Upstream Models",
"Auto-disable rules": "Auto-disable rules",
"Auto-disable status codes": "Auto-disable status codes", "Auto-disable status codes": "Auto-disable status codes",
"Auto-discover": "Auto-discover", "Auto-discover": "Auto-discover",
"Auto-discovers endpoints from the provider": "Auto-discovers endpoints from the provider", "Auto-discovers endpoints from the provider": "Auto-discovers endpoints from the provider",
...@@ -683,6 +684,7 @@ ...@@ -683,6 +684,7 @@
"Channel Affinity": "Channel Affinity", "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 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 Affinity: Upstream Cache Hit": "Channel Affinity: Upstream Cache Hit",
"Channel health checks": "Channel health checks",
"Channel copied successfully": "Channel copied successfully", "Channel copied successfully": "Channel copied successfully",
"Channel created successfully": "Channel created successfully", "Channel created successfully": "Channel created successfully",
"Channel deleted successfully": "Channel deleted successfully", "Channel deleted successfully": "Channel deleted successfully",
...@@ -2391,6 +2393,7 @@ ...@@ -2391,6 +2393,7 @@
"Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)", "Max Disk Cache Size (MB)": "Max Disk Cache Size (MB)",
"Max Entries": "Max Entries", "Max Entries": "Max Entries",
"Max output": "Max output", "Max output": "Max output",
"Max Retries": "Max Retries",
"Max Requests (incl. failures)": "Max Requests (incl. failures)", "Max Requests (incl. failures)": "Max Requests (incl. failures)",
"Max Requests (including failures)": "Max Requests (including failures)", "Max Requests (including failures)": "Max Requests (including failures)",
"Max requests per period": "Max requests per period", "Max requests per period": "Max requests per period",
...@@ -3541,11 +3544,13 @@ ...@@ -3541,11 +3544,13 @@
"Restore defaults": "Restore defaults", "Restore defaults": "Restore defaults",
"Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)", "Restrict user model request frequency (may impact high concurrency performance)": "Restrict user model request frequency (may impact high concurrency performance)",
"Result": "Result", "Result": "Result",
"Request retry": "Request retry",
"Retain last N days": "Retain last N days", "Retain last N days": "Retain last N days",
"Retain last N files": "Retain last N files", "Retain last N files": "Retain last N files",
"Retention days": "Retention days", "Retention days": "Retention days",
"Retry": "Retry", "Retry": "Retry",
"Retry Chain": "Retry Chain", "Retry Chain": "Retry Chain",
"Retry Settings": "Retry Settings",
"Retry Suggestion": "Retry Suggestion", "Retry Suggestion": "Retry Suggestion",
"Retry Times": "Retry Times", "Retry Times": "Retry Times",
"Return a custom error immediately": "Return a custom error immediately", "Return a custom error immediately": "Return a custom error immediately",
...@@ -3576,6 +3581,7 @@ ...@@ -3576,6 +3581,7 @@
"Route, auth, and balance check in one place": "Route, auth, and balance check in one place", "Route, auth, and balance check in one place": "Route, auth, and balance check in one place",
"Routes": "Routes", "Routes": "Routes",
"Routing & Overrides": "Routing & Overrides", "Routing & Overrides": "Routing & Overrides",
"Routing Reliability": "Routing Reliability",
"Routing Strategy": "Routing Strategy", "Routing Strategy": "Routing Strategy",
"Rows per page": "Rows per page", "Rows per page": "Rows per page",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -473,6 +473,7 @@ ...@@ -473,6 +473,7 @@
"Auto Group Chain": "Chaîne de groupes automatique", "Auto Group Chain": "Chaîne de groupes automatique",
"Auto refresh": "Actualisation automatique", "Auto refresh": "Actualisation automatique",
"Auto Sync Upstream Models": "Synchronisation automatique des modèles en amont", "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-disable status codes": "Codes de statut de désactivation auto",
"Auto-discover": "Découverte automatique", "Auto-discover": "Découverte automatique",
"Auto-discovers endpoints from the provider": "Découvre automatiquement les points de terminaison du fournisseur", "Auto-discovers endpoints from the provider": "Découvre automatiquement les points de terminaison du fournisseur",
...@@ -683,6 +684,7 @@ ...@@ -683,6 +684,7 @@
"Channel Affinity": "Affinité de canal", "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 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 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 copied successfully": "Canal copié avec succès",
"Channel created successfully": "Canal créé avec succès", "Channel created successfully": "Canal créé avec succès",
"Channel deleted successfully": "Canal supprimé avec succès", "Channel deleted successfully": "Canal supprimé avec succès",
...@@ -2391,6 +2393,7 @@ ...@@ -2391,6 +2393,7 @@
"Max Disk Cache Size (MB)": "Taille max du cache disque (Mo)", "Max Disk Cache Size (MB)": "Taille max du cache disque (Mo)",
"Max Entries": "Entrées max", "Max Entries": "Entrées max",
"Max output": "Sortie max", "Max output": "Sortie max",
"Max Retries": "Relances max",
"Max Requests (incl. failures)": "Max Requêtes (incl. échecs)", "Max Requests (incl. failures)": "Max Requêtes (incl. échecs)",
"Max Requests (including failures)": "Max Requêtes (incluant les échecs)", "Max Requests (including failures)": "Max Requêtes (incluant les échecs)",
"Max requests per period": "Nombre max de requêtes par période", "Max requests per period": "Nombre max de requêtes par période",
...@@ -3541,11 +3544,13 @@ ...@@ -3541,11 +3544,13 @@
"Restore defaults": "Restaurer les paramètres par défaut", "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)", "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", "Result": "Résultat",
"Request retry": "Relance des requêtes",
"Retain last N days": "Conserver les N derniers jours", "Retain last N days": "Conserver les N derniers jours",
"Retain last N files": "Conserver les N derniers fichiers", "Retain last N files": "Conserver les N derniers fichiers",
"Retention days": "Jours de rétention", "Retention days": "Jours de rétention",
"Retry": "Réessayer", "Retry": "Réessayer",
"Retry Chain": "Chaîne de tentatives", "Retry Chain": "Chaîne de tentatives",
"Retry Settings": "Paramètres de relance",
"Retry Suggestion": "Suggestion de relance", "Retry Suggestion": "Suggestion de relance",
"Retry Times": "Nombre de tentatives", "Retry Times": "Nombre de tentatives",
"Return a custom error immediately": "Retourner immédiatement une erreur personnalisée", "Return a custom error immediately": "Retourner immédiatement une erreur personnalisée",
...@@ -3576,6 +3581,7 @@ ...@@ -3576,6 +3581,7 @@
"Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit", "Route, auth, and balance check in one place": "Routage, authentification et solde au même endroit",
"Routes": "Routes", "Routes": "Routes",
"Routing & Overrides": "Routage et surcharges", "Routing & Overrides": "Routage et surcharges",
"Routing Reliability": "Fiabilité du routage",
"Routing Strategy": "Stratégie de routage", "Routing Strategy": "Stratégie de routage",
"Rows per page": "Lignes par page", "Rows per page": "Lignes par page",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -473,6 +473,7 @@ ...@@ -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 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 @@ ...@@ -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.": "チャネルアフィニティは、リクエストコンテキストまたは JSON Body から抽出したキーに基づいて、前回成功したチャネルを優先的に再利用します。", "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 Affinity: Upstream Cache Hit": "チャネルアフィニティ:上流キャッシュヒット",
"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 @@ ...@@ -2391,6 +2393,7 @@
"Max Disk Cache Size (MB)": "ディスクキャッシュ最大容量 (MB)", "Max Disk Cache Size (MB)": "ディスクキャッシュ最大容量 (MB)",
"Max Entries": "最大エントリ数", "Max Entries": "最大エントリ数",
"Max output": "最大出力", "Max output": "最大出力",
"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 @@ ...@@ -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": "リクエスト再試行",
"Retain last N days": "最新N日間を保持", "Retain last N days": "最新N日間を保持",
"Retain last N files": "最新N個のファイルを保持", "Retain last N files": "最新N個のファイルを保持",
"Retention days": "保持日数", "Retention days": "保持日数",
"Retry": "再試行", "Retry": "再試行",
"Retry Chain": "リトライチェーン", "Retry Chain": "リトライチェーン",
"Retry Settings": "再試行設定",
"Retry Suggestion": "リトライ提案", "Retry Suggestion": "リトライ提案",
"Retry Times": "再試行回数", "Retry Times": "再試行回数",
"Return a custom error immediately": "即座にカスタムエラーを返す", "Return a custom error immediately": "即座にカスタムエラーを返す",
...@@ -3576,6 +3581,7 @@ ...@@ -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 Strategy": "ルーティング戦略", "Routing Strategy": "ルーティング戦略",
"Rows per page": "ページあたりの行数", "Rows per page": "ページあたりの行数",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -473,6 +473,7 @@ ...@@ -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 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 @@ ...@@ -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.": "Привязка к каналу повторно использует последний успешный канал на основе ключей, извлечённых из контекста запроса или тела JSON.", "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 Affinity: Upstream Cache Hit": "Привязка к каналу: попадание в кэш upstream",
"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 @@ ...@@ -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 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 @@ ...@@ -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": "Повтор запросов",
"Retain last N days": "Хранить последние N дней", "Retain last N days": "Хранить последние N дней",
"Retain last N files": "Хранить последние N файлов", "Retain last N files": "Хранить последние N файлов",
"Retention days": "Дней хранения", "Retention days": "Дней хранения",
"Retry": "Повторить попытку", "Retry": "Повторить попытку",
"Retry Chain": "Цепочка повторов", "Retry Chain": "Цепочка повторов",
"Retry Settings": "Настройки повторов",
"Retry Suggestion": "Рекомендация по повтору", "Retry Suggestion": "Рекомендация по повтору",
"Retry Times": "Количество повторных попыток", "Retry Times": "Количество повторных попыток",
"Return a custom error immediately": "Немедленно вернуть пользовательскую ошибку", "Return a custom error immediately": "Немедленно вернуть пользовательскую ошибку",
...@@ -3576,6 +3581,7 @@ ...@@ -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 Strategy": "Стратегия маршрутизации", "Routing Strategy": "Стратегия маршрутизации",
"Rows per page": "Строк на страницу", "Rows per page": "Строк на страницу",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -473,6 +473,7 @@ ...@@ -473,6 +473,7 @@
"Auto Group Chain": "Chuỗi nhóm tự động", "Auto Group Chain": "Chuỗi nhóm tự động",
"Auto refresh": "Tự động làm mới", "Auto refresh": "Tự động làm mới",
"Auto Sync Upstream Models": "Tự động đồng bộ mô hình nguồn", "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-disable status codes": "Mã trạng thái tự tắt",
"Auto-discover": "Tự động khám phá", "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", "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 @@ ...@@ -683,6 +684,7 @@
"Channel Affinity": "Ưu tiên kênh", "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 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 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 copied successfully": "Sao chép kênh thành công",
"Channel created successfully": "Tạo 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", "Channel deleted successfully": "Xóa kênh thành công",
...@@ -2391,6 +2393,7 @@ ...@@ -2391,6 +2393,7 @@
"Max Disk Cache Size (MB)": "Dung lượng tối đa bộ nhớ đệm đĩa (MB)", "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 Entries": "Số mục tối đa",
"Max output": "Đầu ra 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 (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 (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", "Max requests per period": "Yêu cầu tối đa mỗi khoảng thời gian",
...@@ -3541,11 +3544,13 @@ ...@@ -3541,11 +3544,13 @@
"Restore defaults": "Khôi phục mặc định", "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)", "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ả", "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 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", "Retain last N files": "Giữ lại N tệp gần nhất",
"Retention days": "Số ngày lưu giữ", "Retention days": "Số ngày lưu giữ",
"Retry": "Thử lại", "Retry": "Thử lại",
"Retry Chain": "Chuỗi 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 Suggestion": "Gợi ý thử lại",
"Retry Times": "Số lần 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", "Return a custom error immediately": "Trả về lỗi tùy chỉnh ngay lập tức",
...@@ -3576,6 +3581,7 @@ ...@@ -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", "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", "Routes": "Route",
"Routing & Overrides": "Định tuyến & ghi đè", "Routing & Overrides": "Định tuyến & ghi đè",
"Routing Reliability": "Độ tin cậy định tuyến",
"Routing Strategy": "Chiến lược định tuyến", "Routing Strategy": "Chiến lược định tuyến",
"Rows per page": "Số hàng trên trang", "Rows per page": "Số hàng trên trang",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -473,6 +473,7 @@ ...@@ -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 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 @@ ...@@ -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.": "渠道亲和性会基于从请求上下文或 JSON Body 提取的 Key,优先复用上一次成功的渠道。", "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 Affinity: Upstream Cache Hit": "渠道亲和性:上游缓存命中",
"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 @@ ...@@ -2391,6 +2393,7 @@
"Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)", "Max Disk Cache Size (MB)": "磁盘缓存最大总量 (MB)",
"Max Entries": "最大条目数", "Max Entries": "最大条目数",
"Max output": "最大输出", "Max output": "最大输出",
"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 @@ ...@@ -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": "请求重试",
"Retain last N days": "保留最近N天", "Retain last N days": "保留最近N天",
"Retain last N files": "保留最近 N 个文件", "Retain last N files": "保留最近 N 个文件",
"Retention days": "保留天数", "Retention days": "保留天数",
"Retry": "重试", "Retry": "重试",
"Retry Chain": "重试链路", "Retry Chain": "重试链路",
"Retry Settings": "重试设置",
"Retry Suggestion": "重试建议", "Retry Suggestion": "重试建议",
"Retry Times": "重试次数", "Retry Times": "重试次数",
"Return a custom error immediately": "立即返回自定义错误", "Return a custom error immediately": "立即返回自定义错误",
...@@ -3576,6 +3581,7 @@ ...@@ -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 Strategy": "路由策略", "Routing Strategy": "路由策略",
"Rows per page": "每页行数", "Rows per page": "每页行数",
"RPM": "RPM", "RPM": "RPM",
......
...@@ -30,6 +30,9 @@ export const STATIC_I18N_KEYS = [ ...@@ -30,6 +30,9 @@ export const STATIC_I18N_KEYS = [
// Sidebar views (drill-in workspaces) // Sidebar views (drill-in workspaces)
'System Settings', 'System Settings',
'Back to Dashboard', 'Back to Dashboard',
'Auto-disable rules',
'Channel health checks',
'Request retry',
// System settings sidebar // System settings sidebar
'System Administration', 'System Administration',
...@@ -39,6 +42,7 @@ export const STATIC_I18N_KEYS = [ ...@@ -39,6 +42,7 @@ export const STATIC_I18N_KEYS = [
'Content', 'Content',
'Integrations', 'Integrations',
'Models', 'Models',
'Routing Reliability',
'Maintenance', 'Maintenance',
// Pricing constants // Pricing constants
......
...@@ -27,6 +27,13 @@ export const Route = createFileRoute( ...@@ -27,6 +27,13 @@ export const Route = createFileRoute(
'/_authenticated/system-settings/operations/$section' '/_authenticated/system-settings/operations/$section'
)({ )({
beforeLoad: ({ params }) => { 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[] const validSections = OPERATIONS_SECTION_IDS as unknown as string[]
if (!validSections.includes(params.section)) { if (!validSections.includes(params.section)) {
throw redirect({ throw redirect({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment