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:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user