Files
OpenFlare/frontend/app/(main)/performance/page.tsx
T
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00

506 lines
16 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { useAuth } from '@/components/providers/auth-provider';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { OptionService } from '@/lib/services/openflare';
import {
defaultPerformanceFields,
entriesFromKeys,
mapOptionsToFields,
optionsToMap,
type PerformanceFields,
validateCacheFields,
validateGzipFields,
validateProxyFields,
validateRuntimeFields,
} from './components/performance-utils';
const optionsQueryKey = ['openflare', 'options'] as const;
export default function PerformancePage() {
const t = useTranslations('performance');
const tc = useTranslations('common');
const { user, loading: authLoading } = useAuth();
const queryClient = useQueryClient();
const [fields, setFields] = useState<PerformanceFields>(
defaultPerformanceFields,
);
const [savingSection, setSavingSection] = useState<string | null>(null);
const optionsQuery = useQuery({
queryKey: optionsQueryKey,
queryFn: () => OptionService.list(),
enabled: !!user?.is_admin,
});
useEffect(() => {
if (!optionsQuery.data) return;
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)));
}, [optionsQuery.data]);
const saveMutation = useMutation({
mutationFn: async ({
section,
entries,
validator,
}: {
section: string;
entries: Array<{ key: string; value: string }>;
validator?: (fields: PerformanceFields) => void;
}) => {
validator?.(fields);
setSavingSection(section);
await OptionService.updateBatch(entries);
},
onSuccess: async () => {
toast.success(t('saved'));
await Promise.all([
queryClient.invalidateQueries({ queryKey: optionsQueryKey }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-preview'],
}),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions'],
}),
]);
setSavingSection(null);
},
onError: (error) => {
setSavingSection(null);
toast.error(error instanceof Error ? error.message : t('saveFailed'));
},
});
const updateField = <K extends keyof PerformanceFields>(
key: K,
value: PerformanceFields[K],
) => {
setFields((prev) => ({ ...prev, [key]: value }));
};
if (authLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description={t('loadingAuth')} />
</div>
);
}
if (!user?.is_admin) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Gauge}
title={t('forbiddenTitle')}
description={t('forbiddenDesc')}
/>
</div>
);
}
if (optionsQuery.isLoading) {
return (
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Gauge} description={t('loading')} />
</div>
);
}
if (optionsQuery.isError) {
return (
<div className='py-6 px-1'>
<ErrorInline
message={
optionsQuery.error instanceof Error
? optionsQuery.error.message
: tc('loadFailed')
}
onRetry={() => void optionsQuery.refetch()}
/>
</div>
);
}
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='flex items-center gap-2'>
<Gauge className='size-5 text-primary' />
<div>
<h1 className='text-2xl font-semibold tracking-tight'>
{t('title')}
</h1>
<p className='text-sm text-muted-foreground'>{t('description')}</p>
</div>
</div>
<Button variant='outline' size='sm' asChild>
<Link href='/config-versions'>
<ExternalLink className='size-3.5 mr-1' />
{t('preview')}
</Link>
</Button>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>{t('runtimeTitle')}</CardTitle>
<CardDescription>{t('runtimeDesc')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'runtime'}
onClick={() =>
saveMutation.mutate({
section: 'runtime',
validator: (fields) => validateRuntimeFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_default_server_return_status',
'openresty_worker_processes',
'openresty_resolvers',
'openresty_worker_connections',
'openresty_worker_rlimit_nofile',
'openresty_events_use',
'openresty_events_multi_accept_enabled',
'openresty_keepalive_timeout',
'openresty_keepalive_requests',
'openresty_client_header_timeout',
'openresty_client_body_timeout',
'openresty_client_max_body_size',
'openresty_large_client_header_buffers',
'openresty_send_timeout',
]),
})
}
>
{savingSection === 'runtime' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
{tc('save')}
</Button>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<FieldInput
label='worker_processes'
value={fields.openresty_worker_processes}
onChange={(v) => updateField('openresty_worker_processes', v)}
/>
<FieldInput
label='worker_connections'
value={fields.openresty_worker_connections}
onChange={(v) => updateField('openresty_worker_connections', v)}
type='number'
/>
<FieldInput
label='worker_rlimit_nofile'
value={fields.openresty_worker_rlimit_nofile}
onChange={(v) => updateField('openresty_worker_rlimit_nofile', v)}
type='number'
/>
<div className='space-y-1.5'>
<Label>events use</Label>
<Select
value={fields.openresty_events_use}
onValueChange={(v) => updateField('openresty_events_use', v)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='epoll'>epoll</SelectItem>
<SelectItem value='kqueue'>kqueue</SelectItem>
<SelectItem value='poll'>poll</SelectItem>
<SelectItem value='select'>select</SelectItem>
</SelectContent>
</Select>
</div>
<ToggleRow
label='multi_accept'
checked={fields.openresty_events_multi_accept_enabled}
onChange={(v) =>
updateField('openresty_events_multi_accept_enabled', v)
}
/>
<FieldInput
label='keepalive_timeout'
value={fields.openresty_keepalive_timeout}
onChange={(v) => updateField('openresty_keepalive_timeout', v)}
type='number'
/>
<FieldInput
label='client_max_body_size'
value={fields.openresty_client_max_body_size}
onChange={(v) => updateField('openresty_client_max_body_size', v)}
/>
<FieldInput
label='resolvers'
value={fields.openresty_resolvers}
onChange={(v) => updateField('openresty_resolvers', v)}
placeholder='1.1.1.1 8.8.8.8'
/>
<FieldInput
label='default_server_return_status'
value={fields.openresty_default_server_return_status}
onChange={(v) =>
updateField('openresty_default_server_return_status', v)
}
type='number'
/>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>{t('proxyTitle')}</CardTitle>
<CardDescription>{t('proxyDesc')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'proxy'}
onClick={() =>
saveMutation.mutate({
section: 'proxy',
validator: (fields) => validateProxyFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_proxy_connect_timeout',
'openresty_proxy_send_timeout',
'openresty_proxy_read_timeout',
'openresty_websocket_enabled',
'openresty_http3_enabled',
'openresty_proxy_request_buffering_enabled',
'openresty_proxy_buffering_enabled',
'openresty_proxy_buffers',
'openresty_proxy_buffer_size',
'openresty_proxy_busy_buffers_size',
]),
})
}
>
{savingSection === 'proxy' ? (
<Loader2 className='size-4 animate-spin mr-1' />
) : (
<Save className='size-3.5 mr-1' />
)}
{tc('save')}
</Button>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2'>
<FieldInput
label='proxy_connect_timeout'
value={fields.openresty_proxy_connect_timeout}
onChange={(v) => updateField('openresty_proxy_connect_timeout', v)}
type='number'
/>
<FieldInput
label='proxy_read_timeout'
value={fields.openresty_proxy_read_timeout}
onChange={(v) => updateField('openresty_proxy_read_timeout', v)}
type='number'
/>
<ToggleRow
label='websocket'
checked={fields.openresty_websocket_enabled}
onChange={(v) => updateField('openresty_websocket_enabled', v)}
/>
<ToggleRow
label='http3'
checked={fields.openresty_http3_enabled}
onChange={(v) => updateField('openresty_http3_enabled', v)}
/>
<ToggleRow
label='proxy_buffering'
checked={fields.openresty_proxy_buffering_enabled}
onChange={(v) =>
updateField('openresty_proxy_buffering_enabled', v)
}
/>
<FieldInput
label='proxy_buffers'
value={fields.openresty_proxy_buffers}
onChange={(v) => updateField('openresty_proxy_buffers', v)}
/>
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='text-base'>{t('gzipTitle')}</CardTitle>
<Button
size='sm'
disabled={savingSection === 'gzip'}
onClick={() =>
saveMutation.mutate({
section: 'gzip',
validator: (fields) => validateGzipFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_gzip_enabled',
'openresty_gzip_min_length',
'openresty_gzip_comp_level',
]),
})
}
>
{tc('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<ToggleRow
label='gzip'
checked={fields.openresty_gzip_enabled}
onChange={(v) => updateField('openresty_gzip_enabled', v)}
/>
<FieldInput
label='gzip_min_length'
value={fields.openresty_gzip_min_length}
onChange={(v) => updateField('openresty_gzip_min_length', v)}
type='number'
/>
<FieldInput
label='gzip_comp_level'
value={fields.openresty_gzip_comp_level}
onChange={(v) => updateField('openresty_gzip_comp_level', v)}
type='number'
/>
</CardContent>
</Card>
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between'>
<div>
<CardTitle className='text-base'>{t('cacheTitle')}</CardTitle>
<CardDescription>{t('cacheDesc')}</CardDescription>
</div>
<Button
size='sm'
disabled={savingSection === 'cache'}
onClick={() =>
saveMutation.mutate({
section: 'cache',
validator: (fields) => validateCacheFields(fields, t),
entries: entriesFromKeys(fields, [
'openresty_cache_enabled',
'openresty_cache_path',
'openresty_cache_levels',
'openresty_cache_inactive',
'openresty_cache_max_size',
'openresty_cache_key_template',
'openresty_cache_lock_enabled',
'openresty_cache_lock_timeout',
'openresty_cache_use_stale',
]),
})
}
>
{tc('save')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
<ToggleRow
label='cache_enabled'
checked={fields.openresty_cache_enabled}
onChange={(v) => updateField('openresty_cache_enabled', v)}
/>
<FieldInput
label='proxy_cache_path'
value={fields.openresty_cache_path}
onChange={(v) => updateField('openresty_cache_path', v)}
disabled={!fields.openresty_cache_enabled}
/>
<FieldInput
label='levels'
value={fields.openresty_cache_levels}
onChange={(v) => updateField('openresty_cache_levels', v)}
disabled={!fields.openresty_cache_enabled}
/>
<FieldInput
label='max_size'
value={fields.openresty_cache_max_size}
onChange={(v) => updateField('openresty_cache_max_size', v)}
disabled={!fields.openresty_cache_enabled}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function FieldInput({
label,
value,
onChange,
type = 'text',
placeholder,
disabled,
}: {
label: string;
value: string;
onChange: (value: string) => void;
type?: string;
placeholder?: string;
disabled?: boolean;
}) {
return (
<div className='space-y-1.5'>
<Label className='text-xs text-muted-foreground'>{label}</Label>
<Input
type={type}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className='h-9 text-xs'
/>
</div>
);
}
function ToggleRow({
label,
checked,
onChange,
}: {
label: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className='flex items-center justify-between rounded-lg border border-dashed px-3 py-2'>
<Label className='text-xs'>{label}</Label>
<Switch checked={checked} onCheckedChange={onChange} />
</div>
);
}