diff --git a/atsf_server/common/constants.go b/atsf_server/common/constants.go
index 1046fcd0..65a86e47 100644
--- a/atsf_server/common/constants.go
+++ b/atsf_server/common/constants.go
@@ -85,6 +85,39 @@ var OpenRestyCacheKeyTemplate = "$scheme$proxy_host$request_uri"
var OpenRestyCacheLockEnabled = true
var OpenRestyCacheLockTimeout = "5s"
var OpenRestyCacheUseStale = "error timeout updating http_500 http_502 http_503 http_504"
+var OpenRestyMainConfigTemplate = `# This file is generated by ATSFlare. Do not edit manually.
+worker_processes {{OpenRestyWorkerProcesses}};
+worker_rlimit_nofile {{OpenRestyWorkerRlimitNofile}};
+pid logs/nginx.pid;
+
+events {
+ worker_connections {{OpenRestyWorkerConnections}};
+{{OpenRestyEventsUseDirective}}{{OpenRestyEventsMultiAcceptDirective}}}
+
+http {
+ include mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout {{OpenRestyKeepaliveTimeout}};
+ keepalive_requests {{OpenRestyKeepaliveRequests}};
+ client_header_timeout {{OpenRestyClientHeaderTimeout}};
+ client_body_timeout {{OpenRestyClientBodyTimeout}};
+ send_timeout {{OpenRestySendTimeout}};
+ proxy_connect_timeout {{OpenRestyProxyConnectTimeout}};
+ proxy_send_timeout {{OpenRestyProxySendTimeout}};
+ proxy_read_timeout {{OpenRestyProxyReadTimeout}};
+ proxy_buffering {{OpenRestyProxyBuffering}};
+ proxy_buffers {{OpenRestyProxyBuffers}};
+ proxy_buffer_size {{OpenRestyProxyBufferSize}};
+ proxy_busy_buffers_size {{OpenRestyProxyBusyBuffersSize}};
+ gzip {{OpenRestyGzip}};
+ gzip_min_length {{OpenRestyGzipMinLength}};
+ gzip_comp_level {{OpenRestyGzipCompLevel}};
+{{OpenRestyCacheBlock}} include {{OpenRestyRouteConfigInclude}};
+}
+`
const (
RoleGuestUser = 0
diff --git a/atsf_server/controller/option.go b/atsf_server/controller/option.go
index 6e493d1b..eba822b0 100644
--- a/atsf_server/controller/option.go
+++ b/atsf_server/controller/option.go
@@ -3,6 +3,7 @@ package controller
import (
"atsflare/common"
"atsflare/model"
+ "atsflare/service"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
@@ -154,6 +155,8 @@ func validateOpenRestyOption(key string, value string) error {
}
}
return nil
+ case "OpenRestyMainConfigTemplate":
+ return service.ValidateOpenRestyMainConfigTemplate(value)
default:
return nil
}
diff --git a/atsf_server/model/option.go b/atsf_server/model/option.go
index 48c95b1b..a801a44c 100644
--- a/atsf_server/model/option.go
+++ b/atsf_server/model/option.go
@@ -84,6 +84,7 @@ func InitOptionMap() {
common.OptionMap["OpenRestyCacheLockEnabled"] = strconv.FormatBool(common.OpenRestyCacheLockEnabled)
common.OptionMap["OpenRestyCacheLockTimeout"] = common.OpenRestyCacheLockTimeout
common.OptionMap["OpenRestyCacheUseStale"] = common.OpenRestyCacheUseStale
+ common.OptionMap["OpenRestyMainConfigTemplate"] = common.OpenRestyMainConfigTemplate
common.OptionMap["GlobalApiRateLimitNum"] = strconv.Itoa(common.GlobalApiRateLimitNum)
common.OptionMap["GlobalApiRateLimitDuration"] = strconv.FormatInt(common.GlobalApiRateLimitDuration, 10)
common.OptionMap["GlobalWebRateLimitNum"] = strconv.Itoa(common.GlobalWebRateLimitNum)
@@ -309,6 +310,10 @@ func updateOptionMap(key string, value string) {
if strings.TrimSpace(value) != "" {
common.OpenRestyCacheUseStale = value
}
+ case "OpenRestyMainConfigTemplate":
+ if strings.TrimSpace(value) != "" {
+ common.OpenRestyMainConfigTemplate = value
+ }
case "GlobalApiRateLimitNum":
if v, err := strconv.Atoi(value); err == nil && v > 0 {
common.GlobalApiRateLimitNum = v
diff --git a/atsf_server/service/config_version.go b/atsf_server/service/config_version.go
index 8576ac5d..ff159be4 100644
--- a/atsf_server/service/config_version.go
+++ b/atsf_server/service/config_version.go
@@ -116,6 +116,31 @@ const (
nginxRouteConfigPlaceholder = "__ATSF_ROUTE_CONFIG__"
)
+var requiredMainConfigTemplatePlaceholders = []string{
+ "{{OpenRestyWorkerProcesses}}",
+ "{{OpenRestyWorkerConnections}}",
+ "{{OpenRestyWorkerRlimitNofile}}",
+ "{{OpenRestyEventsUseDirective}}",
+ "{{OpenRestyEventsMultiAcceptDirective}}",
+ "{{OpenRestyKeepaliveTimeout}}",
+ "{{OpenRestyKeepaliveRequests}}",
+ "{{OpenRestyClientHeaderTimeout}}",
+ "{{OpenRestyClientBodyTimeout}}",
+ "{{OpenRestySendTimeout}}",
+ "{{OpenRestyProxyConnectTimeout}}",
+ "{{OpenRestyProxySendTimeout}}",
+ "{{OpenRestyProxyReadTimeout}}",
+ "{{OpenRestyProxyBuffering}}",
+ "{{OpenRestyProxyBuffers}}",
+ "{{OpenRestyProxyBufferSize}}",
+ "{{OpenRestyProxyBusyBuffersSize}}",
+ "{{OpenRestyGzip}}",
+ "{{OpenRestyGzipMinLength}}",
+ "{{OpenRestyGzipCompLevel}}",
+ "{{OpenRestyCacheBlock}}",
+ "{{OpenRestyRouteConfigInclude}}",
+}
+
func ListConfigVersions() ([]*model.ConfigVersion, error) {
return model.ListConfigVersions()
}
@@ -569,51 +594,77 @@ func renderRouteConfig(routes []*model.ProxyRoute) (string, []SupportFile, error
}
func renderMainConfig(cfg openRestyConfigSnapshot) string {
- var builder strings.Builder
- builder.WriteString("# This file is generated by ATSFlare. Do not edit manually.\n")
- builder.WriteString(fmt.Sprintf("worker_processes %s;\n", cfg.WorkerProcesses))
- builder.WriteString(fmt.Sprintf("worker_rlimit_nofile %d;\n", cfg.WorkerRlimitNofile))
- builder.WriteString("pid logs/nginx.pid;\n\n")
- builder.WriteString("events {\n")
- builder.WriteString(fmt.Sprintf(" worker_connections %d;\n", cfg.WorkerConnections))
- if strings.TrimSpace(cfg.EventsUse) != "" {
- builder.WriteString(fmt.Sprintf(" use %s;\n", cfg.EventsUse))
+ templateText := common.OpenRestyMainConfigTemplate
+ if strings.TrimSpace(templateText) == "" {
+ templateText = defaultOpenRestyMainConfigTemplate()
}
- if cfg.EventsMultiAcceptEnabled {
- builder.WriteString(" multi_accept on;\n")
+ return renderMainConfigTemplate(templateText, cfg)
+}
+
+func ValidateOpenRestyMainConfigTemplate(templateText string) error {
+ trimmed := strings.TrimSpace(templateText)
+ if trimmed == "" {
+ return errors.New("OpenRestyMainConfigTemplate 不能为空")
}
- builder.WriteString("}\n\n")
- builder.WriteString("http {\n")
- builder.WriteString(" include mime.types;\n")
- builder.WriteString(" default_type application/octet-stream;\n")
- builder.WriteString(" sendfile on;\n")
- builder.WriteString(" tcp_nopush on;\n")
- builder.WriteString(" tcp_nodelay on;\n")
- builder.WriteString(fmt.Sprintf(" keepalive_timeout %d;\n", cfg.KeepaliveTimeout))
- builder.WriteString(fmt.Sprintf(" keepalive_requests %d;\n", cfg.KeepaliveRequests))
- builder.WriteString(fmt.Sprintf(" client_header_timeout %d;\n", cfg.ClientHeaderTimeout))
- builder.WriteString(fmt.Sprintf(" client_body_timeout %d;\n", cfg.ClientBodyTimeout))
- builder.WriteString(fmt.Sprintf(" send_timeout %d;\n", cfg.SendTimeout))
- builder.WriteString(fmt.Sprintf(" proxy_connect_timeout %d;\n", cfg.ProxyConnectTimeout))
- builder.WriteString(fmt.Sprintf(" proxy_send_timeout %d;\n", cfg.ProxySendTimeout))
- builder.WriteString(fmt.Sprintf(" proxy_read_timeout %d;\n", cfg.ProxyReadTimeout))
- builder.WriteString(fmt.Sprintf(" proxy_buffering %s;\n", onOff(cfg.ProxyBufferingEnabled)))
- builder.WriteString(fmt.Sprintf(" proxy_buffers %s;\n", cfg.ProxyBuffers))
- builder.WriteString(fmt.Sprintf(" proxy_buffer_size %s;\n", cfg.ProxyBufferSize))
- builder.WriteString(fmt.Sprintf(" proxy_busy_buffers_size %s;\n", cfg.ProxyBusyBuffersSize))
- builder.WriteString(fmt.Sprintf(" gzip %s;\n", onOff(cfg.GzipEnabled)))
- builder.WriteString(fmt.Sprintf(" gzip_min_length %d;\n", cfg.GzipMinLength))
- builder.WriteString(fmt.Sprintf(" gzip_comp_level %d;\n", cfg.GzipCompLevel))
- if cfg.CacheEnabled {
- builder.WriteString(fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=atsflare_cache:10m inactive=%s max_size=%s;\n", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize))
- builder.WriteString(fmt.Sprintf(" proxy_cache_key \"%s\";\n", cfg.CacheKeyTemplate))
- builder.WriteString(fmt.Sprintf(" proxy_cache_lock %s;\n", onOff(cfg.CacheLockEnabled)))
- builder.WriteString(fmt.Sprintf(" proxy_cache_lock_timeout %s;\n", cfg.CacheLockTimeout))
- builder.WriteString(fmt.Sprintf(" proxy_cache_use_stale %s;\n", cfg.CacheUseStale))
+ for _, placeholder := range requiredMainConfigTemplatePlaceholders {
+ if !strings.Contains(trimmed, placeholder) {
+ return fmt.Errorf("OpenRestyMainConfigTemplate 必须保留占位符 %s", placeholder)
+ }
}
- builder.WriteString(fmt.Sprintf(" include %s;\n", nginxRouteConfigPlaceholder))
- builder.WriteString("}\n")
- return builder.String()
+ return nil
+}
+
+func defaultOpenRestyMainConfigTemplate() string {
+ return common.OpenRestyMainConfigTemplate
+}
+
+func renderMainConfigTemplate(templateText string, cfg openRestyConfigSnapshot) string {
+ replacer := strings.NewReplacer(
+ "{{OpenRestyWorkerProcesses}}", cfg.WorkerProcesses,
+ "{{OpenRestyWorkerConnections}}", fmt.Sprintf("%d", cfg.WorkerConnections),
+ "{{OpenRestyWorkerRlimitNofile}}", fmt.Sprintf("%d", cfg.WorkerRlimitNofile),
+ "{{OpenRestyEventsUseDirective}}", renderTemplateDirective(cfg.EventsUse != "", fmt.Sprintf("use %s;", cfg.EventsUse)),
+ "{{OpenRestyEventsMultiAcceptDirective}}", renderTemplateDirective(cfg.EventsMultiAcceptEnabled, "multi_accept on;"),
+ "{{OpenRestyKeepaliveTimeout}}", fmt.Sprintf("%d", cfg.KeepaliveTimeout),
+ "{{OpenRestyKeepaliveRequests}}", fmt.Sprintf("%d", cfg.KeepaliveRequests),
+ "{{OpenRestyClientHeaderTimeout}}", fmt.Sprintf("%d", cfg.ClientHeaderTimeout),
+ "{{OpenRestyClientBodyTimeout}}", fmt.Sprintf("%d", cfg.ClientBodyTimeout),
+ "{{OpenRestySendTimeout}}", fmt.Sprintf("%d", cfg.SendTimeout),
+ "{{OpenRestyProxyConnectTimeout}}", fmt.Sprintf("%d", cfg.ProxyConnectTimeout),
+ "{{OpenRestyProxySendTimeout}}", fmt.Sprintf("%d", cfg.ProxySendTimeout),
+ "{{OpenRestyProxyReadTimeout}}", fmt.Sprintf("%d", cfg.ProxyReadTimeout),
+ "{{OpenRestyProxyBuffering}}", onOff(cfg.ProxyBufferingEnabled),
+ "{{OpenRestyProxyBuffers}}", cfg.ProxyBuffers,
+ "{{OpenRestyProxyBufferSize}}", cfg.ProxyBufferSize,
+ "{{OpenRestyProxyBusyBuffersSize}}", cfg.ProxyBusyBuffersSize,
+ "{{OpenRestyGzip}}", onOff(cfg.GzipEnabled),
+ "{{OpenRestyGzipMinLength}}", fmt.Sprintf("%d", cfg.GzipMinLength),
+ "{{OpenRestyGzipCompLevel}}", fmt.Sprintf("%d", cfg.GzipCompLevel),
+ "{{OpenRestyCacheBlock}}", renderOpenRestyCacheTemplateBlock(cfg),
+ "{{OpenRestyRouteConfigInclude}}", nginxRouteConfigPlaceholder,
+ )
+ return replacer.Replace(templateText)
+}
+
+func renderTemplateDirective(enabled bool, statement string) string {
+ if !enabled {
+ return ""
+ }
+ return fmt.Sprintf(" %s\n", statement)
+}
+
+func renderOpenRestyCacheTemplateBlock(cfg openRestyConfigSnapshot) string {
+ if !cfg.CacheEnabled {
+ return ""
+ }
+ return strings.Join([]string{
+ fmt.Sprintf(" proxy_cache_path %s levels=%s keys_zone=atsflare_cache:10m inactive=%s max_size=%s;", cfg.CachePath, cfg.CacheLevels, cfg.CacheInactive, cfg.CacheMaxSize),
+ fmt.Sprintf(" proxy_cache_key \"%s\";", cfg.CacheKeyTemplate),
+ fmt.Sprintf(" proxy_cache_lock %s;", onOff(cfg.CacheLockEnabled)),
+ fmt.Sprintf(" proxy_cache_lock_timeout %s;", cfg.CacheLockTimeout),
+ fmt.Sprintf(" proxy_cache_use_stale %s;", cfg.CacheUseStale),
+ "",
+ }, "\n")
}
func onOff(value bool) string {
diff --git a/atsf_server/service/https_phase1_test.go b/atsf_server/service/https_phase1_test.go
index 09576a24..fa91cfbd 100644
--- a/atsf_server/service/https_phase1_test.go
+++ b/atsf_server/service/https_phase1_test.go
@@ -251,6 +251,45 @@ func TestCreateTLSCertificateRejectsInvalidPEM(t *testing.T) {
}
}
+func TestOpenRestyMainConfigTemplateRenderAndValidate(t *testing.T) {
+ setupServiceTestDB(t)
+
+ customTemplate := strings.ReplaceAll(
+ common.OpenRestyMainConfigTemplate,
+ "pid logs/nginx.pid;",
+ "pid logs/nginx.pid;\nworker_shutdown_timeout 10s;",
+ )
+ if err := ValidateOpenRestyMainConfigTemplate(customTemplate); err != nil {
+ t.Fatalf("ValidateOpenRestyMainConfigTemplate failed: %v", err)
+ }
+ if err := model.UpdateOption("OpenRestyMainConfigTemplate", customTemplate); err != nil {
+ t.Fatalf("UpdateOption OpenRestyMainConfigTemplate failed: %v", err)
+ }
+
+ preview, err := PreviewConfigVersion()
+ if err != nil {
+ t.Fatalf("PreviewConfigVersion failed: %v", err)
+ }
+ if !strings.Contains(preview.MainConfig, "worker_shutdown_timeout 10s;") {
+ t.Fatal("expected preview main config to include custom template content")
+ }
+ if strings.Contains(preview.MainConfig, "{{OpenRestyWorkerProcesses}}") {
+ t.Fatal("expected preview main config placeholders to be rendered")
+ }
+ if !strings.Contains(preview.MainConfig, "include __ATSF_ROUTE_CONFIG__;") {
+ t.Fatal("expected preview main config to preserve managed route include")
+ }
+
+ invalidTemplate := strings.ReplaceAll(
+ common.OpenRestyMainConfigTemplate,
+ "{{OpenRestyRouteConfigInclude}}",
+ "",
+ )
+ if err := ValidateOpenRestyMainConfigTemplate(invalidTemplate); err == nil {
+ t.Fatal("expected template without managed route placeholder to fail validation")
+ }
+}
+
func setupServiceTestDB(t *testing.T) {
t.Helper()
common.SQLitePath = filepath.Join(t.TempDir(), "service.db")
diff --git a/atsf_server/web/app/(dashboard)/performance/page.tsx b/atsf_server/web/app/(dashboard)/performance/page.tsx
new file mode 100644
index 00000000..a3cafbfb
--- /dev/null
+++ b/atsf_server/web/app/(dashboard)/performance/page.tsx
@@ -0,0 +1,5 @@
+import { PerformancePage } from '@/features/performance/components/performance-page';
+
+export default function PerformanceRoute() {
+ return ;
+}
diff --git a/atsf_server/web/components/layout/dashboard-sidebar.tsx b/atsf_server/web/components/layout/dashboard-sidebar.tsx
index 9dcdf7ca..92027b5d 100644
--- a/atsf_server/web/components/layout/dashboard-sidebar.tsx
+++ b/atsf_server/web/components/layout/dashboard-sidebar.tsx
@@ -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 (
-
- );
- case 'node':
- return (
-
- );
- case 'website':
- return (
-
- );
- case 'domain':
- return (
-
- );
- case 'certificate':
- return (
-
- );
- case 'proxy':
- return (
-
- );
- case 'release':
- return (
-
- );
- case 'user':
- return (
-
- );
- case 'setting':
- return (
-
- );
- }
+ switch (icon) {
+ case 'home':
+ return (
+
+ );
+ case 'node':
+ return (
+
+ );
+ case 'website':
+ return (
+
+ );
+ case 'domain':
+ return (
+
+ );
+ case 'certificate':
+ return (
+
+ );
+ case 'proxy':
+ return (
+
+ );
+ case 'release':
+ return (
+
+ );
+ case 'performance':
+ return (
+
+ );
+ case 'user':
+ return (
+
+ );
+ case 'setting':
+ return (
+
+ );
+ }
}
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 (
-
-
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)]',
- )}
- >
-
-
+ return (
+
+
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)]',
+ )}
+ >
+
+
- {showLabel ?
{item.label} : null}
-
- {showLabel && hasChildren ? (
-
- {item.children?.map((child) => (
-
- ))}
-
- ) : null}
+ {showLabel ? (
+
+ {item.label}
+
+ ) : null}
+
+ {showLabel && hasChildren ? (
+
+ {item.children?.map((child) => (
+
+ ))}
- );
+ ) : null}
+
+ );
}
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 (
-
-
-
- AF
-
- {showLabel ? (
-
- ) : null}
-
-
-
+ return (
+
+
+
+ AF
- );
+ {showLabel ? (
+
+ ) : null}
+
+
+
+
+ );
}
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 (
- <>
-
setMobileSidebarOpen(false)}
- aria-hidden='true'
- />
+ return (
+ <>
+
setMobileSidebarOpen(false)}
+ aria-hidden="true"
+ />
-
+
-
- >
- );
+
+ >
+ );
}
diff --git a/atsf_server/web/features/performance/components/performance-page.tsx b/atsf_server/web/features/performance/components/performance-page.tsx
new file mode 100644
index 00000000..d7f35e68
--- /dev/null
+++ b/atsf_server/web/features/performance/components/performance-page.tsx
@@ -0,0 +1,1050 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { EmptyState } from '@/components/feedback/empty-state';
+import { ErrorState } from '@/components/feedback/error-state';
+import { InlineMessage } from '@/components/feedback/inline-message';
+import { LoadingState } from '@/components/feedback/loading-state';
+import { PageHeader } from '@/components/layout/page-header';
+import { useAuth } from '@/components/providers/auth-provider';
+import { AppCard } from '@/components/ui/app-card';
+import { StatusBadge } from '@/components/ui/status-badge';
+import { getConfigVersionPreview } from '@/features/config-versions/api/config-versions';
+import { getOptions, updateOption } from '@/features/settings/api/settings';
+import type { OptionItem } from '@/features/settings/types';
+import {
+ CodeBlock,
+ PrimaryButton,
+ ResourceField,
+ ResourceInput,
+ ResourceSelect,
+ ResourceTextarea,
+ SecondaryButton,
+ ToggleField,
+} from '@/features/shared/components/resource-primitives';
+
+const settingsQueryKey = ['settings', 'options'] as const;
+const previewQueryKey = ['performance', 'preview'] as const;
+
+const defaultPerformanceFields = {
+ OpenRestyWorkerProcesses: 'auto',
+ OpenRestyWorkerConnections: '4096',
+ OpenRestyWorkerRlimitNofile: '65535',
+ OpenRestyEventsUse: '',
+ OpenRestyEventsMultiAcceptEnabled: false,
+ OpenRestyKeepaliveTimeout: '65',
+ OpenRestyKeepaliveRequests: '1000',
+ OpenRestyClientHeaderTimeout: '15',
+ OpenRestyClientBodyTimeout: '15',
+ OpenRestySendTimeout: '30',
+ OpenRestyProxyConnectTimeout: '5',
+ OpenRestyProxySendTimeout: '60',
+ OpenRestyProxyReadTimeout: '60',
+ OpenRestyProxyBufferingEnabled: true,
+ OpenRestyProxyBuffers: '16 16k',
+ OpenRestyProxyBufferSize: '8k',
+ OpenRestyProxyBusyBuffersSize: '64k',
+ OpenRestyGzipEnabled: true,
+ OpenRestyGzipMinLength: '1024',
+ OpenRestyGzipCompLevel: '5',
+ OpenRestyCacheEnabled: false,
+ OpenRestyCachePath: '',
+ OpenRestyCacheLevels: '1:2',
+ OpenRestyCacheInactive: '30m',
+ OpenRestyCacheMaxSize: '1g',
+ OpenRestyCacheKeyTemplate: '$scheme$proxy_host$request_uri',
+ OpenRestyCacheLockEnabled: true,
+ OpenRestyCacheLockTimeout: '5s',
+ OpenRestyCacheUseStale:
+ 'error timeout updating http_500 http_502 http_503 http_504',
+};
+
+type PerformanceTab = 'settings' | 'editor';
+
+type FeedbackState = {
+ tone: 'info' | 'success' | 'danger';
+ message: string;
+};
+
+function getErrorMessage(error: unknown) {
+ return error instanceof Error ? error.message : '请求失败,请稍后重试。';
+}
+
+function optionsToMap(options: OptionItem[] | undefined) {
+ return (options ?? []).reduce
>(
+ (accumulator, option) => {
+ accumulator[option.key] = option.value;
+ return accumulator;
+ },
+ {},
+ );
+}
+
+function toBoolean(value: string | undefined, fallback: boolean) {
+ if (value === undefined) {
+ return fallback;
+ }
+
+ return value === 'true';
+}
+
+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());
+}
+
+export function PerformancePage() {
+ const queryClient = useQueryClient();
+ const { user } = useAuth();
+ const [activeTab, setActiveTab] = useState('settings');
+ const [feedback, setFeedback] = useState(null);
+ const [busyKey, setBusyKey] = useState(null);
+ const [performanceFields, setPerformanceFields] = useState(
+ defaultPerformanceFields,
+ );
+ const [templateContent, setTemplateContent] = useState('');
+
+ const isRoot = (user?.role ?? 0) >= 100;
+
+ const optionsQuery = useQuery({
+ queryKey: settingsQueryKey,
+ queryFn: getOptions,
+ enabled: isRoot,
+ });
+
+ const previewQuery = useQuery({
+ queryKey: previewQueryKey,
+ queryFn: getConfigVersionPreview,
+ enabled: isRoot,
+ });
+
+ useEffect(() => {
+ if (!optionsQuery.data) {
+ return;
+ }
+
+ const optionMap = optionsToMap(optionsQuery.data);
+ setPerformanceFields({
+ OpenRestyWorkerProcesses: optionMap.OpenRestyWorkerProcesses ?? 'auto',
+ OpenRestyWorkerConnections:
+ optionMap.OpenRestyWorkerConnections ?? '4096',
+ OpenRestyWorkerRlimitNofile:
+ optionMap.OpenRestyWorkerRlimitNofile ?? '65535',
+ OpenRestyEventsUse: optionMap.OpenRestyEventsUse ?? '',
+ OpenRestyEventsMultiAcceptEnabled: toBoolean(
+ optionMap.OpenRestyEventsMultiAcceptEnabled,
+ false,
+ ),
+ OpenRestyKeepaliveTimeout: optionMap.OpenRestyKeepaliveTimeout ?? '65',
+ OpenRestyKeepaliveRequests:
+ optionMap.OpenRestyKeepaliveRequests ?? '1000',
+ OpenRestyClientHeaderTimeout:
+ optionMap.OpenRestyClientHeaderTimeout ?? '15',
+ OpenRestyClientBodyTimeout: optionMap.OpenRestyClientBodyTimeout ?? '15',
+ OpenRestySendTimeout: optionMap.OpenRestySendTimeout ?? '30',
+ OpenRestyProxyConnectTimeout:
+ optionMap.OpenRestyProxyConnectTimeout ?? '5',
+ OpenRestyProxySendTimeout: optionMap.OpenRestyProxySendTimeout ?? '60',
+ OpenRestyProxyReadTimeout: optionMap.OpenRestyProxyReadTimeout ?? '60',
+ OpenRestyProxyBufferingEnabled: toBoolean(
+ optionMap.OpenRestyProxyBufferingEnabled,
+ true,
+ ),
+ OpenRestyProxyBuffers: optionMap.OpenRestyProxyBuffers ?? '16 16k',
+ OpenRestyProxyBufferSize: optionMap.OpenRestyProxyBufferSize ?? '8k',
+ OpenRestyProxyBusyBuffersSize:
+ optionMap.OpenRestyProxyBusyBuffersSize ?? '64k',
+ OpenRestyGzipEnabled: toBoolean(optionMap.OpenRestyGzipEnabled, true),
+ OpenRestyGzipMinLength: optionMap.OpenRestyGzipMinLength ?? '1024',
+ OpenRestyGzipCompLevel: optionMap.OpenRestyGzipCompLevel ?? '5',
+ OpenRestyCacheEnabled: toBoolean(optionMap.OpenRestyCacheEnabled, false),
+ OpenRestyCachePath: optionMap.OpenRestyCachePath ?? '',
+ OpenRestyCacheLevels: optionMap.OpenRestyCacheLevels ?? '1:2',
+ OpenRestyCacheInactive: optionMap.OpenRestyCacheInactive ?? '30m',
+ OpenRestyCacheMaxSize: optionMap.OpenRestyCacheMaxSize ?? '1g',
+ OpenRestyCacheKeyTemplate:
+ optionMap.OpenRestyCacheKeyTemplate ?? '$scheme$proxy_host$request_uri',
+ OpenRestyCacheLockEnabled: toBoolean(
+ optionMap.OpenRestyCacheLockEnabled,
+ true,
+ ),
+ OpenRestyCacheLockTimeout: optionMap.OpenRestyCacheLockTimeout ?? '5s',
+ OpenRestyCacheUseStale:
+ optionMap.OpenRestyCacheUseStale ??
+ 'error timeout updating http_500 http_502 http_503 http_504',
+ });
+ setTemplateContent(optionMap.OpenRestyMainConfigTemplate ?? '');
+ }, [optionsQuery.data]);
+
+ const runBusyAction = async (key: string, action: () => Promise) => {
+ setBusyKey(key);
+ setFeedback(null);
+
+ try {
+ await action();
+ } catch (error) {
+ setFeedback({ tone: 'danger', message: getErrorMessage(error) });
+ } finally {
+ setBusyKey(null);
+ }
+ };
+
+ const saveOptionEntries = async (
+ entries: Array<[string, string]>,
+ successMessage: string,
+ ) => {
+ for (const [key, value] of entries) {
+ await updateOption(key, value);
+ }
+
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: settingsQueryKey }),
+ queryClient.invalidateQueries({ queryKey: previewQueryKey }),
+ queryClient.invalidateQueries({ queryKey: ['config-versions'] }),
+ ]);
+ setFeedback({ tone: 'success', message: successMessage });
+ };
+
+ const handleRuntimeSave = () => {
+ void runBusyAction('performance-runtime', async () => {
+ if (
+ performanceFields.OpenRestyWorkerProcesses !== 'auto' &&
+ !isPositiveInteger(performanceFields.OpenRestyWorkerProcesses)
+ ) {
+ throw new Error('worker_processes 必须为 auto 或大于 0 的整数。');
+ }
+
+ const integerFields = [
+ ['worker_connections', performanceFields.OpenRestyWorkerConnections],
+ ['worker_rlimit_nofile', performanceFields.OpenRestyWorkerRlimitNofile],
+ ['keepalive_timeout', performanceFields.OpenRestyKeepaliveTimeout],
+ ['keepalive_requests', performanceFields.OpenRestyKeepaliveRequests],
+ [
+ 'client_header_timeout',
+ performanceFields.OpenRestyClientHeaderTimeout,
+ ],
+ ['client_body_timeout', performanceFields.OpenRestyClientBodyTimeout],
+ ['send_timeout', performanceFields.OpenRestySendTimeout],
+ ] as const;
+
+ for (const [label, value] of integerFields) {
+ if (!isPositiveInteger(value)) {
+ throw new Error(`${label} 必须为大于 0 的整数。`);
+ }
+ }
+
+ await saveOptionEntries(
+ [
+ [
+ 'OpenRestyWorkerProcesses',
+ performanceFields.OpenRestyWorkerProcesses.trim(),
+ ],
+ [
+ 'OpenRestyWorkerConnections',
+ performanceFields.OpenRestyWorkerConnections.trim(),
+ ],
+ [
+ 'OpenRestyWorkerRlimitNofile',
+ performanceFields.OpenRestyWorkerRlimitNofile.trim(),
+ ],
+ ['OpenRestyEventsUse', performanceFields.OpenRestyEventsUse.trim()],
+ [
+ 'OpenRestyEventsMultiAcceptEnabled',
+ String(performanceFields.OpenRestyEventsMultiAcceptEnabled),
+ ],
+ [
+ 'OpenRestyKeepaliveTimeout',
+ performanceFields.OpenRestyKeepaliveTimeout.trim(),
+ ],
+ [
+ 'OpenRestyKeepaliveRequests',
+ performanceFields.OpenRestyKeepaliveRequests.trim(),
+ ],
+ [
+ 'OpenRestyClientHeaderTimeout',
+ performanceFields.OpenRestyClientHeaderTimeout.trim(),
+ ],
+ [
+ 'OpenRestyClientBodyTimeout',
+ performanceFields.OpenRestyClientBodyTimeout.trim(),
+ ],
+ [
+ 'OpenRestySendTimeout',
+ performanceFields.OpenRestySendTimeout.trim(),
+ ],
+ ],
+ 'OpenResty 连接与事件参数已保存。',
+ );
+ });
+ };
+
+ const handleProxySave = () => {
+ void runBusyAction('performance-proxy', async () => {
+ const timeoutFields = [
+ performanceFields.OpenRestyProxyConnectTimeout,
+ performanceFields.OpenRestyProxySendTimeout,
+ performanceFields.OpenRestyProxyReadTimeout,
+ ];
+
+ for (const value of timeoutFields) {
+ if (!isPositiveInteger(value)) {
+ throw new Error('代理超时参数必须为大于 0 的整数秒。');
+ }
+ }
+ if (!isProxyBuffersValue(performanceFields.OpenRestyProxyBuffers)) {
+ throw new Error('proxy_buffers 格式必须类似 "16 16k"。');
+ }
+ if (
+ !isSizeValue(performanceFields.OpenRestyProxyBufferSize) ||
+ !isSizeValue(performanceFields.OpenRestyProxyBusyBuffersSize)
+ ) {
+ throw new Error('缓冲大小必须为整数或带 k/m/g 单位的值。');
+ }
+
+ await saveOptionEntries(
+ [
+ [
+ 'OpenRestyProxyConnectTimeout',
+ performanceFields.OpenRestyProxyConnectTimeout.trim(),
+ ],
+ [
+ 'OpenRestyProxySendTimeout',
+ performanceFields.OpenRestyProxySendTimeout.trim(),
+ ],
+ [
+ 'OpenRestyProxyReadTimeout',
+ performanceFields.OpenRestyProxyReadTimeout.trim(),
+ ],
+ [
+ 'OpenRestyProxyBufferingEnabled',
+ String(performanceFields.OpenRestyProxyBufferingEnabled),
+ ],
+ [
+ 'OpenRestyProxyBuffers',
+ performanceFields.OpenRestyProxyBuffers.trim(),
+ ],
+ [
+ 'OpenRestyProxyBufferSize',
+ performanceFields.OpenRestyProxyBufferSize.trim(),
+ ],
+ [
+ 'OpenRestyProxyBusyBuffersSize',
+ performanceFields.OpenRestyProxyBusyBuffersSize.trim(),
+ ],
+ ],
+ 'OpenResty 反代缓冲参数已保存。',
+ );
+ });
+ };
+
+ const handleCacheSave = () => {
+ void runBusyAction('performance-cache', async () => {
+ if (!isPositiveInteger(performanceFields.OpenRestyGzipMinLength)) {
+ throw new Error('gzip_min_length 必须为大于 0 的整数。');
+ }
+ const gzipLevel = Number.parseInt(
+ performanceFields.OpenRestyGzipCompLevel,
+ 10,
+ );
+ if (Number.isNaN(gzipLevel) || gzipLevel < 1 || gzipLevel > 9) {
+ throw new Error('gzip_comp_level 必须在 1 到 9 之间。');
+ }
+ if (performanceFields.OpenRestyCacheEnabled) {
+ if (!performanceFields.OpenRestyCachePath.trim()) {
+ throw new Error('启用缓存时必须填写 proxy_cache_path 目录。');
+ }
+ if (
+ !isCacheLevelsValue(performanceFields.OpenRestyCacheLevels) ||
+ !isDurationToken(performanceFields.OpenRestyCacheInactive) ||
+ !isSizeValue(performanceFields.OpenRestyCacheMaxSize) ||
+ !isDurationToken(performanceFields.OpenRestyCacheLockTimeout)
+ ) {
+ throw new Error(
+ '缓存 levels、inactive、max_size 或 lock_timeout 格式不合法。',
+ );
+ }
+ if (!performanceFields.OpenRestyCacheKeyTemplate.trim()) {
+ throw new Error('启用缓存时必须填写缓存 Key 模板。');
+ }
+ }
+
+ await saveOptionEntries(
+ [
+ [
+ 'OpenRestyGzipEnabled',
+ String(performanceFields.OpenRestyGzipEnabled),
+ ],
+ [
+ 'OpenRestyGzipMinLength',
+ performanceFields.OpenRestyGzipMinLength.trim(),
+ ],
+ [
+ 'OpenRestyGzipCompLevel',
+ performanceFields.OpenRestyGzipCompLevel.trim(),
+ ],
+ ['OpenRestyCachePath', performanceFields.OpenRestyCachePath.trim()],
+ [
+ 'OpenRestyCacheLevels',
+ performanceFields.OpenRestyCacheLevels.trim(),
+ ],
+ [
+ 'OpenRestyCacheInactive',
+ performanceFields.OpenRestyCacheInactive.trim(),
+ ],
+ [
+ 'OpenRestyCacheMaxSize',
+ performanceFields.OpenRestyCacheMaxSize.trim(),
+ ],
+ [
+ 'OpenRestyCacheKeyTemplate',
+ performanceFields.OpenRestyCacheKeyTemplate.trim(),
+ ],
+ [
+ 'OpenRestyCacheLockEnabled',
+ String(performanceFields.OpenRestyCacheLockEnabled),
+ ],
+ [
+ 'OpenRestyCacheLockTimeout',
+ performanceFields.OpenRestyCacheLockTimeout.trim(),
+ ],
+ [
+ 'OpenRestyCacheUseStale',
+ performanceFields.OpenRestyCacheUseStale.trim(),
+ ],
+ [
+ 'OpenRestyCacheEnabled',
+ String(performanceFields.OpenRestyCacheEnabled),
+ ],
+ ],
+ 'OpenResty 压缩与缓存参数已保存。',
+ );
+ });
+ };
+
+ const handleTemplateSave = () => {
+ void runBusyAction('performance-template', async () => {
+ await saveOptionEntries(
+ [['OpenRestyMainConfigTemplate', templateContent]],
+ 'OpenResty 主配置模板已保存。',
+ );
+ });
+ };
+
+ if (!isRoot) {
+ return (
+
+ );
+ }
+
+ if (optionsQuery.isLoading || previewQuery.isLoading) {
+ return ;
+ }
+
+ if (optionsQuery.isError) {
+ return (
+
+ );
+ }
+
+ if (previewQuery.isError) {
+ return (
+
+ );
+ }
+
+ const preview = previewQuery.data;
+ if (!preview) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {feedback ? (
+
+ ) : null}
+
+
+ {[
+ {
+ key: 'settings' as const,
+ label: '设置',
+ description: '维护连接、缓冲、压缩与缓存参数。',
+ },
+ {
+ key: 'editor' as const,
+ label: '编辑',
+ description: '编辑 nginx.conf 模板并查看当前渲染结果。',
+ },
+ ].map((tab) => (
+
+ ))}
+
+
+ {activeTab === 'settings' ? (
+
+
+
+
+
+ 当前预览规则数
+
+
+ {preview.route_count} 条
+
+
+
+
+ 当前模板
+
+
+ 受控模板 + 占位符渲染
+
+
+
+
+
+ {busyKey === 'performance-runtime'
+ ? '保存中...'
+ : '保存运行调优'}
+
+ }
+ >
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyWorkerProcesses: event.target.value,
+ }))
+ }
+ placeholder="auto"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyWorkerConnections: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyWorkerRlimitNofile: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyEventsUse: event.target.value,
+ }))
+ }
+ >
+
+
+
+
+
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyEventsMultiAcceptEnabled: checked,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyKeepaliveTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyKeepaliveRequests: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyClientHeaderTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyClientBodyTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestySendTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+
+ {busyKey === 'performance-proxy'
+ ? '保存中...'
+ : '保存代理调优'}
+
+ }
+ >
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyConnectTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxySendTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyReadTimeout: event.target.value,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyBufferingEnabled: checked,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyBuffers: event.target.value,
+ }))
+ }
+ placeholder="16 16k"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyBufferSize: event.target.value,
+ }))
+ }
+ placeholder="8k"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyProxyBusyBuffersSize: event.target.value,
+ }))
+ }
+ placeholder="64k"
+ />
+
+
+
+
+
+ {busyKey === 'performance-cache'
+ ? '保存中...'
+ : '保存压缩与缓存'}
+
+ }
+ >
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyGzipEnabled: checked,
+ }))
+ }
+ />
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheEnabled: checked,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyGzipMinLength: event.target.value,
+ }))
+ }
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyGzipCompLevel: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCachePath: event.target.value,
+ }))
+ }
+ placeholder="/var/cache/openresty/atsflare"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheLevels: event.target.value,
+ }))
+ }
+ placeholder="1:2"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheInactive: event.target.value,
+ }))
+ }
+ placeholder="30m"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheMaxSize: event.target.value,
+ }))
+ }
+ placeholder="1g"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheKeyTemplate: event.target.value,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheLockEnabled: checked,
+ }))
+ }
+ />
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheLockTimeout: event.target.value,
+ }))
+ }
+ placeholder="5s"
+ />
+
+
+
+ setPerformanceFields((previous) => ({
+ ...previous,
+ OpenRestyCacheUseStale: event.target.value,
+ }))
+ }
+ />
+
+
+
+
+
+
+ ) : (
+
+
+
+ void queryClient.invalidateQueries({
+ queryKey: previewQueryKey,
+ })
+ }
+ >
+ 刷新预览
+
+
+ {busyKey === 'performance-template'
+ ? '保存中...'
+ : '保存模板'}
+
+
+ }
+ >
+
+
+
+ setTemplateContent(event.target.value)}
+ className="min-h-[560px] font-mono text-xs leading-6"
+ placeholder="请输入受控 nginx.conf 模板"
+ />
+
+
+
+
+
+
+
+ 当前渲染预览
+
+
+ 基于当前结构化参数和模板生成的主配置文件。
+
+
+
+
+
+ {preview.main_config}
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx
index ecda4999..8c735a43 100644
--- a/atsf_server/web/features/settings/components/settings-page.tsx
+++ b/atsf_server/web/features/settings/components/settings-page.tsx
@@ -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() {
- 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'
- ? '保存中...'
- : '保存运行调优'}
+ 打开性能页
}
>
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyWorkerProcesses: event.target.value,
- }))
- }
- placeholder="auto"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyWorkerConnections: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyWorkerRlimitNofile: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyEventsUse: event.target.value,
- }))
- }
- >
-
-
-
-
-
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyEventsMultiAcceptEnabled: checked,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyKeepaliveTimeout: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyKeepaliveRequests: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyClientHeaderTimeout: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyClientBodyTimeout: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestySendTimeout: event.target.value,
- }))
- }
- />
-
-
+
+ 设置页保留 Agent、系统、限流和品牌配置;OpenResty
+ 相关项已拆分为独立工作台,支持“设置 / 编辑”双 Tab 操作。
+
-
-
- 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'
- ? '保存中...'
- : '保存代理调优'}
-
- }
- >
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyConnectTimeout: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxySendTimeout: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyReadTimeout: event.target.value,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyBufferingEnabled: checked,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyBuffers: event.target.value,
- }))
- }
- placeholder="16 16k"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyBufferSize: event.target.value,
- }))
- }
- placeholder="8k"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyProxyBusyBuffersSize: event.target.value,
- }))
- }
- placeholder="64k"
- />
-
-
-
-
- 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'
- ? '保存中...'
- : '保存压缩与缓存'}
-
- }
- >
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyGzipEnabled: checked,
- }))
- }
- />
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheEnabled: checked,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyGzipMinLength: event.target.value,
- }))
- }
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyGzipCompLevel: event.target.value,
- }))
- }
- />
-
-
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCachePath: event.target.value,
- }))
- }
- placeholder="/var/cache/openresty/atsflare"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheLevels: event.target.value,
- }))
- }
- placeholder="1:2"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheInactive: event.target.value,
- }))
- }
- placeholder="30m"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheMaxSize: event.target.value,
- }))
- }
- placeholder="1g"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheKeyTemplate: event.target.value,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheLockEnabled: checked,
- }))
- }
- />
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheLockTimeout: event.target.value,
- }))
- }
- placeholder="5s"
- />
-
-
-
- setOperationFields((previous) => ({
- ...previous,
- OpenRestyCacheUseStale: event.target.value,
- }))
- }
- />
-
-
-
-
-
+
window.location.assign('/performance')}
+ >
+ 前往性能页
+
+ }
+ >
+
+ 这样可以把 Agent 运行参数、系统开关与 OpenResty
+ 专项配置拆开,减少设置页的混杂信息量。
+
+
{feedback ? (
diff --git a/atsf_server/web/lib/constants/navigation.ts b/atsf_server/web/lib/constants/navigation.ts
index 4f99d6fc..bfbc82d0 100644
--- a/atsf_server/web/lib/constants/navigation.ts
+++ b/atsf_server/web/lib/constants/navigation.ts
@@ -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',
+ },
];
diff --git a/atsf_server/web/tests/unit/navigation-utils.test.ts b/atsf_server/web/tests/unit/navigation-utils.test.ts
index 0b371bea..160c7463 100644
--- a/atsf_server/web/tests/unit/navigation-utils.test.ts
+++ b/atsf_server/web/tests/unit/navigation-utils.test.ts
@@ -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('设置');
});
});
diff --git a/atsf_server/web/types/navigation.ts b/atsf_server/web/types/navigation.ts
index 09eac919..34c2224b 100644
--- a/atsf_server/web/types/navigation.ts
+++ b/atsf_server/web/types/navigation.ts
@@ -6,6 +6,7 @@ export type NavigationIconKey =
| 'certificate'
| 'proxy'
| 'release'
+ | 'performance'
| 'user'
| 'setting';
diff --git a/docs/design.md b/docs/design.md
index 074b44f8..e11c8cce 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -63,6 +63,7 @@ ATSFlare 当前定位为内部自用的反向代理控制面,不面向外部
* 主配置文件由 Server 统一生成并由 Agent 受控落地,不开放节点侧手改后再回传合并
* 性能优化参数统一在 Server 配置,不支持按节点分叉不同性能模板
* 第五版不开放任意自定义 Nginx/OpenResty 片段上传,不提供任意指令执行入口, 但是需要保留拓展能力, 为后续开放准备
+* 允许在管理端提供 OpenResty 主配置模板编辑能力,但模板必须保留 ATSFlare 预留占位符,确保性能参数、证书目录与受管路由 include 继续由 Server 统一渲染
新增能力超出上述边界时,必须先更新本文档,再进入实现。
@@ -122,7 +123,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update)
职责分工:
-* Server 负责配置、版本、节点、设置、管理端 UI 以及 OpenResty 主配置渲染
+* Server 负责配置、版本、节点、设置、管理端 UI 以及 OpenResty 主配置模板渲染
* Agent 负责本地落盘、校验、reload、回滚、自更新,不负责维护独立于 Server 的主配置真相
* 发布通过“生成完整版本并激活”完成
* 历史版本不可变
@@ -152,6 +153,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update)
* 域名与证书匹配同时支持精确匹配与通配符匹配
* 节点专属 `agent_token` 必须可立即失效
* OpenResty 性能优化参数与缓存参数统一由 Server 设置管理,不允许节点侧形成额外配置源
+* OpenResty 主配置模板允许编辑,但必须保留系统要求的占位符,不能绕过结构化参数校验与受管 include 注入
* Agent 只应用受控主配置文件,不提供任意配置片段拼接入口
---
@@ -215,7 +217,7 @@ ATSFlare Agent (register / heartbeat / sync / apply / update)
负责:
* 管理端页面、布局、交互与主题
-* 规则、版本、节点、证书、域名、用户、设置等页面
+* 规则、版本、节点、证书、域名、用户、设置、性能等页面
* 统一请求层与前端状态管理
---
diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md
index 7bb48d3b..d7e4bb62 100644
--- a/docs/development-guidelines.md
+++ b/docs/development-guidelines.md
@@ -30,7 +30,7 @@
* 默认不引入 Redis、MQ、对象存储等新基础设施
* 不为未确认的平台化能力预埋复杂抽象
-* OpenResty 性能参数与缓存参数优先复用现有 `Option` 体系管理,不为单一版本额外引入配置中心
+* OpenResty 性能参数、缓存参数与主配置模板优先复用现有 `Option` 体系管理,不为单一版本额外引入配置中心
### 2.2 Agent
@@ -179,6 +179,7 @@ Agent:
* 用通用 shell/命令执行方式替代受限节点操作接口
* 在日志中打印完整 Token
* 为性能优化需求开放任意 OpenResty 文本片段上传或任意指令下发
+* 允许绕过系统占位符约束直接保存不可渲染的 OpenResty 主配置模板
---
@@ -199,6 +200,7 @@ Agent:
* 主配置文件的真相源在 Server,Agent 只负责受控写入、校验与回滚
* 性能优化参数必须通过结构化字段渲染,禁止直接拼接未经校验的自由文本
* 新增参数命名统一采用 `OpenResty...` 前缀,布尔值、整数、大小单位和时间单位必须在更新入口做校验
+* 主配置模板编辑必须保留系统要求的占位符,由 Server 在发布与预览时再渲染为最终 `nginx.conf`
版本号格式保持:
diff --git a/docs/development-plan.md b/docs/development-plan.md
index 7c6d1556..2353310c 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -47,13 +47,14 @@
* 平台化缓存产品、Purge 系统、分层缓存、节点差异化缓存策略
* 任意文本片段注入、任意 OpenResty 指令执行、节点侧自定义模板合并
+* 绕过占位符约束的主配置模板直写
* 引入 Redis、Prometheus、消息队列等新基础设施作为第五版前置条件
## 4. 第五版实施顺序
建议按以下顺序推进:
-1. 补齐 Server 侧 OpenResty 性能参数模型、默认值、校验规则与设置页入口
+1. 补齐 Server 侧 OpenResty 性能参数模型、默认值、校验规则与性能页入口,并补充主配置模板编辑能力
2. 扩展配置渲染结果,使版本快照覆盖主配置文件与路由配置文件
3. 调整 Agent 本地应用链路,支持主配置写入、校验、reload、失败回滚
4. 补齐预览、diff、应用结果与日志,确保第五版能力可观测
@@ -64,6 +65,7 @@
完成第五版时至少满足:
* 管理端可以查看并修改第一批 OpenResty 性能优化项
+* 管理端提供独立“性能”页面,支持结构化设置与主配置模板编辑/预览
* 性能优化项保存后进入统一发布链路,而不是节点即时生效
* Agent 可以接管主配置文件,并在 `openresty -t` 失败时完整回滚
* 配置预览或 diff 能体现主配置与关键性能参数变化
diff --git a/docs/frontend-revamp-plan.md b/docs/frontend-revamp-plan.md
index eb4f9eec..025a7696 100644
--- a/docs/frontend-revamp-plan.md
+++ b/docs/frontend-revamp-plan.md
@@ -55,6 +55,7 @@
* TLS 证书
* 用户管理
* 设置
+* 性能
* 登录、注册、重置密码、GitHub OAuth、关于页
---