feat: split openresty settings into performance page

This commit is contained in:
ryan
2026-03-12 23:51:57 +08:00
parent 97b67720bc
commit 238546b358
16 changed files with 1562 additions and 1031 deletions
@@ -0,0 +1,5 @@
import { PerformancePage } from '@/features/performance/components/performance-page';
export default function PerformanceRoute() {
return <PerformancePage />;
}
@@ -1,252 +1,270 @@
'use client';
import {useEffect} from 'react';
import { useEffect } from 'react';
import Link from 'next/link';
import {usePathname} from 'next/navigation';
import { usePathname } from 'next/navigation';
import {dashboardNavigation} from '@/lib/constants/navigation';
import {cn} from '@/lib/utils/cn';
import {isNavigationItemActive} from '@/lib/utils/navigation';
import {useAppShellStore} from '@/store/app-shell';
import type {NavigationIconKey, NavigationItem} from '@/types/navigation';
import { dashboardNavigation } from '@/lib/constants/navigation';
import { cn } from '@/lib/utils/cn';
import { isNavigationItemActive } from '@/lib/utils/navigation';
import { useAppShellStore } from '@/store/app-shell';
import type { NavigationIconKey, NavigationItem } from '@/types/navigation';
function SidebarIcon({icon}: { icon: NavigationIconKey }) {
const commonProps = {
className: 'h-[18px] w-[18px]',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
viewBox: '0 0 24 24',
};
function SidebarIcon({ icon }: { icon: NavigationIconKey }) {
const commonProps = {
className: 'h-[18px] w-[18px]',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.8,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
viewBox: '0 0 24 24',
};
switch (icon) {
case 'home':
return (
<svg {...commonProps}>
<path d='M3 10.5 12 3l9 7.5'/>
<path d='M5.5 9.5V21h13V9.5'/>
<path d='M9.5 21v-6h5v6'/>
</svg>
);
case 'node':
return (
<svg {...commonProps}>
<rect x='4' y='4' width='6' height='6' rx='1.5'/>
<rect x='14' y='4' width='6' height='6' rx='1.5'/>
<rect x='9' y='14' width='6' height='6' rx='1.5'/>
<path d='M10 7h4M12 10v4'/>
</svg>
);
case 'website':
return (
<svg {...commonProps}>
<circle cx='12' cy='12' r='8.5'/>
<path d='M3.5 12h17'/>
<path
d='M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z'/>
</svg>
);
case 'domain':
return (
<svg {...commonProps}>
<path d='M4 8.5h16'/>
<path d='M4 15.5h16'/>
<rect x='3.5' y='5' width='17' height='14' rx='3'/>
</svg>
);
case 'certificate':
return (
<svg {...commonProps}>
<path d='M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z'/>
<path d='M9 10h6'/>
<path d='M10 14h4'/>
</svg>
);
case 'proxy':
return (
<svg {...commonProps}>
<path d='M7 7h10'/>
<path d='M7 12h10'/>
<path d='M7 17h6'/>
<path d='m14 15 3 2-3 2'/>
</svg>
);
case 'release':
return (
<svg {...commonProps}>
<path d='M12 4v10'/>
<path d='m8.5 7.5 3.5-3.5 3.5 3.5'/>
<path d='M5 19.5h14'/>
</svg>
);
case 'user':
return (
<svg {...commonProps}>
<circle cx='12' cy='8' r='3.25'/>
<path d='M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5'/>
</svg>
);
case 'setting':
return (
<svg {...commonProps}>
<circle cx='12' cy='12' r='3.5'/>
<path
d='M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9'/>
</svg>
);
}
switch (icon) {
case 'home':
return (
<svg {...commonProps}>
<path d="M3 10.5 12 3l9 7.5" />
<path d="M5.5 9.5V21h13V9.5" />
<path d="M9.5 21v-6h5v6" />
</svg>
);
case 'node':
return (
<svg {...commonProps}>
<rect x="4" y="4" width="6" height="6" rx="1.5" />
<rect x="14" y="4" width="6" height="6" rx="1.5" />
<rect x="9" y="14" width="6" height="6" rx="1.5" />
<path d="M10 7h4M12 10v4" />
</svg>
);
case 'website':
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="8.5" />
<path d="M3.5 12h17" />
<path d="M12 3.5c2.4 2.2 3.8 5.2 3.8 8.5S14.4 18.3 12 20.5C9.6 18.3 8.2 15.3 8.2 12S9.6 5.7 12 3.5Z" />
</svg>
);
case 'domain':
return (
<svg {...commonProps}>
<path d="M4 8.5h16" />
<path d="M4 15.5h16" />
<rect x="3.5" y="5" width="17" height="14" rx="3" />
</svg>
);
case 'certificate':
return (
<svg {...commonProps}>
<path d="M7 4.5h10l2 2V14a7 7 0 1 1-14 0V4.5Z" />
<path d="M9 10h6" />
<path d="M10 14h4" />
</svg>
);
case 'proxy':
return (
<svg {...commonProps}>
<path d="M7 7h10" />
<path d="M7 12h10" />
<path d="M7 17h6" />
<path d="m14 15 3 2-3 2" />
</svg>
);
case 'release':
return (
<svg {...commonProps}>
<path d="M12 4v10" />
<path d="m8.5 7.5 3.5-3.5 3.5 3.5" />
<path d="M5 19.5h14" />
</svg>
);
case 'performance':
return (
<svg {...commonProps}>
<path d="M4.5 19.5h15" />
<path d="M7 16l3-4 3 2 4-6" />
<path d="m15.5 8 1.5 0 0 1.5" />
</svg>
);
case 'user':
return (
<svg {...commonProps}>
<circle cx="12" cy="8" r="3.25" />
<path d="M5 19.5c1.7-3 4.1-4.5 7-4.5s5.3 1.5 7 4.5" />
</svg>
);
case 'setting':
return (
<svg {...commonProps}>
<circle cx="12" cy="12" r="3.5" />
<path d="M12 3.5v2.2M12 18.3v2.2M20.5 12h-2.2M5.7 12H3.5M18.1 5.9l-1.6 1.6M7.5 16.5l-1.6 1.6M18.1 18.1l-1.6-1.6M7.5 7.5 5.9 5.9" />
</svg>
);
}
}
function SidebarNavItem({
item,
currentPath,
isSidebarCollapsed,
forceExpanded,
onNavigate,
depth = 0,
}: {
item: NavigationItem;
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
depth?: number;
item,
currentPath,
isSidebarCollapsed,
forceExpanded,
onNavigate,
depth = 0,
}: {
item: NavigationItem;
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
depth?: number;
}) {
const active = isNavigationItemActive(currentPath, item);
const hasChildren = Boolean(item.children?.length);
const showLabel = forceExpanded || !isSidebarCollapsed;
const active = isNavigationItemActive(currentPath, item);
const hasChildren = Boolean(item.children?.length);
const showLabel = forceExpanded || !isSidebarCollapsed;
return (
<div className='space-y-2'>
<Link
href={item.href}
onClick={onNavigate}
className={cn(
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
depth > 0 && 'ml-3 rounded-xl',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span
className='flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]'>
<SidebarIcon icon={item.icon}/>
return (
<div className="space-y-2">
<Link
href={item.href}
onClick={onNavigate}
className={cn(
'flex min-h-[50px] items-center gap-3 rounded-2xl border px-3 py-2.5 transition-colors',
depth > 0 && 'ml-3 rounded-xl',
active
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-[var(--surface-muted)] hover:text-[var(--foreground-primary)]',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--control-background)] text-[var(--foreground-primary)]">
<SidebarIcon icon={item.icon} />
</span>
{showLabel ? <span className='min-w-0 flex-1 text-sm font-medium'>{item.label}</span> : null}
</Link>
{showLabel && hasChildren ? (
<div className='space-y-2'>
{item.children?.map((child) => (
<SidebarNavItem
key={child.href}
item={child}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</div>
) : null}
{showLabel ? (
<span className="min-w-0 flex-1 text-sm font-medium">
{item.label}
</span>
) : null}
</Link>
{showLabel && hasChildren ? (
<div className="space-y-2">
{item.children?.map((child) => (
<SidebarNavItem
key={child.href}
item={child}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
depth={depth + 1}
/>
))}
</div>
);
) : null}
</div>
);
}
function SidebarContent({
currentPath,
isSidebarCollapsed,
forceExpanded = false,
onNavigate,
}: {
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
currentPath,
isSidebarCollapsed,
forceExpanded = false,
onNavigate,
}: {
currentPath: string;
isSidebarCollapsed: boolean;
forceExpanded?: boolean;
onNavigate?: () => void;
}) {
const showLabel = forceExpanded || !isSidebarCollapsed;
const showLabel = forceExpanded || !isSidebarCollapsed;
return (
<div className='flex h-full flex-col gap-5'>
<div
className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3'>
<div
className='flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]'>
AF
</div>
{showLabel ? (
<div>
<p className='text-sm font-semibold text-[var(--foreground-primary)]'>ATSFlare</p>
<p className='text-xs text-[var(--foreground-secondary)]'>控制面</p>
</div>
) : null}
</div>
<nav className='flex-1 space-y-2'>
<div className='flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1'>
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</div>
</nav>
return (
<div className="flex h-full flex-col gap-5">
<div className="flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-3 py-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--brand-primary-soft)] text-sm font-semibold text-[var(--brand-primary)]">
AF
</div>
);
{showLabel ? (
<div>
<p className="text-sm font-semibold text-[var(--foreground-primary)]">
ATSFlare
</p>
<p className="text-xs text-[var(--foreground-secondary)]">控制面</p>
</div>
) : null}
</div>
<nav className="flex-1 space-y-2">
<div className="flex max-h-full min-h-0 flex-col gap-2 overflow-y-auto pr-1">
{dashboardNavigation.map((item) => (
<SidebarNavItem
key={item.href}
item={item}
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
forceExpanded={forceExpanded}
onNavigate={onNavigate}
/>
))}
</div>
</nav>
</div>
);
}
export function DashboardSidebar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
const isMobileSidebarOpen = useAppShellStore((state) => state.isMobileSidebarOpen);
const setMobileSidebarOpen = useAppShellStore((state) => state.setMobileSidebarOpen);
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore(
(state) => state.isSidebarCollapsed,
);
const isMobileSidebarOpen = useAppShellStore(
(state) => state.isMobileSidebarOpen,
);
const setMobileSidebarOpen = useAppShellStore(
(state) => state.setMobileSidebarOpen,
);
useEffect(() => {
setMobileSidebarOpen(false);
}, [currentPath, setMobileSidebarOpen]);
useEffect(() => {
setMobileSidebarOpen(false);
}, [currentPath, setMobileSidebarOpen]);
return (
<>
<div
className={cn(
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
onClick={() => setMobileSidebarOpen(false)}
aria-hidden='true'
/>
return (
<>
<div
className={cn(
'fixed inset-0 z-30 bg-black/35 transition-opacity duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
onClick={() => setMobileSidebarOpen(false)}
aria-hidden="true"
/>
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={false}
forceExpanded
onNavigate={() => setMobileSidebarOpen(false)}
/>
</aside>
<aside
className={cn(
'fixed top-0 left-0 z-40 h-screen w-[200px] overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur transition-transform duration-200 min-[1000px]:hidden',
isMobileSidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={false}
forceExpanded
onNavigate={() => setMobileSidebarOpen(false)}
/>
</aside>
<aside
className={cn(
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
)}
>
<SidebarContent currentPath={currentPath} isSidebarCollapsed={isSidebarCollapsed}/>
</aside>
</>
);
<aside
className={cn(
'sticky top-0 z-10 hidden h-screen shrink-0 overflow-hidden border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-5 backdrop-blur min-[1000px]:block',
isSidebarCollapsed ? 'w-[76px]' : 'w-[200px]',
)}
>
<SidebarContent
currentPath={currentPath}
isSidebarCollapsed={isSidebarCollapsed}
/>
</aside>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -35,7 +35,6 @@ import {
PrimaryButton,
ResourceField,
ResourceInput,
ResourceSelect,
ResourceTextarea,
SecondaryButton,
ToggleField,
@@ -201,27 +200,6 @@ function formatSecondsLabel(value: string) {
return `${seconds} 秒`;
}
function isPositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return !Number.isNaN(parsed) && parsed > 0;
}
function isSizeValue(value: string) {
return /^\d+[kKmMgG]?$/.test(value.trim());
}
function isProxyBuffersValue(value: string) {
return /^\d+\s+\d+[kKmMgG]?$/.test(value.trim());
}
function isDurationToken(value: string) {
return /^\d+[smhdwSMHDW]$/.test(value.trim());
}
function isCacheLevelsValue(value: string) {
return /^\d{1,2}(?::\d{1,2}){0,2}$/.test(value.trim());
}
function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) {
return [
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
@@ -487,7 +465,7 @@ export function SettingsPage() {
{
key: 'system' as const,
label: '系统设置',
description: '登录注册、SMTP、OAuth 与风控开关。',
description: '登录注册、SMTP、OAuth、限流与风控开关。',
},
{
key: 'other' as const,
@@ -1087,247 +1065,21 @@ export function SettingsPage() {
</ResourceField>
</AppCard>
<AppCard
title="OpenResty 连接与事件"
description="第五版第一批结构化性能项。保存后进入统一发布链路,不会在节点即时生效。"
title="性能页入口"
description="OpenResty 连接、缓冲、压缩、缓存以及主配置模板编辑已迁移到侧边栏“性能”页面。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction(
'operation-openresty-runtime',
async () => {
if (
operationFields.OpenRestyWorkerProcesses !== 'auto' &&
!isPositiveInteger(
operationFields.OpenRestyWorkerProcesses,
)
) {
throw new Error(
'worker_processes 必须为 auto 或大于 0 的整数。',
);
}
const integerFields = [
[
'worker_connections',
operationFields.OpenRestyWorkerConnections,
],
[
'worker_rlimit_nofile',
operationFields.OpenRestyWorkerRlimitNofile,
],
[
'keepalive_timeout',
operationFields.OpenRestyKeepaliveTimeout,
],
[
'keepalive_requests',
operationFields.OpenRestyKeepaliveRequests,
],
[
'client_header_timeout',
operationFields.OpenRestyClientHeaderTimeout,
],
[
'client_body_timeout',
operationFields.OpenRestyClientBodyTimeout,
],
[
'send_timeout',
operationFields.OpenRestySendTimeout,
],
] as const;
for (const [label, value] of integerFields) {
if (!isPositiveInteger(value)) {
throw new Error(`${label} 必须为大于 0 的整数。`);
}
}
await saveOptionEntries(
[
[
'OpenRestyWorkerProcesses',
operationFields.OpenRestyWorkerProcesses.trim(),
],
[
'OpenRestyWorkerConnections',
operationFields.OpenRestyWorkerConnections.trim(),
],
[
'OpenRestyWorkerRlimitNofile',
operationFields.OpenRestyWorkerRlimitNofile.trim(),
],
[
'OpenRestyEventsUse',
operationFields.OpenRestyEventsUse.trim(),
],
[
'OpenRestyEventsMultiAcceptEnabled',
String(
operationFields.OpenRestyEventsMultiAcceptEnabled,
),
],
[
'OpenRestyKeepaliveTimeout',
operationFields.OpenRestyKeepaliveTimeout.trim(),
],
[
'OpenRestyKeepaliveRequests',
operationFields.OpenRestyKeepaliveRequests.trim(),
],
[
'OpenRestyClientHeaderTimeout',
operationFields.OpenRestyClientHeaderTimeout.trim(),
],
[
'OpenRestyClientBodyTimeout',
operationFields.OpenRestyClientBodyTimeout.trim(),
],
[
'OpenRestySendTimeout',
operationFields.OpenRestySendTimeout.trim(),
],
],
'OpenResty 运行参数已保存。',
);
},
)
}
disabled={busyKey === 'operation-openresty-runtime'}
onClick={() => window.location.assign('/performance')}
>
{busyKey === 'operation-openresty-runtime'
? '保存中...'
: '保存运行调优'}
打开性能页
</PrimaryButton>
}
>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<ResourceField label="worker_processes">
<ResourceInput
value={operationFields.OpenRestyWorkerProcesses}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyWorkerProcesses: event.target.value,
}))
}
placeholder="auto"
/>
</ResourceField>
<ResourceField label="worker_connections">
<ResourceInput
type="number"
value={operationFields.OpenRestyWorkerConnections}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyWorkerConnections: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="worker_rlimit_nofile">
<ResourceInput
type="number"
value={operationFields.OpenRestyWorkerRlimitNofile}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyWorkerRlimitNofile: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="events use" hint="留空表示不显式渲染。">
<ResourceSelect
value={operationFields.OpenRestyEventsUse}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyEventsUse: event.target.value,
}))
}
>
<option value="">默认</option>
<option value="epoll">epoll</option>
<option value="kqueue">kqueue</option>
<option value="poll">poll</option>
<option value="select">select</option>
</ResourceSelect>
</ResourceField>
<ToggleField
label="multi_accept"
description="是否启用 multi_accept on。"
checked={operationFields.OpenRestyEventsMultiAcceptEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
OpenRestyEventsMultiAcceptEnabled: checked,
}))
}
/>
<ResourceField label="keepalive_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyKeepaliveTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyKeepaliveTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="keepalive_requests">
<ResourceInput
type="number"
value={operationFields.OpenRestyKeepaliveRequests}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyKeepaliveRequests: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="client_header_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyClientHeaderTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyClientHeaderTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="client_body_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyClientBodyTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyClientBodyTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="send_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestySendTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestySendTimeout: event.target.value,
}))
}
/>
</ResourceField>
</div>
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
设置页保留 Agent、系统、限流和品牌配置;OpenResty
相关项已拆分为独立工作台,支持“设置 / 编辑”双 Tab 操作。
</p>
</AppCard>
<AppCard
title="Discovery Token 与部署命令"
@@ -1775,462 +1527,23 @@ export function SettingsPage() {
</div>
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
<AppCard
title="OpenResty 反代缓冲与超时"
description="用于控制 upstream 连接、发送、读取超时,以及常用代理缓冲参数。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction(
'operation-openresty-proxy',
async () => {
const timeoutFields = [
operationFields.OpenRestyProxyConnectTimeout,
operationFields.OpenRestyProxySendTimeout,
operationFields.OpenRestyProxyReadTimeout,
];
for (const value of timeoutFields) {
if (!isPositiveInteger(value)) {
throw new Error(
'代理超时参数必须为大于 0 的整数秒。',
);
}
}
if (
!isProxyBuffersValue(
operationFields.OpenRestyProxyBuffers,
)
) {
throw new Error(
'proxy_buffers 格式必须类似 "16 16k"。',
);
}
if (
!isSizeValue(
operationFields.OpenRestyProxyBufferSize,
) ||
!isSizeValue(
operationFields.OpenRestyProxyBusyBuffersSize,
)
) {
throw new Error(
'缓冲大小必须为整数或带 k/m/g 单位的值。',
);
}
await saveOptionEntries(
[
[
'OpenRestyProxyConnectTimeout',
operationFields.OpenRestyProxyConnectTimeout.trim(),
],
[
'OpenRestyProxySendTimeout',
operationFields.OpenRestyProxySendTimeout.trim(),
],
[
'OpenRestyProxyReadTimeout',
operationFields.OpenRestyProxyReadTimeout.trim(),
],
[
'OpenRestyProxyBufferingEnabled',
String(
operationFields.OpenRestyProxyBufferingEnabled,
),
],
[
'OpenRestyProxyBuffers',
operationFields.OpenRestyProxyBuffers.trim(),
],
[
'OpenRestyProxyBufferSize',
operationFields.OpenRestyProxyBufferSize.trim(),
],
[
'OpenRestyProxyBusyBuffersSize',
operationFields.OpenRestyProxyBusyBuffersSize.trim(),
],
],
'OpenResty 反代缓冲参数已保存。',
);
},
)
}
disabled={busyKey === 'operation-openresty-proxy'}
>
{busyKey === 'operation-openresty-proxy'
? '保存中...'
: '保存代理调优'}
</PrimaryButton>
}
>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="proxy_connect_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyProxyConnectTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyConnectTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="proxy_send_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyProxySendTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxySendTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="proxy_read_timeout (秒)">
<ResourceInput
type="number"
value={operationFields.OpenRestyProxyReadTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyReadTimeout: event.target.value,
}))
}
/>
</ResourceField>
<ToggleField
label="proxy_buffering"
description="是否启用 proxy_buffering。"
checked={operationFields.OpenRestyProxyBufferingEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyBufferingEnabled: checked,
}))
}
/>
<ResourceField label="proxy_buffers">
<ResourceInput
value={operationFields.OpenRestyProxyBuffers}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyBuffers: event.target.value,
}))
}
placeholder="16 16k"
/>
</ResourceField>
<ResourceField label="proxy_buffer_size">
<ResourceInput
value={operationFields.OpenRestyProxyBufferSize}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyBufferSize: event.target.value,
}))
}
placeholder="8k"
/>
</ResourceField>
<ResourceField label="proxy_busy_buffers_size">
<ResourceInput
value={operationFields.OpenRestyProxyBusyBuffersSize}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyProxyBusyBuffersSize: event.target.value,
}))
}
placeholder="64k"
/>
</ResourceField>
</div>
</AppCard>
<AppCard
title="OpenResty 压缩与缓存"
description="缓存能力仍限定在单节点反代优化场景,不扩展为独立缓存产品。"
action={
<PrimaryButton
type="button"
onClick={() =>
void runBusyAction(
'operation-openresty-cache',
async () => {
if (
!isPositiveInteger(
operationFields.OpenRestyGzipMinLength,
)
) {
throw new Error(
'gzip_min_length 必须为大于 0 的整数。',
);
}
const gzipLevel = Number.parseInt(
operationFields.OpenRestyGzipCompLevel,
10,
);
if (
Number.isNaN(gzipLevel) ||
gzipLevel < 1 ||
gzipLevel > 9
) {
throw new Error(
'gzip_comp_level 必须在 1 到 9 之间。',
);
}
if (operationFields.OpenRestyCacheEnabled) {
if (!operationFields.OpenRestyCachePath.trim()) {
throw new Error(
'启用缓存时必须填写 proxy_cache_path 目录。',
);
}
if (
!isCacheLevelsValue(
operationFields.OpenRestyCacheLevels,
) ||
!isDurationToken(
operationFields.OpenRestyCacheInactive,
) ||
!isSizeValue(
operationFields.OpenRestyCacheMaxSize,
) ||
!isDurationToken(
operationFields.OpenRestyCacheLockTimeout,
)
) {
throw new Error(
'缓存 levels、inactive、max_size 或 lock_timeout 格式不合法。',
);
}
if (
!operationFields.OpenRestyCacheKeyTemplate.trim()
) {
throw new Error(
'启用缓存时必须填写缓存 Key 模板。',
);
}
}
await saveOptionEntries(
[
[
'OpenRestyGzipEnabled',
String(operationFields.OpenRestyGzipEnabled),
],
[
'OpenRestyGzipMinLength',
operationFields.OpenRestyGzipMinLength.trim(),
],
[
'OpenRestyGzipCompLevel',
operationFields.OpenRestyGzipCompLevel.trim(),
],
[
'OpenRestyCachePath',
operationFields.OpenRestyCachePath.trim(),
],
[
'OpenRestyCacheLevels',
operationFields.OpenRestyCacheLevels.trim(),
],
[
'OpenRestyCacheInactive',
operationFields.OpenRestyCacheInactive.trim(),
],
[
'OpenRestyCacheMaxSize',
operationFields.OpenRestyCacheMaxSize.trim(),
],
[
'OpenRestyCacheKeyTemplate',
operationFields.OpenRestyCacheKeyTemplate.trim(),
],
[
'OpenRestyCacheLockEnabled',
String(operationFields.OpenRestyCacheLockEnabled),
],
[
'OpenRestyCacheLockTimeout',
operationFields.OpenRestyCacheLockTimeout.trim(),
],
[
'OpenRestyCacheUseStale',
operationFields.OpenRestyCacheUseStale.trim(),
],
[
'OpenRestyCacheEnabled',
String(operationFields.OpenRestyCacheEnabled),
],
],
'OpenResty 压缩与缓存参数已保存。',
);
},
)
}
disabled={busyKey === 'operation-openresty-cache'}
>
{busyKey === 'operation-openresty-cache'
? '保存中...'
: '保存压缩与缓存'}
</PrimaryButton>
}
>
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<ToggleField
label="gzip"
description="是否启用 gzip on。"
checked={operationFields.OpenRestyGzipEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
OpenRestyGzipEnabled: checked,
}))
}
/>
<ToggleField
label="proxy cache"
description="是否启用单节点代理缓存。"
checked={operationFields.OpenRestyCacheEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheEnabled: checked,
}))
}
/>
<ResourceField label="gzip_min_length">
<ResourceInput
type="number"
value={operationFields.OpenRestyGzipMinLength}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyGzipMinLength: event.target.value,
}))
}
/>
</ResourceField>
<ResourceField label="gzip_comp_level">
<ResourceInput
type="number"
min="1"
max="9"
value={operationFields.OpenRestyGzipCompLevel}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyGzipCompLevel: event.target.value,
}))
}
/>
</ResourceField>
</div>
<div className="grid gap-5 md:grid-cols-2">
<ResourceField label="proxy_cache_path">
<ResourceInput
value={operationFields.OpenRestyCachePath}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCachePath: event.target.value,
}))
}
placeholder="/var/cache/openresty/atsflare"
/>
</ResourceField>
<ResourceField label="levels">
<ResourceInput
value={operationFields.OpenRestyCacheLevels}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheLevels: event.target.value,
}))
}
placeholder="1:2"
/>
</ResourceField>
<ResourceField label="inactive">
<ResourceInput
value={operationFields.OpenRestyCacheInactive}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheInactive: event.target.value,
}))
}
placeholder="30m"
/>
</ResourceField>
<ResourceField label="max_size">
<ResourceInput
value={operationFields.OpenRestyCacheMaxSize}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheMaxSize: event.target.value,
}))
}
placeholder="1g"
/>
</ResourceField>
<ResourceField label="cache key template">
<ResourceInput
value={operationFields.OpenRestyCacheKeyTemplate}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheKeyTemplate: event.target.value,
}))
}
/>
</ResourceField>
<ToggleField
label="proxy_cache_lock"
description="是否启用 proxy_cache_lock。"
checked={operationFields.OpenRestyCacheLockEnabled}
onChange={(checked) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheLockEnabled: checked,
}))
}
/>
<ResourceField label="proxy_cache_lock_timeout">
<ResourceInput
value={operationFields.OpenRestyCacheLockTimeout}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheLockTimeout: event.target.value,
}))
}
placeholder="5s"
/>
</ResourceField>
<ResourceField label="proxy_cache_use_stale">
<ResourceInput
value={operationFields.OpenRestyCacheUseStale}
onChange={(event) =>
setOperationFields((previous) => ({
...previous,
OpenRestyCacheUseStale: event.target.value,
}))
}
/>
</ResourceField>
</div>
</div>
</AppCard>
</div>
<AppCard
title="性能页说明"
description="OpenResty 反代缓冲、压缩、缓存和主配置模板编辑已迁移到独立“性能”页面。"
action={
<PrimaryButton
type="button"
onClick={() => window.location.assign('/performance')}
>
前往性能页
</PrimaryButton>
}
>
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
这样可以把 Agent 运行参数、系统开关与 OpenResty
专项配置拆开,减少设置页的混杂信息量。
</p>
</AppCard>
<AppCard
title="请求限流设置"
description="按来源 IP 生效,保存后立即影响 Web、API、上传下载及登录注册等敏感接口。时间单位均为秒。"
@@ -2617,7 +1930,7 @@ export function SettingsPage() {
<div className="space-y-6">
<PageHeader
title="设置"
description="第五版从这里开始补齐 OpenResty 性能参数、Agent 运行参数和系统级运维配置。"
description="集中维护账号、Agent、系统、限流与品牌配置。OpenResty 专项能力已迁移到侧边栏“性能”页面。"
/>
{feedback ? (
+46 -41
View File
@@ -1,44 +1,49 @@
import type {NavigationItem} from '@/types/navigation';
import type { NavigationItem } from '@/types/navigation';
export const dashboardNavigation: NavigationItem[] = [
{
href: '/',
label: '总览',
icon: 'home',
},
{
href: '/node',
label: '节点',
icon: 'node',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/config-version',
label: '发布',
icon: 'release',
},
{
href: '/managed-domain',
label: '域名',
icon: 'domain',
},
{
href: '/tls-certificate',
label: '证书',
icon: 'certificate',
},
{
href: '/user',
label: '用户',
icon: 'user',
},
{
href: '/setting',
label: '设置',
icon: 'setting',
},
{
href: '/',
label: '总览',
icon: 'home',
},
{
href: '/node',
label: '节点',
icon: 'node',
},
{
href: '/proxy-route',
label: '规则',
icon: 'proxy',
},
{
href: '/config-version',
label: '发布',
icon: 'release',
},
{
href: '/managed-domain',
label: '域名',
icon: 'domain',
},
{
href: '/tls-certificate',
label: '证书',
icon: 'certificate',
},
{
href: '/user',
label: '用户',
icon: 'user',
},
{
href: '/performance',
label: '性能',
icon: 'performance',
},
{
href: '/setting',
label: '设置',
icon: 'setting',
},
];
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { getCurrentNavigationItem, isPathActive } from '@/lib/utils/navigation';
describe('navigation utils', () => {
import { describe, expect, it } from 'vitest';
import { getCurrentNavigationItem, isPathActive } from '@/lib/utils/navigation';
describe('navigation utils', () => {
it('marks root path as active only for home', () => {
expect(isPathActive('/', '/')).toBe(true);
expect(isPathActive('/node', '/')).toBe(false);
@@ -11,6 +11,7 @@ describe('navigation utils', () => {
it('resolves current navigation item for nested paths', () => {
expect(getCurrentNavigationItem('/node/abc')?.label).toBe('节点');
expect(getCurrentNavigationItem('/managed-domain')?.label).toBe('域名');
expect(getCurrentNavigationItem('/performance')?.label).toBe('性能');
expect(getCurrentNavigationItem('/setting')?.label).toBe('设置');
});
});
+1
View File
@@ -6,6 +6,7 @@ export type NavigationIconKey =
| 'certificate'
| 'proxy'
| 'release'
| 'performance'
| 'user'
| 'setting';