From 238546b358515328413d166391560d437869b647 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 12 Mar 2026 23:51:57 +0800 Subject: [PATCH] feat: split openresty settings into performance page --- atsf_server/common/constants.go | 33 + atsf_server/controller/option.go | 3 + atsf_server/model/option.go | 5 + atsf_server/service/config_version.go | 135 ++- atsf_server/service/https_phase1_test.go | 39 + .../web/app/(dashboard)/performance/page.tsx | 5 + .../components/layout/dashboard-sidebar.tsx | 468 ++++---- .../components/performance-page.tsx | 1050 +++++++++++++++++ .../settings/components/settings-page.tsx | 741 +----------- atsf_server/web/lib/constants/navigation.ts | 87 +- .../web/tests/unit/navigation-utils.test.ts | 11 +- atsf_server/web/types/navigation.ts | 1 + docs/design.md | 6 +- docs/development-guidelines.md | 4 +- docs/development-plan.md | 4 +- docs/frontend-revamp-plan.md | 1 + 16 files changed, 1562 insertions(+), 1031 deletions(-) create mode 100644 atsf_server/web/app/(dashboard)/performance/page.tsx create mode 100644 atsf_server/web/features/performance/components/performance-page.tsx 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 ? ( -
-

ATSFlare

-

控制面

-
- ) : null} -
- - + return ( +
+
+
+ AF
- ); + {showLabel ? ( +
+

+ ATSFlare +

+

控制面

+
+ ) : 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、关于页 ---