refactor(auth): replace dashboard sessions with stateless tokens and session control (#6329)

* refactor(auth): replace dashboard sessions with stateless tokens

* feat(auth): harden session issuance and distributed enforcement

* fix(proxy): preserve trusted proxy compatibility defaults

* refactor: address dashboard auth review feedback

* refactor: remove classic frontend and flatten web app
This commit is contained in:
Calcium-Ion
2026-07-20 16:48:43 +08:00
committed by GitHub
parent 5a6c53d496
commit 31d70fca39
1605 changed files with 17511 additions and 147913 deletions
+242
View File
@@ -0,0 +1,242 @@
/*
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
*/
export type HeaderNavAccessConfig = {
enabled: boolean
requireAuth: boolean
}
export type HeaderNavModulesConfig = {
home: boolean
console: boolean
pricing: HeaderNavAccessConfig
rankings: HeaderNavAccessConfig
docs: boolean
about: boolean
[key: string]: boolean | HeaderNavAccessConfig
}
export type SidebarSectionConfig = {
enabled: boolean
[key: string]: boolean
}
export type SidebarModulesAdminConfig = Record<string, SidebarSectionConfig>
export const HEADER_NAV_DEFAULT: HeaderNavModulesConfig = {
home: true,
console: true,
pricing: {
enabled: true,
requireAuth: false,
},
rankings: {
enabled: true,
requireAuth: false,
},
docs: true,
about: true,
}
export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
chat: {
enabled: true,
playground: true,
chat: true,
},
console: {
enabled: true,
detail: true,
token: true,
log: true,
midjourney: true,
task: true,
},
personal: {
enabled: true,
topup: true,
personal: true,
},
admin: {
enabled: true,
channel: true,
models: true,
redemption: true,
user: true,
setting: true,
subscription: true,
},
}
const toBoolean = (value: unknown, fallback: boolean): boolean => {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'true' || normalized === '1') return true
if (normalized === 'false' || normalized === '0') return false
}
return fallback
}
const cloneHeaderNavDefault = (): HeaderNavModulesConfig => ({
...HEADER_NAV_DEFAULT,
pricing: { ...HEADER_NAV_DEFAULT.pricing },
rankings: { ...HEADER_NAV_DEFAULT.rankings },
})
const parseAccessModule = (
raw: unknown,
fallback: HeaderNavAccessConfig
): HeaderNavAccessConfig => {
if (
typeof raw === 'boolean' ||
typeof raw === 'string' ||
typeof raw === 'number'
) {
return {
enabled: toBoolean(raw, fallback.enabled),
requireAuth: fallback.requireAuth,
}
}
if (raw && typeof raw === 'object') {
const record = raw as Record<string, unknown>
return {
enabled: toBoolean(record.enabled, fallback.enabled),
requireAuth: toBoolean(record.requireAuth, fallback.requireAuth),
}
}
return { ...fallback }
}
const cloneSidebarDefault = (): SidebarModulesAdminConfig =>
Object.entries(SIDEBAR_MODULES_DEFAULT).reduce<SidebarModulesAdminConfig>(
(acc, [section, config]) => {
acc[section] = { ...config }
return acc
},
{}
)
export function parseHeaderNavModules(
value: string | null | undefined
): HeaderNavModulesConfig {
const base = cloneHeaderNavDefault()
if (!value) {
return base
}
try {
const parsed = JSON.parse(value) as Record<string, unknown>
const result: HeaderNavModulesConfig = {
...base,
pricing: { ...base.pricing },
rankings: { ...base.rankings },
}
Object.entries(parsed).forEach(([key, raw]) => {
if (key === 'pricing') {
result.pricing = parseAccessModule(raw, base.pricing)
return
}
if (key === 'rankings') {
result.rankings = parseAccessModule(raw, base.rankings)
return
}
if (typeof raw === 'boolean') {
result[key] = raw
return
}
if (typeof raw === 'string' || typeof raw === 'number') {
result[key] = toBoolean(raw, Boolean(base[key]))
return
}
})
return result
} catch {
return base
}
}
export function serializeHeaderNavModules(
config: HeaderNavModulesConfig
): string {
return JSON.stringify(config)
}
export function parseSidebarModulesAdmin(
value: string | null | undefined
): SidebarModulesAdminConfig {
const defaults = cloneSidebarDefault()
// If empty string, null, or undefined, use default config
if (!value || value.trim() === '') return defaults
try {
const parsed = JSON.parse(value) as Record<string, unknown>
const result: SidebarModulesAdminConfig = {}
Object.entries(parsed).forEach(([sectionKey, raw]) => {
if (!raw || typeof raw !== 'object') return
const defaultSection = defaults[sectionKey] ?? { enabled: true }
const sectionConfig: SidebarSectionConfig = {
enabled: toBoolean(
(raw as Record<string, unknown>).enabled,
defaultSection.enabled ?? true
),
}
Object.entries(raw as Record<string, unknown>).forEach(
([moduleKey, moduleValue]) => {
if (moduleKey === 'enabled') return
sectionConfig[moduleKey] = toBoolean(
moduleValue,
defaultSection[moduleKey] ?? true
)
}
)
result[sectionKey] = sectionConfig
})
// Merge defaults to ensure expected sections exist
Object.entries(defaults).forEach(([sectionKey, config]) => {
if (!result[sectionKey]) {
result[sectionKey] = { ...config }
return
}
Object.entries(config).forEach(([moduleKey, moduleValue]) => {
if (!(moduleKey in result[sectionKey])) {
result[sectionKey][moduleKey] = moduleValue
}
})
})
return result
} catch {
return defaults
}
}
export function serializeSidebarModulesAdmin(
config: SidebarModulesAdminConfig
): string {
return JSON.stringify(config)
}
@@ -0,0 +1,301 @@
/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import {
Form,
FormControl,
FormDescription,
FormField,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
SettingsControlChildren,
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
HEADER_NAV_DEFAULT,
type HeaderNavModulesConfig,
serializeHeaderNavModules,
} from './config'
const headerNavSchema = z.object({
home: z.boolean(),
console: z.boolean(),
pricingEnabled: z.boolean(),
pricingRequireAuth: z.boolean(),
rankingsEnabled: z.boolean(),
rankingsRequireAuth: z.boolean(),
docs: z.boolean(),
about: z.boolean(),
})
type HeaderNavFormValues = z.infer<typeof headerNavSchema>
type HeaderNavigationSectionProps = {
config: HeaderNavModulesConfig
initialSerialized: string
}
const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({
home:
config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home),
console:
config.console === undefined
? HEADER_NAV_DEFAULT.console
: Boolean(config.console),
pricingEnabled:
config.pricing?.enabled === undefined
? HEADER_NAV_DEFAULT.pricing.enabled
: Boolean(config.pricing.enabled),
pricingRequireAuth:
config.pricing?.requireAuth === undefined
? HEADER_NAV_DEFAULT.pricing.requireAuth
: Boolean(config.pricing.requireAuth),
rankingsEnabled:
config.rankings?.enabled === undefined
? HEADER_NAV_DEFAULT.rankings.enabled
: Boolean(config.rankings.enabled),
rankingsRequireAuth:
config.rankings?.requireAuth === undefined
? HEADER_NAV_DEFAULT.rankings.requireAuth
: Boolean(config.rankings.requireAuth),
docs:
config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs),
about:
config.about === undefined
? HEADER_NAV_DEFAULT.about
: Boolean(config.about),
})
export function HeaderNavigationSection({
config,
initialSerialized,
}: HeaderNavigationSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const formDefaults = useMemo(() => toFormValues(config), [config])
const form = useForm<HeaderNavFormValues>({
resolver: zodResolver(headerNavSchema),
defaultValues: formDefaults,
})
useEffect(() => {
form.reset(formDefaults)
}, [formDefaults, form])
const onSubmit = async (values: HeaderNavFormValues) => {
const payload: HeaderNavModulesConfig = {
...config,
home: values.home,
console: values.console,
docs: values.docs,
about: values.about,
pricing: {
...(config.pricing ?? HEADER_NAV_DEFAULT.pricing),
enabled: values.pricingEnabled,
requireAuth: values.pricingRequireAuth,
},
rankings: {
...(config.rankings ?? HEADER_NAV_DEFAULT.rankings),
enabled: values.rankingsEnabled,
requireAuth: values.rankingsRequireAuth,
},
}
const serialized = serializeHeaderNavModules(payload)
if (serialized === initialSerialized) {
return
}
await updateOption.mutateAsync({
key: 'HeaderNavModules',
value: serialized,
})
}
const resetToDefault = () => {
form.reset(toFormValues(HEADER_NAV_DEFAULT))
}
const simpleModules: Array<{
key: keyof HeaderNavFormValues
title: string
description: string
}> = [
{
key: 'home',
title: t('Home'),
description: t('Landing page with system overview.'),
},
{
key: 'console',
title: t('Console'),
description: t('User dashboard and quota controls.'),
},
{
key: 'docs',
title: t('Docs'),
description: t('Documentation or external knowledge base.'),
},
{
key: 'about',
title: t('About'),
description: t('Static page describing the platform.'),
},
]
const accessModules: Array<{
enabledKey: keyof HeaderNavFormValues
requireAuthKey: keyof HeaderNavFormValues
requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled'
title: string
description: string
requireAuthTitle: string
requireAuthDescription: string
}> = [
{
enabledKey: 'pricingEnabled',
requireAuthKey: 'pricingRequireAuth',
requireAuthDependsOn: 'pricingEnabled',
title: t('Model Square'),
description: t('Public model catalog and pricing page.'),
requireAuthTitle: t('Require login to view models'),
requireAuthDescription: t(
'Visitors must authenticate before accessing the pricing directory.'
),
},
{
enabledKey: 'rankingsEnabled',
requireAuthKey: 'rankingsRequireAuth',
requireAuthDependsOn: 'rankingsEnabled',
title: t('Rankings'),
description: t('Public rankings page based on live usage data.'),
requireAuthTitle: t('Require login to view rankings'),
requireAuthDescription: t(
'Visitors must authenticate before accessing the rankings page.'
),
},
]
return (
<SettingsSection title={t('Header navigation')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
onReset={resetToDefault}
isSaving={updateOption.isPending}
resetLabel='Reset to default'
saveLabel='Save navigation'
/>
<div className='grid gap-4 md:grid-cols-2'>
{simpleModules.map((module) => (
<FormField
key={module.key}
control={form.control}
name={module.key}
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{module.title}</FormLabel>
<FormDescription>{module.description}</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</SettingsSwitchItem>
)}
/>
))}
</div>
<div className='grid gap-4 lg:grid-cols-2'>
{accessModules.map((module) => (
<SettingsControlGroup key={module.enabledKey}>
<FormField
control={form.control}
name={module.enabledKey}
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{module.title}</FormLabel>
<FormDescription>{module.description}</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name={module.requireAuthKey}
render={({ field }) => (
<SettingsControlChildren>
<SettingsSwitchItem className='py-2'>
<SettingsSwitchContent>
<FormLabel>{module.requireAuthTitle}</FormLabel>
<FormDescription>
{module.requireAuthDescription}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!form.watch(module.requireAuthDependsOn)}
/>
</FormControl>
<FormMessage />
</SettingsSwitchItem>
</SettingsControlChildren>
)}
/>
</SettingsControlGroup>
))}
</div>
</SettingsForm>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,616 @@
/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import * as z from 'zod'
import { DateTimePicker } from '@/components/datetime-picker'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { api } from '@/lib/api'
import dayjs from '@/lib/dayjs'
import { formatTimestampToDate } from '@/lib/format'
import {
getCurrentLogCleanupTask,
getSystemTask,
startLogCleanupTask,
} from '../api'
import {
SettingsControlGroup,
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import type { LogCleanupTask } from '../types'
const logSettingsSchema = z.object({
LogConsumeEnabled: z.boolean(),
})
type LogSettingsFormValues = z.infer<typeof logSettingsSchema>
type LogSettingsSectionProps = {
defaultEnabled: boolean
}
type ServerLogInfo = {
enabled: boolean
log_dir: string
file_count: number
total_size: number
oldest_time?: string
newest_time?: string
}
const HOURS_IN_DAY = 24
function formatBytes(bytes: number, decimals = 2): string {
if (!bytes || Number.isNaN(bytes)) return '0 Bytes'
if (bytes === 0) return '0 Bytes'
if (bytes < 0) return `-${formatBytes(-bytes, decimals)}`
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(Math.abs(bytes)) / Math.log(k))
if (i < 0 || i >= sizes.length) return `${bytes} Bytes`
return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${
sizes[i]
}`
}
const getDateHoursAgo = (hours: number) => {
const date = new Date()
date.setHours(date.getHours() - hours)
return date
}
const getDateDaysAgo = (days: number) => getDateHoursAgo(days * HOURS_IN_DAY)
const quickSelectOptions = [
{
label: '24 hours ago',
getValue: () => getDateHoursAgo(24),
},
{
label: '7 days ago',
getValue: () => getDateDaysAgo(7),
},
{
label: '30 days ago',
getValue: () => getDateDaysAgo(30),
},
]
function isActiveLogCleanupTask(task: LogCleanupTask | null) {
return task?.status === 'pending' || task?.status === 'running'
}
export function LogSettingsSection({
defaultEnabled,
}: LogSettingsSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const form = useForm<LogSettingsFormValues>({
resolver: zodResolver(logSettingsSchema),
defaultValues: {
LogConsumeEnabled: defaultEnabled,
},
})
const [purgeDate, setPurgeDate] = useState<Date | undefined>(() =>
getDateDaysAgo(30)
)
const [isStartingLogCleanup, setIsStartingLogCleanup] = useState(false)
const [logCleanupTask, setLogCleanupTask] = useState<LogCleanupTask | null>(
null
)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [serverLogInfo, setServerLogInfo] = useState<ServerLogInfo | null>(null)
const [serverLogCleanupMode, setServerLogCleanupMode] = useState('by_count')
const [serverLogCleanupValue, setServerLogCleanupValue] = useState(10)
const [serverLogCleanupLoading, setServerLogCleanupLoading] = useState(false)
const fetchServerLogInfo = useCallback(async () => {
try {
const res = await api.get('/api/performance/logs')
if (res.data.success) setServerLogInfo(res.data.data)
} catch {
/* ignore */
}
}, [])
useEffect(() => {
form.reset({ LogConsumeEnabled: defaultEnabled })
}, [defaultEnabled, form])
useEffect(() => {
fetchServerLogInfo()
}, [fetchServerLogInfo])
useEffect(() => {
let cancelled = false
async function fetchCurrentLogCleanupTask() {
try {
const res = await getCurrentLogCleanupTask()
if (!cancelled && res.success && res.data) {
setLogCleanupTask(res.data)
}
} catch {
/* ignore */
}
}
fetchCurrentLogCleanupTask()
return () => {
cancelled = true
}
}, [])
const purgeTimestamp = useMemo(() => {
if (!purgeDate) return null
return Math.floor(purgeDate.getTime() / 1000)
}, [purgeDate])
const formattedPurgeDate = useMemo(() => {
if (!purgeDate) return ''
return formatTimestampToDate(purgeDate.getTime(), 'milliseconds')
}, [purgeDate])
const logCleanupActive = isActiveLogCleanupTask(logCleanupTask)
const logCleanupState = logCleanupTask?.state
const logCleanupProgress = Math.min(
100,
Math.max(0, logCleanupState?.progress ?? 0)
)
const logCleanupProcessed = logCleanupState?.processed ?? 0
const logCleanupTotal = logCleanupState?.total ?? 0
const logCleanupTaskId = logCleanupTask?.task_id
useEffect(() => {
if (!logCleanupTaskId || !logCleanupActive) return
let cancelled = false
const interval = window.setInterval(async () => {
try {
const res = await getSystemTask(logCleanupTaskId)
if (cancelled || !res.success || !res.data) return
setLogCleanupTask(res.data)
if (!isActiveLogCleanupTask(res.data)) {
if (res.data.status === 'succeeded') {
const count =
res.data.result?.deleted_count ?? res.data.state?.processed ?? 0
toast.success(
count > 0
? t('{{count}} log entries removed.', { count })
: t('No log entries matched the selected time.')
)
} else if (res.data.status === 'failed') {
toast.error(res.data.error || t('Failed to clean logs'))
}
}
} catch {
/* keep polling */
}
}, 1000)
return () => {
cancelled = true
window.clearInterval(interval)
}
}, [logCleanupActive, logCleanupTaskId, t])
const onSubmit = async (values: LogSettingsFormValues) => {
if (values.LogConsumeEnabled === defaultEnabled) return
await updateOption.mutateAsync({
key: 'LogConsumeEnabled',
value: values.LogConsumeEnabled,
})
}
const handleRequestCleanLogs = () => {
if (!purgeTimestamp) {
toast.error(t('Select a timestamp before clearing logs.'))
return
}
setShowConfirmDialog(true)
}
const handleCleanLogs = async () => {
if (!purgeTimestamp) {
toast.error(t('Select a timestamp before clearing logs.'))
return
}
setIsStartingLogCleanup(true)
try {
const res = await startLogCleanupTask(purgeTimestamp)
if (!res.success) {
throw new Error(res.message || t('Failed to clean logs'))
}
if (!res.data) {
throw new Error(t('Failed to clean logs'))
}
setLogCleanupTask(res.data)
setShowConfirmDialog(false)
toast.success(t('Log cleanup task started.'))
} catch (error) {
const message =
error instanceof Error ? error.message : t('Failed to clean logs')
toast.error(message)
} finally {
setIsStartingLogCleanup(false)
}
}
const cleanupServerLogFiles = async () => {
if (
!serverLogCleanupValue ||
Number.isNaN(serverLogCleanupValue) ||
serverLogCleanupValue < 1
) {
toast.error(t('Please enter a valid number'))
return
}
setServerLogCleanupLoading(true)
try {
const res = await api.delete(
`/api/performance/logs?mode=${serverLogCleanupMode}&value=${serverLogCleanupValue}`
)
if (res.data.success) {
const { deleted_count, freed_bytes } = res.data.data
toast.success(
t('Cleaned up {{count}} log files, freed {{size}}', {
count: deleted_count,
size: formatBytes(freed_bytes),
})
)
} else {
toast.error(res.data.message || t('Cleanup failed'))
}
fetchServerLogInfo()
} catch {
toast.error(t('Cleanup failed'))
} finally {
setServerLogCleanupLoading(false)
}
}
return (
<SettingsSection title={t('Log Maintenance')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save log settings'
/>
<FormField
control={form.control}
name='LogConsumeEnabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Record quota usage')}</FormLabel>
<FormDescription>
{t(
'Track per-request consumption to power usage analytics. Keeping this on increases database writes.'
)}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage />
</SettingsSwitchItem>
)}
/>
<SettingsControlGroup className='space-y-3'>
<div>
<h4 className='text-sm font-medium'>{t('Clean history logs')}</h4>
<p className='text-muted-foreground text-sm'>
{t(
'Remove all log entries created before the selected timestamp.'
)}
</p>
</div>
<DateTimePicker value={purgeDate} onChange={setPurgeDate} />
<div className='flex flex-wrap gap-3'>
{quickSelectOptions.map((option) => (
<Button
key={option.label}
type='button'
variant='outline'
onClick={() => setPurgeDate(option.getValue())}
>
{t(option.label)}
</Button>
))}
<Button
type='button'
variant='destructive'
onClick={handleRequestCleanLogs}
disabled={isStartingLogCleanup || logCleanupActive}
>
{isStartingLogCleanup || logCleanupActive
? t('Cleaning...')
: t('Clean logs')}
</Button>
</div>
{logCleanupTask && (
<div className='rounded-md border p-3'>
<div className='mb-2 flex items-center justify-between gap-3 text-sm'>
<span className='font-medium'>
{t('Log cleanup progress')}
</span>
<span className='text-muted-foreground tabular-nums'>
{logCleanupProgress}%
</span>
</div>
<Progress value={logCleanupProgress} />
<div className='text-muted-foreground mt-2 text-xs'>
{t('{{processed}} of {{total}} log entries processed.', {
processed: logCleanupProcessed,
total: logCleanupTotal,
})}
</div>
{logCleanupTask.status === 'failed' && logCleanupTask.error && (
<div className='text-destructive mt-2 text-xs'>
{logCleanupTask.error}
</div>
)}
</div>
)}
</SettingsControlGroup>
</SettingsForm>
</Form>
<Separator />
<div className='space-y-4'>
<div>
<h4 className='font-medium'>{t('Server Log Management')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Manage server log files. Log files accumulate over time; regular cleanup is recommended to free disk space.'
)}
</p>
</div>
{serverLogInfo !== null &&
(serverLogInfo.enabled ? (
<div className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='grid grid-cols-2 gap-2 text-sm md:grid-cols-4'>
<div>
<span className='text-muted-foreground'>
{t('Log Directory')}:
</span>{' '}
<span className='font-mono text-xs'>
{serverLogInfo.log_dir}
</span>
</div>
<div>
<span className='text-muted-foreground'>
{t('Log File Count')}:
</span>{' '}
{serverLogInfo.file_count}
</div>
<div>
<span className='text-muted-foreground'>
{t('Total Log Size')}:
</span>{' '}
{formatBytes(serverLogInfo.total_size)}
</div>
{serverLogInfo.oldest_time && serverLogInfo.newest_time && (
<div>
<span className='text-muted-foreground'>
{t('Date Range')}:
</span>{' '}
{dayjs(serverLogInfo.oldest_time).format('YYYY-MM-DD')} ~{' '}
{dayjs(serverLogInfo.newest_time).format('YYYY-MM-DD')}
</div>
)}
</div>
</div>
<div className='flex flex-wrap items-end gap-3'>
<div className='grid gap-1.5'>
<Label className='text-xs'>{t('Cleanup Mode')}</Label>
<Select
items={[
{ value: 'by_count', label: t('Retain last N files') },
{ value: 'by_days', label: t('Retain last N days') },
]}
value={serverLogCleanupMode}
onValueChange={(value) =>
value !== null && setServerLogCleanupMode(value)
}
>
<SelectTrigger className='w-[160px]'>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectGroup>
<SelectItem value='by_count'>
{t('Retain last N files')}
</SelectItem>
<SelectItem value='by_days'>
{t('Retain last N days')}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className='grid gap-1.5'>
<Label className='text-xs'>
{serverLogCleanupMode === 'by_count'
? t('Files to Retain')
: t('Days to Retain')}
</Label>
<Input
type='number'
min={1}
max={serverLogCleanupMode === 'by_count' ? 1000 : 3650}
value={serverLogCleanupValue}
onChange={(event) =>
setServerLogCleanupValue(Number(event.target.value))
}
className='w-[120px]'
/>
</div>
<AlertDialog>
<AlertDialogTrigger
render={
<Button
type='button'
variant='destructive'
size='sm'
disabled={serverLogCleanupLoading}
/>
}
>
{serverLogCleanupLoading
? t('Cleaning...')
: t('Clean Up Log Files')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Confirm log file cleanup?')}
</AlertDialogTitle>
<AlertDialogDescription>
{serverLogCleanupMode === 'by_count'
? t(
'Only the last {{value}} log files will be retained; the rest will be deleted.',
{
value: serverLogCleanupValue,
}
)
: t(
'Log files older than {{value}} days will be deleted.',
{
value: serverLogCleanupValue,
}
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={cleanupServerLogFiles}
>
{t('Confirm Cleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
) : (
<Alert>
<AlertDescription>
{t(
'Server logging is not enabled (log directory not configured)'
)}
</AlertDescription>
</Alert>
))}
</div>
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('Confirm log cleanup')}</AlertDialogTitle>
<AlertDialogDescription>
{formattedPurgeDate
? t(
'This will permanently remove all log entries created before {{date}}.',
{ date: formattedPurgeDate }
)
: t(
'This will permanently remove log entries before the selected timestamp.'
)}{' '}
{t('This action cannot be undone.')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isStartingLogCleanup}>
{t('Cancel')}
</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={handleCleanLogs}
disabled={isStartingLogCleanup}
>
{isStartingLogCleanup ? t('Cleaning...') : t('Delete logs')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsSection>
)
}
@@ -0,0 +1,107 @@
/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import * as z from 'zod'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Textarea } from '@/components/ui/textarea'
import { SettingsForm } from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
const noticeSchema = z.object({
Notice: z.string().optional(),
})
type NoticeFormValues = z.infer<typeof noticeSchema>
type NoticeSectionProps = {
defaultValue: string
}
export function NoticeSection({ defaultValue }: NoticeSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const form = useForm<NoticeFormValues>({
resolver: zodResolver(noticeSchema),
defaultValues: {
Notice: defaultValue ?? '',
},
})
useEffect(() => {
form.reset({ Notice: defaultValue ?? '' })
}, [defaultValue, form])
const onSubmit = async (values: NoticeFormValues) => {
const normalized = values.Notice ?? ''
if (normalized === (defaultValue ?? '')) {
return
}
await updateOption.mutateAsync({
key: 'Notice',
value: normalized,
})
}
return (
<SettingsSection title={t('System Notice')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
saveLabel='Save notice'
/>
<FormField
control={form.control}
name='Notice'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Announcement content')}</FormLabel>
<FormControl>
<Textarea
rows={8}
placeholder={t(
'Planned maintenance on Friday at 22:00 UTC...'
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsForm>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,717 @@
/*
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 { zodResolver } from '@hookform/resolvers/zod'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import * as z from 'zod'
import { StatusBadge } from '@/components/status-badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { api } from '@/lib/api'
import {
SettingsForm,
SettingsSwitchContent,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { safeNumberFieldProps } from '../utils/numeric-field'
/**
* IMPORTANT: react-hook-form 7 interprets dotted `name` strings as nested
* paths. If we declare the schema with literal flat keys like
* `'performance_setting.disk_cache_enabled'`, the form state diverges from
* what zod validates and saves silently turn into no-ops. So we model the
* form internally with proper nested objects and only flatten back to the
* server-side key format right before persisting.
*/
const perfSchema = z.object({
performance_setting: z.object({
disk_cache_enabled: z.boolean(),
disk_cache_threshold_mb: z.coerce.number().min(1),
disk_cache_max_size_mb: z.coerce.number().min(100),
disk_cache_path: z.string(),
monitor_enabled: z.boolean(),
monitor_cpu_threshold: z.coerce.number().min(0),
monitor_memory_threshold: z.coerce.number().min(0).max(100),
monitor_disk_threshold: z.coerce.number().min(0).max(100),
}),
})
type PerfFormInput = z.input<typeof perfSchema>
type PerfFormValues = z.output<typeof perfSchema>
type FlatPerfDefaults = {
'performance_setting.disk_cache_enabled': boolean
'performance_setting.disk_cache_threshold_mb': number
'performance_setting.disk_cache_max_size_mb': number
'performance_setting.disk_cache_path': string
'performance_setting.monitor_enabled': boolean
'performance_setting.monitor_cpu_threshold': number
'performance_setting.monitor_memory_threshold': number
'performance_setting.monitor_disk_threshold': number
}
const buildFormDefaults = (defaults: FlatPerfDefaults): PerfFormInput => ({
performance_setting: {
disk_cache_enabled: defaults['performance_setting.disk_cache_enabled'],
disk_cache_threshold_mb:
defaults['performance_setting.disk_cache_threshold_mb'],
disk_cache_max_size_mb:
defaults['performance_setting.disk_cache_max_size_mb'],
disk_cache_path: defaults['performance_setting.disk_cache_path'] ?? '',
monitor_enabled: defaults['performance_setting.monitor_enabled'],
monitor_cpu_threshold:
defaults['performance_setting.monitor_cpu_threshold'],
monitor_memory_threshold:
defaults['performance_setting.monitor_memory_threshold'],
monitor_disk_threshold:
defaults['performance_setting.monitor_disk_threshold'],
},
})
const normalizeFormValues = (values: PerfFormValues): FlatPerfDefaults => ({
'performance_setting.disk_cache_enabled':
values.performance_setting.disk_cache_enabled,
'performance_setting.disk_cache_threshold_mb':
values.performance_setting.disk_cache_threshold_mb,
'performance_setting.disk_cache_max_size_mb':
values.performance_setting.disk_cache_max_size_mb,
'performance_setting.disk_cache_path':
values.performance_setting.disk_cache_path ?? '',
'performance_setting.monitor_enabled':
values.performance_setting.monitor_enabled,
'performance_setting.monitor_cpu_threshold':
values.performance_setting.monitor_cpu_threshold,
'performance_setting.monitor_memory_threshold':
values.performance_setting.monitor_memory_threshold,
'performance_setting.monitor_disk_threshold':
values.performance_setting.monitor_disk_threshold,
})
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]
}`
}
interface Props {
defaultValues: FlatPerfDefaults
}
type PerformanceStats = {
cache_stats?: {
current_disk_usage_bytes: number
disk_cache_max_bytes: number
active_disk_files: number
disk_cache_hits: number
current_memory_usage_bytes: number
active_memory_buffers: number
memory_cache_hits: number
}
disk_space_info?: {
total: number
free: number
used: number
used_percent: number
}
memory_stats?: {
alloc: number
total_alloc: number
sys: number
num_gc: number
num_goroutine: number
}
disk_cache_info?: {
path: string
file_count: number
total_size: number
}
config?: {
is_running_in_container: boolean
}
}
export function PerformanceSection(props: Props) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const [stats, setStats] = useState<PerformanceStats | null>(null)
const formDefaults = useMemo(
() => buildFormDefaults(props.defaultValues),
[props.defaultValues]
)
const form = useForm<PerfFormInput, unknown, PerfFormValues>({
resolver: zodResolver(perfSchema),
defaultValues: formDefaults,
})
const baselineRef = useRef<FlatPerfDefaults>(props.defaultValues)
const baselineSerializedRef = useRef<string>(
JSON.stringify(props.defaultValues)
)
useEffect(() => {
const serialized = JSON.stringify(props.defaultValues)
if (serialized === baselineSerializedRef.current) return
baselineRef.current = props.defaultValues
baselineSerializedRef.current = serialized
form.reset(buildFormDefaults(props.defaultValues))
}, [props.defaultValues, form])
const fetchStats = useCallback(async () => {
try {
const res = await api.get('/api/performance/stats')
if (res.data.success) setStats(res.data.data)
} catch {
/* ignore */
}
}, [])
useEffect(() => {
fetchStats()
}, [fetchStats])
const onSubmit = async (values: PerfFormValues) => {
const normalized = normalizeFormValues(values)
const changedKeys = (
Object.keys(normalized) as Array<keyof FlatPerfDefaults>
).filter((key) => normalized[key] !== baselineRef.current[key])
if (changedKeys.length === 0) {
toast.info(t('No changes to save'))
return
}
for (const key of changedKeys) {
await updateOption.mutateAsync({
key,
value: normalized[key],
})
}
baselineRef.current = normalized
baselineSerializedRef.current = JSON.stringify(normalized)
form.reset(buildFormDefaults(normalized))
fetchStats()
}
const clearDiskCache = async () => {
try {
const res = await api.delete('/api/performance/disk_cache')
if (res.data.success) {
toast.success(t('Disk cache cleared'))
fetchStats()
}
} catch {
toast.error(t('Cleanup failed'))
}
}
const resetStats = async () => {
try {
const res = await api.post('/api/performance/reset_stats')
if (res.data.success) {
toast.success(t('Statistics reset'))
fetchStats()
}
} catch {
toast.error(t('Reset failed'))
}
}
const forceGC = async () => {
try {
const res = await api.post('/api/performance/gc')
if (res.data.success) {
toast.success(t('GC executed'))
fetchStats()
}
} catch {
toast.error(t('GC execution failed'))
}
}
const diskEnabled = form.watch('performance_setting.disk_cache_enabled')
const monitorEnabled = form.watch('performance_setting.monitor_enabled')
const maxCacheSizeRaw = form.watch(
'performance_setting.disk_cache_max_size_mb'
)
const maxCacheSizeMb =
typeof maxCacheSizeRaw === 'number'
? maxCacheSizeRaw
: Number(maxCacheSizeRaw) || 0
const lowDiskSpace =
diskEnabled &&
stats?.disk_space_info &&
stats.disk_space_info.free > 0 &&
maxCacheSizeMb > 0 &&
stats.disk_space_info.free < maxCacheSizeMb * 1024 * 1024
const diskCachePercent =
stats?.cache_stats?.disk_cache_max_bytes &&
stats.cache_stats.disk_cache_max_bytes > 0
? Math.round(
(stats.cache_stats.current_disk_usage_bytes /
stats.cache_stats.disk_cache_max_bytes) *
100
)
: 0
return (
<SettingsSection title={t('Performance Settings')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
isSaving={updateOption.isPending}
/>
{/* Disk Cache Settings */}
<div>
<h4 className='font-medium'>{t('Disk Cache Settings')}</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. SSD recommended.'
)}
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-3'>
<FormField
control={form.control}
name='performance_setting.disk_cache_enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Disk Cache')}</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='performance_setting.disk_cache_threshold_mb'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Disk Cache Threshold (MB)')}</FormLabel>
<FormControl>
<Input
type='number'
min={1}
step={1}
{...safeNumberFieldProps(field)}
disabled={!diskEnabled}
/>
</FormControl>
<FormDescription>
{t('Use disk cache when request body exceeds this size')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='performance_setting.disk_cache_max_size_mb'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Max Disk Cache Size (MB)')}</FormLabel>
<FormControl>
<Input
type='number'
min={100}
step={1}
{...safeNumberFieldProps(field)}
disabled={!diskEnabled}
/>
</FormControl>
{stats?.disk_space_info &&
stats.disk_space_info.total > 0 && (
<FormDescription>
{t('Free: {{free}} / Total: {{total}}', {
free: formatBytes(stats.disk_space_info.free),
total: formatBytes(stats.disk_space_info.total),
})}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
</div>
{lowDiskSpace && (
<Alert variant='destructive'>
<AlertDescription>
{`${t('Warning')}: ${t('Available disk space')} (${formatBytes(stats?.disk_space_info?.free ?? 0)}) ${t('is less than the configured maximum cache size')} (${maxCacheSizeMb} MB). ${t('This may cause cache failures.')}`}
</AlertDescription>
</Alert>
)}
{!stats?.config?.is_running_in_container && (
<FormField
control={form.control}
name='performance_setting.disk_cache_path'
render={({ field }) => (
<FormItem className='max-w-md'>
<FormLabel>{t('Cache Directory')}</FormLabel>
<FormControl>
<Input
placeholder={t(
'Leave empty to use system temp directory'
)}
value={field.value ?? ''}
onChange={(event) => field.onChange(event.target.value)}
name={field.name}
onBlur={field.onBlur}
ref={field.ref}
disabled={!diskEnabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Separator />
{/* System Performance Monitor */}
<div>
<h4 className='font-medium'>
{t('System Performance Monitoring')}
</h4>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'When performance monitoring is enabled and system resource usage exceeds the set threshold, new Relay requests will be rejected.'
)}
</p>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-4'>
<FormField
control={form.control}
name='performance_setting.monitor_enabled'
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{t('Enable Performance Monitoring')}</FormLabel>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<FormField
control={form.control}
name='performance_setting.monitor_cpu_threshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('CPU Threshold (%)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
step={1}
{...safeNumberFieldProps(field)}
disabled={!monitorEnabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='performance_setting.monitor_memory_threshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Memory Threshold (%)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
step={1}
{...safeNumberFieldProps(field)}
disabled={!monitorEnabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='performance_setting.monitor_disk_threshold'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Disk Threshold (%)')}</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
step={1}
{...safeNumberFieldProps(field)}
disabled={!monitorEnabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</SettingsForm>
</Form>
<Separator />
{/* Performance Stats Dashboard */}
<div className='space-y-4'>
<div className='flex items-center gap-2'>
<h4 className='font-medium'>{t('Performance Monitor')}</h4>
<Button variant='outline' size='sm' onClick={fetchStats}>
{t('Refresh Stats')}
</Button>
<AlertDialog>
<AlertDialogTrigger render={<Button variant='outline' size='sm' />}>
{t('Clean up inactive cache')}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('Confirm cleanup of inactive disk cache?')}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
'This will delete temporary cache files that have not been used for more than 10 minutes'
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
<AlertDialogAction
variant='destructive'
onClick={clearDiskCache}
>
{t('Confirm')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button variant='outline' size='sm' onClick={resetStats}>
{t('Reset Stats')}
</Button>
<Button variant='outline' size='sm' onClick={forceGC}>
{t('Run GC')}
</Button>
</div>
{stats && (
<>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2'>
<div className='space-y-2 rounded-lg border p-4'>
<p className='text-sm font-medium'>
{t('Request Body Disk Cache')}
</p>
<Progress value={diskCachePercent} />
<div className='text-muted-foreground flex justify-between text-xs'>
<span>
{formatBytes(
stats.cache_stats?.current_disk_usage_bytes ?? 0
)}{' '}
/{' '}
{formatBytes(stats.cache_stats?.disk_cache_max_bytes ?? 0)}
</span>
<span>
{t('Active Files')}:{' '}
{stats.cache_stats?.active_disk_files ?? 0}
</span>
</div>
<StatusBadge variant='neutral' copyable={false}>
{t('Disk Hits')}: {stats.cache_stats?.disk_cache_hits ?? 0}
</StatusBadge>
</div>
<div className='space-y-2 rounded-lg border p-4'>
<p className='text-sm font-medium'>
{t('Request Body Memory Cache')}
</p>
<div className='text-muted-foreground flex justify-between text-xs'>
<span>
{t('Current Cache Size')}:{' '}
{formatBytes(
stats.cache_stats?.current_memory_usage_bytes ?? 0
)}
</span>
<span>
{t('Active Cache Count')}:{' '}
{stats.cache_stats?.active_memory_buffers ?? 0}
</span>
</div>
<StatusBadge variant='neutral' copyable={false}>
{t('Memory Hits')}:{' '}
{stats.cache_stats?.memory_cache_hits ?? 0}
</StatusBadge>
</div>
</div>
{stats.disk_space_info && stats.disk_space_info.total > 0 && (
<div className='rounded-lg border p-4'>
<p className='mb-2 text-sm font-medium'>
{t('Cache Directory Disk Space')}
</p>
<Progress
value={Math.round(stats.disk_space_info.used_percent)}
/>
<div className='text-muted-foreground mt-2 flex justify-between text-xs'>
<span>
{t('Used')}: {formatBytes(stats.disk_space_info.used)}
</span>
<span>
{t('Available')}: {formatBytes(stats.disk_space_info.free)}
</span>
<span>
{t('Total')}: {formatBytes(stats.disk_space_info.total)}
</span>
</div>
</div>
)}
{stats.memory_stats && (
<div className='rounded-lg border p-4'>
<p className='mb-2 text-sm font-medium'>
{t('System Memory Stats')}
</p>
<div className='grid grid-cols-2 gap-2 text-xs md:grid-cols-5'>
<div>
<span className='text-muted-foreground'>
{t('Allocated Memory')}:
</span>{' '}
{formatBytes(stats.memory_stats.alloc)}
</div>
<div>
<span className='text-muted-foreground'>
{t('Total Allocated')}:
</span>{' '}
{formatBytes(stats.memory_stats.total_alloc)}
</div>
<div>
<span className='text-muted-foreground'>
{t('System Memory')}:
</span>{' '}
{formatBytes(stats.memory_stats.sys)}
</div>
<div>
<span className='text-muted-foreground'>
{t('GC Count')}:
</span>{' '}
{stats.memory_stats.num_gc}
</div>
<div>
<span className='text-muted-foreground'>Goroutines:</span>{' '}
{stats.memory_stats.num_goroutine}
</div>
</div>
</div>
)}
{stats.disk_cache_info && (
<div className='rounded-lg border p-4'>
<p className='mb-2 text-sm font-medium'>
{t('Cache Directory Info')}
</p>
<div className='grid grid-cols-3 gap-2 text-xs'>
<div>
<span className='text-muted-foreground'>
{t('Cache Directory')}:
</span>{' '}
<span className='font-mono'>
{stats.disk_cache_info.path}
</span>
</div>
<div>
<span className='text-muted-foreground'>
{t('Directory File Count')}:
</span>{' '}
{stats.disk_cache_info.file_count}
</div>
<div>
<span className='text-muted-foreground'>
{t('Directory Total Size')}:
</span>{' '}
{formatBytes(stats.disk_cache_info.total_size)}
</div>
</div>
</div>
)}
</>
)}
</div>
</SettingsSection>
)
}
@@ -0,0 +1,274 @@
/*
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 { useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import {
Form,
FormControl,
FormDescription,
FormField,
FormLabel,
} from '@/components/ui/form'
import { Switch } from '@/components/ui/switch'
import {
SettingsControlChildren,
SettingsForm,
SettingsSwitchContent,
SettingsControlGroup,
SettingsSwitchItem,
} from '../components/settings-form-layout'
import { SettingsPageFormActions } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import {
SIDEBAR_MODULES_DEFAULT,
type SidebarModulesAdminConfig,
serializeSidebarModulesAdmin,
} from './config'
type SidebarModulesSectionProps = {
config: SidebarModulesAdminConfig
initialSerialized: string
}
type SidebarFormValues = SidebarModulesAdminConfig
const toTitleCase = (value: string) =>
value
.replaceAll(/[_-]+/g, ' ')
.replaceAll(/\b\w/g, (char) => char.toUpperCase())
export function SidebarModulesSection({
config,
initialSerialized,
}: SidebarModulesSectionProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const sectionMeta: Record<string, { title: string; description: string }> = {
chat: {
title: t('Chat area'),
description: t('Playground experiments and live conversations.'),
},
console: {
title: t('Console area'),
description: t('Dashboards, tokens, and usage analytics.'),
},
personal: {
title: t('Personal area'),
description: t('Wallet management and personal preferences.'),
},
admin: {
title: t('Admin area'),
description: t('Global configuration and administrative tools.'),
},
}
const moduleMeta: Record<
string,
Record<string, { title: string; description: string }>
> = {
chat: {
playground: {
title: t('Playground'),
description: t('Experiment with prompts and models in real time.'),
},
chat: {
title: t('Chat'),
description: t('Access previous conversations and start new ones.'),
},
},
console: {
detail: {
title: t('Dashboard'),
description: t('Aggregated usage metrics and trend charts.'),
},
token: {
title: t('Token management'),
description: t('Create, revoke, and audit API tokens.'),
},
log: {
title: t('Usage logs'),
description: t('Detailed request logs for investigations.'),
},
midjourney: {
title: t('Drawing logs'),
description: t('History of MjProxy-style image tasks.'),
},
task: {
title: t('Task logs'),
description: t('Background job tracker for queued work.'),
},
},
personal: {
topup: {
title: t('Wallet'),
description: t('Top up balance and view billing history.'),
},
personal: {
title: t('Profile'),
description: t('Personal settings and profile management.'),
},
},
admin: {
channel: {
title: t('Channels'),
description: t('Configure upstream providers and routing.'),
},
models: {
title: t('Models'),
description: t('Manage catalog visibility and pricing.'),
},
redemption: {
title: t('Redeem codes'),
description: t('Create and review invite or credit codes.'),
},
user: {
title: t('Users'),
description: t('Administer user accounts and roles.'),
},
setting: {
title: t('System settings'),
description: t('Advanced platform configuration.'),
},
subscription: {
title: t('Subscription Management'),
description: t('Manage subscription plans and pricing.'),
},
},
}
const formDefaults = useMemo(() => config, [config])
const form = useForm<SidebarFormValues>({
defaultValues: formDefaults,
})
useEffect(() => {
form.reset(formDefaults)
}, [formDefaults, form])
const onSubmit = async (values: SidebarFormValues) => {
const serialized = serializeSidebarModulesAdmin(values)
if (serialized === initialSerialized) {
return
}
await updateOption.mutateAsync({
key: 'SidebarModulesAdmin',
value: serialized,
})
}
const resetToDefault = () => {
form.reset(SIDEBAR_MODULES_DEFAULT)
}
const sections = Object.entries(config)
return (
<SettingsSection title={t('Sidebar modules')}>
<Form {...form}>
<SettingsForm onSubmit={form.handleSubmit(onSubmit)}>
<SettingsPageFormActions
onSave={form.handleSubmit(onSubmit)}
onReset={resetToDefault}
isSaving={updateOption.isPending}
resetLabel='Reset to default'
saveLabel='Save sidebar modules'
/>
{sections.map(([sectionKey, sectionConfig]) => {
const sectionInfo = sectionMeta[sectionKey] ?? {
title: toTitleCase(sectionKey),
description: t('Custom sidebar section'),
}
const modules = Object.entries(sectionConfig).filter(
([moduleKey]) => moduleKey !== 'enabled'
)
return (
<SettingsControlGroup key={sectionKey}>
<FormField
control={form.control}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.enabled` as any}
render={({ field }) => (
<SettingsSwitchItem>
<SettingsSwitchContent>
<FormLabel>{sectionInfo.title}</FormLabel>
<FormDescription>
{sectionInfo.description}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={Boolean(field.value)}
onCheckedChange={field.onChange}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
<SettingsControlChildren className='grid gap-3 md:grid-cols-2'>
{modules.map(([moduleKey]) => {
const moduleInfo = moduleMeta[sectionKey]?.[moduleKey] ?? {
title: toTitleCase(moduleKey),
description: t('Custom module'),
}
return (
<FormField
key={`${sectionKey}.${moduleKey}`}
control={form.control}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
name={`${sectionKey}.${moduleKey}` as any}
render={({ field }) => (
<SettingsSwitchItem className='py-2'>
<SettingsSwitchContent>
<FormLabel>{moduleInfo.title}</FormLabel>
<FormDescription>
{moduleInfo.description}
</FormDescription>
</SettingsSwitchContent>
<FormControl>
<Switch
checked={Boolean(field.value)}
onCheckedChange={field.onChange}
disabled={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
!form.watch(`${sectionKey}.enabled` as any)
}
/>
</FormControl>
</SettingsSwitchItem>
)}
/>
)
})}
</SettingsControlChildren>
</SettingsControlGroup>
)
})}
</SettingsForm>
</Form>
</SettingsSection>
)
}
@@ -0,0 +1,189 @@
/*
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 { ExternalLinkIcon, RefreshCcwIcon } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Dialog } from '@/components/dialog'
import { Button } from '@/components/ui/button'
import { Markdown } from '@/components/ui/markdown'
import { formatTimestamp, formatTimestampToDate } from '@/lib/format'
import { SettingsSection } from '../components/settings-section'
type ReleaseInfo = {
tag_name: string
name?: string
body?: string
html_url?: string
published_at?: string
}
type UpdateCheckerSectionProps = {
currentVersion?: string | null
startTime?: number | null
}
export function UpdateCheckerSection({
currentVersion,
startTime,
}: UpdateCheckerSectionProps) {
const { t } = useTranslation()
const [checking, setChecking] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [release, setRelease] = useState<ReleaseInfo | null>(null)
const uptime = startTime ? formatTimestamp(startTime) : t('Unknown')
const version = currentVersion || t('Unknown')
const handleCheckUpdates = async () => {
setChecking(true)
try {
const response = await fetch(
'https://api.github.com/repos/Calcium-Ion/new-api/releases/latest',
{
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'new-api-dashboard',
},
}
)
if (!response.ok) {
throw new Error(t('Failed to contact GitHub releases API'))
}
const data = (await response.json()) as ReleaseInfo
if (!data?.tag_name) {
throw new Error(t('Unexpected release payload'))
}
if (currentVersion && data.tag_name === currentVersion) {
toast.success(
t('You are running the latest version ({{version}}).', {
version: data.tag_name,
})
)
return
}
setRelease(data)
setDialogOpen(true)
} catch (error) {
const message =
error instanceof Error
? error.message
: t('Failed to check for updates')
toast.error(message)
} finally {
setChecking(false)
}
}
const goToRelease = () => {
if (release?.html_url) {
window.open(release.html_url, '_blank', 'noopener,noreferrer')
}
}
return (
<>
<SettingsSection title={t('System maintenance')}>
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>
{t('Current version')}
</div>
<div className='text-lg font-semibold'>{version}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>
{t('Uptime since')}
</div>
<div className='text-lg font-semibold'>{uptime}</div>
</div>
</div>
<Button onClick={handleCheckUpdates} disabled={checking}>
{checking ? (
t('Checking updates...')
) : (
<>
<RefreshCcwIcon className='me-2 h-4 w-4' />
{t('Check for updates')}
</>
)}
</Button>
</div>
</SettingsSection>
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={
release?.tag_name
? t('New version available: {{version}}', {
version: release.tag_name,
})
: t('Release details')
}
description={
release?.published_at
? `${t('Published')} ${formatTimestampToDate(
new Date(release.published_at).getTime(),
'milliseconds'
)}`
: undefined
}
contentClassName='max-h-[80vh] overflow-y-auto'
contentHeight='auto'
bodyClassName='space-y-4'
footer={
<>
<Button
type='button'
variant='secondary'
onClick={() => setDialogOpen(false)}
>
{t('Close')}
</Button>
{release?.html_url && (
<Button type='button' onClick={goToRelease}>
<ExternalLinkIcon className='me-2 h-4 w-4' />
{t('Open release')}
</Button>
)}
</>
}
>
<div className='space-y-4'>
{release?.body ? (
<Markdown>{release.body}</Markdown>
) : (
<p className='text-muted-foreground text-sm'>
{t('No release notes provided.')}
</p>
)}
</div>
</Dialog>
</>
)
}