From 91cf5c80bf0b3832349e53942179f36124a5cdf4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:33:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Nginx=20Stream=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=20(#1210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * feat: 添加 Nginx Stream 支持 (Server 和 Upstream) Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: 优化 * fix: 前端优化 * feat: 优化 * feat: 优化 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 --- internal/apps/nginx/app.go | 9 + internal/apps/nginx/request.go | 23 + internal/apps/nginx/stream.go | 638 +++++++++++++++++ pkg/webserver/nginx/parser.go | 29 + web/src/api/apps/nginx/index.ts | 16 +- web/src/api/apps/openresty/index.ts | 16 +- web/src/views/apps/mariadb/IndexView.vue | 129 +--- web/src/views/apps/mysql/IndexView.vue | 129 +--- web/src/views/apps/mysql/MysqlIndex.vue | 140 ++++ web/src/views/apps/nginx/IndexView.vue | 94 +-- web/src/views/apps/nginx/NginxIndex.vue | 757 ++++++++++++++++++++ web/src/views/apps/openresty/IndexView.vue | 94 +-- web/src/views/apps/percona/IndexView.vue | 129 +--- web/src/views/login/IndexView.vue | 2 +- web/src/views/website/ProxyBuilderModal.vue | 206 ------ 15 files changed, 1637 insertions(+), 774 deletions(-) create mode 100644 internal/apps/nginx/stream.go create mode 100644 web/src/views/apps/mysql/MysqlIndex.vue create mode 100644 web/src/views/apps/nginx/NginxIndex.vue delete mode 100644 web/src/views/website/ProxyBuilderModal.vue diff --git a/internal/apps/nginx/app.go b/internal/apps/nginx/app.go index 493143c9..d533aecc 100644 --- a/internal/apps/nginx/app.go +++ b/internal/apps/nginx/app.go @@ -36,6 +36,15 @@ func (s *App) Route(r chi.Router) { r.Post("/config", s.SaveConfig) r.Get("/error_log", s.ErrorLog) r.Post("/clear_error_log", s.ClearErrorLog) + + r.Get("/stream/servers", s.ListStreamServers) + r.Post("/stream/servers", s.CreateStreamServer) + r.Put("/stream/servers/{name}", s.UpdateStreamServer) + r.Delete("/stream/servers/{name}", s.DeleteStreamServer) + r.Get("/stream/upstreams", s.ListStreamUpstreams) + r.Post("/stream/upstreams", s.CreateStreamUpstream) + r.Put("/stream/upstreams/{name}", s.UpdateStreamUpstream) + r.Delete("/stream/upstreams/{name}", s.DeleteStreamUpstream) } func (s *App) GetConfig(w http.ResponseWriter, r *http.Request) { diff --git a/internal/apps/nginx/request.go b/internal/apps/nginx/request.go index bc394211..fefe95a2 100644 --- a/internal/apps/nginx/request.go +++ b/internal/apps/nginx/request.go @@ -1,5 +1,28 @@ package nginx +import "time" + type UpdateConfig struct { Config string `form:"config" json:"config" validate:"required"` } + +type StreamServer struct { + Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"` // 配置名称,用于文件命名 + Listen string `form:"listen" json:"listen" validate:"required"` // 监听地址,如: "12345", "0.0.0.0:12345", "[::]:12345" + UDP bool `form:"udp" json:"udp"` // 是否 UDP 协议 + ProxyPass string `form:"proxy_pass" json:"proxy_pass" validate:"required"` // 代理地址,如: "127.0.0.1:3306", "upstream_name" + ProxyProtocol bool `form:"proxy_protocol" json:"proxy_protocol"` // 是否启用 PROXY 协议 + ProxyTimeout time.Duration `form:"proxy_timeout" json:"proxy_timeout"` // 代理超时时间 + ProxyConnectTimeout time.Duration `form:"proxy_connect_timeout" json:"proxy_connect_timeout"` // 代理连接超时时间 + SSL bool `form:"ssl" json:"ssl"` // 是否启用 SSL + SSLCertificate string `form:"ssl_certificate" json:"ssl_certificate"` // SSL 证书路径 + SSLCertificateKey string `form:"ssl_certificate_key" json:"ssl_certificate_key"` // SSL 私钥路径 +} + +type StreamUpstream struct { + Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"` // 上游名称 + Servers map[string]string `form:"servers" json:"servers" validate:"required"` // 上游服务器及配置,如: map["127.0.0.1:3306"] = "weight=5" + Algo string `form:"algo" json:"algo"` // 负载均衡算法,如: "least_conn", "hash $remote_addr" + Resolver []string `form:"resolver" json:"resolver"` // DNS 解析器,如: ["8.8.8.8", "ipv6=off"] + ResolverTimeout time.Duration `form:"resolver_timeout" json:"resolver_timeout"` // DNS 解析超时时间 +} diff --git a/internal/apps/nginx/stream.go b/internal/apps/nginx/stream.go new file mode 100644 index 00000000..b23b6eb0 --- /dev/null +++ b/internal/apps/nginx/stream.go @@ -0,0 +1,638 @@ +package nginx + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/acepanel/panel/internal/app" + "github.com/acepanel/panel/internal/service" + "github.com/acepanel/panel/pkg/systemctl" + webserverNginx "github.com/acepanel/panel/pkg/webserver/nginx" + "github.com/go-chi/chi/v5" +) + +// ListStreamServers 获取 Stream Server 列表 +func (s *App) ListStreamServers(w http.ResponseWriter, r *http.Request) { + servers, err := s.parseStreamServers() + if err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to list stream servers: %v", err)) + return + } + service.Success(w, servers) +} + +// CreateStreamServer 创建 Stream Server +func (s *App) CreateStreamServer(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[StreamServer](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", req.Name)) + if _, statErr := os.Stat(configPath); statErr == nil { + service.Error(w, http.StatusConflict, s.t.Get("stream server config already exists: %s", req.Name)) + return + } + + if err = s.saveStreamServerConfig(configPath, req); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to write stream server config: %v", err)) + return + } + + if err = systemctl.Reload("nginx"); err != nil { + _ = os.Remove(configPath) + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// UpdateStreamServer 更新 Stream Server +func (s *App) UpdateStreamServer(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + service.Error(w, http.StatusBadRequest, s.t.Get("name is required")) + return + } + + req, err := service.Bind[StreamServer](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", name)) + if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) { + service.Error(w, http.StatusNotFound, s.t.Get("stream server not found: %s", name)) + return + } + + newConfigPath := configPath + if req.Name != name { + newConfigPath = filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", req.Name)) + if _, statErr := os.Stat(newConfigPath); statErr == nil { + service.Error(w, http.StatusConflict, s.t.Get("stream server config already exists: %s", req.Name)) + return + } + } + + if err = s.saveStreamServerConfig(newConfigPath, req); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to write stream server config: %v", err)) + return + } + + if newConfigPath != configPath { + _ = os.Remove(configPath) + } + + if err = systemctl.Reload("nginx"); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// DeleteStreamServer 删除 Stream Server +func (s *App) DeleteStreamServer(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + service.Error(w, http.StatusBadRequest, s.t.Get("name is required")) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("%s.conf", name)) + if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) { + service.Error(w, http.StatusNotFound, s.t.Get("stream server not found: %s", name)) + return + } + + if err := os.Remove(configPath); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to delete stream server config: %v", err)) + return + } + + if err := systemctl.Reload("nginx"); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// ListStreamUpstreams 获取 Stream Upstream 列表 +func (s *App) ListStreamUpstreams(w http.ResponseWriter, r *http.Request) { + upstreams, err := s.parseStreamUpstreams() + if err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to list stream upstreams: %v", err)) + return + } + service.Success(w, upstreams) +} + +// CreateStreamUpstream 创建 Stream Upstream +func (s *App) CreateStreamUpstream(w http.ResponseWriter, r *http.Request) { + req, err := service.Bind[StreamUpstream](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("upstream_%s.conf", req.Name)) + if _, statErr := os.Stat(configPath); statErr == nil { + service.Error(w, http.StatusConflict, s.t.Get("stream upstream config already exists: %s", req.Name)) + return + } + + if err = s.saveStreamUpstreamConfig(configPath, req); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to write stream upstream config: %v", err)) + return + } + + if err = systemctl.Reload("nginx"); err != nil { + _ = os.Remove(configPath) + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// UpdateStreamUpstream 更新 Stream Upstream +func (s *App) UpdateStreamUpstream(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + service.Error(w, http.StatusBadRequest, s.t.Get("name is required")) + return + } + + req, err := service.Bind[StreamUpstream](r) + if err != nil { + service.Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("upstream_%s.conf", name)) + if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) { + service.Error(w, http.StatusNotFound, s.t.Get("stream upstream not found: %s", name)) + return + } + + newConfigPath := configPath + if req.Name != name { + newConfigPath = filepath.Join(s.streamDir(), fmt.Sprintf("upstream_%s.conf", req.Name)) + if _, statErr := os.Stat(newConfigPath); statErr == nil { + service.Error(w, http.StatusConflict, s.t.Get("stream upstream config already exists: %s", req.Name)) + return + } + } + + if err = s.saveStreamUpstreamConfig(newConfigPath, req); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to write stream upstream config: %v", err)) + return + } + + if newConfigPath != configPath { + _ = os.Remove(configPath) + } + + if err = systemctl.Reload("nginx"); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// DeleteStreamUpstream 删除 Stream Upstream +func (s *App) DeleteStreamUpstream(w http.ResponseWriter, r *http.Request) { + name := chi.URLParam(r, "name") + if name == "" { + service.Error(w, http.StatusBadRequest, s.t.Get("name is required")) + return + } + + configPath := filepath.Join(s.streamDir(), fmt.Sprintf("upstream_%s.conf", name)) + if _, statErr := os.Stat(configPath); os.IsNotExist(statErr) { + service.Error(w, http.StatusNotFound, s.t.Get("stream upstream not found: %s", name)) + return + } + + if err := os.Remove(configPath); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to delete stream upstream config: %v", err)) + return + } + + if err := systemctl.Reload("nginx"); err != nil { + service.Error(w, http.StatusInternalServerError, s.t.Get("failed to reload nginx: %v", err)) + return + } + + service.Success(w, nil) +} + +// parseStreamServers 解析所有 Stream Server 配置 +func (s *App) parseStreamServers() ([]StreamServer, error) { + entries, err := os.ReadDir(s.streamDir()) + if err != nil { + return nil, err + } + + servers := make([]StreamServer, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fileName := entry.Name() + // 跳过 upstream 配置文件 + if strings.HasPrefix(fileName, "upstream_") { + continue + } + if !strings.HasSuffix(fileName, ".conf") { + continue + } + + name := strings.TrimSuffix(fileName, ".conf") + configPath := filepath.Join(s.streamDir(), fileName) + server, err := s.parseStreamServerFile(configPath, name) + if err != nil { + continue // 跳过解析失败的文件 + } + if server != nil { + servers = append(servers, *server) + } + } + + // 按名称排序 + sort.Slice(servers, func(i, j int) bool { + return servers[i].Name < servers[j].Name + }) + + return servers, nil +} + +// parseStreamServerFile 解析单个 Stream Server 配置文件 +func (s *App) parseStreamServerFile(filePath string, name string) (*StreamServer, error) { + p, err := webserverNginx.NewParserFromFile(filePath) + if err != nil { + return nil, err + } + + server := &StreamServer{ + Name: name, + } + + // 解析 listen 指令 + listenDirs, err := p.Find("server.listen") + if err == nil && len(listenDirs) > 0 { + params := listenDirs[0].GetParameters() + if len(params) > 0 { + server.Listen = params[0].Value + for i := 1; i < len(params); i++ { + switch params[i].Value { + case "udp": + server.UDP = true + case "ssl": + server.SSL = true + } + } + } + } + // 解析 proxy_pass 指令 + proxyPassDir, err := p.FindOne("server.proxy_pass") + if err == nil { + params := proxyPassDir.GetParameters() + if len(params) > 0 { + server.ProxyPass = params[0].Value + } + } + // 解析 proxy_protocol 指令 + proxyProtocolDir, err := p.FindOne("server.proxy_protocol") + if err == nil { + params := proxyProtocolDir.GetParameters() + if len(params) > 0 && params[0].Value == "on" { + server.ProxyProtocol = true + } + } + // 解析 proxy_timeout 指令 + proxyTimeoutDir, err := p.FindOne("server.proxy_timeout") + if err == nil { + params := proxyTimeoutDir.GetParameters() + if len(params) > 0 { + server.ProxyTimeout = parseNginxDuration(params[0].Value) + } + } + // 解析 proxy_connect_timeout 指令 + proxyConnectTimeoutDir, err := p.FindOne("server.proxy_connect_timeout") + if err == nil { + params := proxyConnectTimeoutDir.GetParameters() + if len(params) > 0 { + server.ProxyConnectTimeout = parseNginxDuration(params[0].Value) + } + } + // 解析 ssl_certificate 指令 + sslCertDir, err := p.FindOne("server.ssl_certificate") + if err == nil { + params := sslCertDir.GetParameters() + if len(params) > 0 { + server.SSLCertificate = params[0].Value + } + } + // 解析 ssl_certificate_key 指令 + sslKeyDir, err := p.FindOne("server.ssl_certificate_key") + if err == nil { + params := sslKeyDir.GetParameters() + if len(params) > 0 { + server.SSLCertificateKey = params[0].Value + } + } + + return server, nil +} + +// parseStreamUpstreams 解析所有 Stream Upstream 配置 +func (s *App) parseStreamUpstreams() ([]StreamUpstream, error) { + entries, err := os.ReadDir(s.streamDir()) + if err != nil { + return nil, err + } + + upstreams := make([]StreamUpstream, 0) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fileName := entry.Name() + // 只处理 upstream 配置文件 + if !strings.HasPrefix(fileName, "upstream_") { + continue + } + if !strings.HasSuffix(fileName, ".conf") { + continue + } + + name := strings.TrimPrefix(fileName, "upstream_") + name = strings.TrimSuffix(name, ".conf") + configPath := filepath.Join(s.streamDir(), fileName) + upstream, err := s.parseStreamUpstreamFile(configPath, name) + if err != nil { + continue // 跳过解析失败的文件 + } + if upstream != nil { + upstreams = append(upstreams, *upstream) + } + } + + // 按名称排序 + sort.Slice(upstreams, func(i, j int) bool { + return upstreams[i].Name < upstreams[j].Name + }) + + return upstreams, nil +} + +// parseStreamUpstreamFile 解析单个 Stream Upstream 配置文件 +func (s *App) parseStreamUpstreamFile(filePath string, expectedName string) (*StreamUpstream, error) { + p, err := webserverNginx.NewParserFromFile(filePath) + if err != nil { + return nil, err + } + + cfg := p.Config() + if cfg == nil || cfg.Block == nil { + return nil, fmt.Errorf("invalid config") + } + + // 查找 upstream 块 + upstreamDirectives := cfg.Block.FindDirectives("upstream") + if len(upstreamDirectives) == 0 { + return nil, fmt.Errorf("no upstream block found") + } + + upstreamDir := upstreamDirectives[0] + params := upstreamDir.GetParameters() + if len(params) == 0 { + return nil, fmt.Errorf("upstream name not found") + } + + name := params[0].Value + if expectedName != "" && name != expectedName { + return nil, fmt.Errorf("upstream name mismatch") + } + + upstream := &StreamUpstream{ + Name: name, + Servers: make(map[string]string), + Resolver: []string{}, + } + + upstreamBlock := upstreamDir.GetBlock() + if upstreamBlock == nil { + return nil, fmt.Errorf("upstream block is empty") + } + + // 解析 upstream 块中的指令 + for _, dir := range upstreamBlock.GetDirectives() { + switch dir.GetName() { + case "server": + dirParams := dir.GetParameters() + if len(dirParams) > 0 { + addr := dirParams[0].Value + var options []string + for i := 1; i < len(dirParams); i++ { + options = append(options, dirParams[i].Value) + } + upstream.Servers[addr] = strings.Join(options, " ") + } + case "least_conn", "ip_hash", "random": + upstream.Algo = dir.GetName() + case "hash": + dirParams := dir.GetParameters() + if len(dirParams) > 0 { + upstream.Algo = "hash " + dirParams[0].Value + // 检查是否有 consistent 参数 + if len(dirParams) > 1 && dirParams[1].Value == "consistent" { + upstream.Algo += " consistent" + } + } + case "least_time": + dirParams := dir.GetParameters() + if len(dirParams) > 0 { + upstream.Algo = "least_time " + dirParams[0].Value + } + case "resolver": + dirParams := dir.GetParameters() + for _, param := range dirParams { + upstream.Resolver = append(upstream.Resolver, param.Value) + } + case "resolver_timeout": + dirParams := dir.GetParameters() + if len(dirParams) > 0 { + upstream.ResolverTimeout = parseNginxDuration(dirParams[0].Value) + } + } + } + + return upstream, nil +} + +// saveStreamServerConfig 生成并保存 Stream Server 配置 +func (s *App) saveStreamServerConfig(filePath string, server *StreamServer) error { + p, err := webserverNginx.NewParserFromString("server {}") + if err != nil { + return err + } + p.SetConfigPath(filePath) + + // listen 指令 + listenParams := []string{server.Listen} + if server.UDP { + listenParams = append(listenParams, "udp") + } + if server.SSL { + listenParams = append(listenParams, "ssl") + } + if err = p.SetOne("server.listen", listenParams); err != nil { + return err + } + // proxy_pass 指令 + if err = p.SetOne("server.proxy_pass", []string{server.ProxyPass}); err != nil { + return err + } + // proxy_protocol 指令 + if server.ProxyProtocol { + if err = p.SetOne("server.proxy_protocol", []string{"on"}); err != nil { + return err + } + } + // proxy_timeout 指令 + if server.ProxyTimeout > 0 { + if err = p.SetOne("server.proxy_timeout", []string{formatNginxDuration(server.ProxyTimeout)}); err != nil { + return err + } + } + // proxy_connect_timeout 指令 + if server.ProxyConnectTimeout > 0 { + if err = p.SetOne("server.proxy_connect_timeout", []string{formatNginxDuration(server.ProxyConnectTimeout)}); err != nil { + return err + } + } + // SSL 配置 + if server.SSL { + if server.SSLCertificate != "" { + if err = p.SetOne("server.ssl_certificate", []string{server.SSLCertificate}); err != nil { + return err + } + } + if server.SSLCertificateKey != "" { + if err = p.SetOne("server.ssl_certificate_key", []string{server.SSLCertificateKey}); err != nil { + return err + } + } + } + + return os.WriteFile(filePath, []byte(p.Dump()), 0600) +} + +// saveStreamUpstreamConfig 生成并保存 Stream Upstream 配置 +func (s *App) saveStreamUpstreamConfig(filePath string, upstream *StreamUpstream) error { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("upstream %s {\n", upstream.Name)) + + // 负载均衡算法 + if upstream.Algo != "" { + sb.WriteString(fmt.Sprintf(" %s;\n", upstream.Algo)) + } + + // resolver 配置 + if len(upstream.Resolver) > 0 { + sb.WriteString(fmt.Sprintf(" resolver %s;\n", strings.Join(upstream.Resolver, " "))) + if upstream.ResolverTimeout > 0 { + sb.WriteString(fmt.Sprintf(" resolver_timeout %s;\n", formatNginxDuration(upstream.ResolverTimeout))) + } + } + + // 服务器列表 + var addrs []string + for addr := range upstream.Servers { + addrs = append(addrs, addr) + } + sort.Strings(addrs) + + for _, addr := range addrs { + options := upstream.Servers[addr] + if options != "" { + sb.WriteString(fmt.Sprintf(" server %s %s;\n", addr, options)) + } else { + sb.WriteString(fmt.Sprintf(" server %s;\n", addr)) + } + } + + sb.WriteString("}\n") + + return os.WriteFile(filePath, []byte(sb.String()), 0600) +} + +// parseNginxDuration 解析 Nginx 时间格式(如 10s, 1m, 1h) +func parseNginxDuration(value string) time.Duration { + if value == "" { + return 0 + } + + // 尝试解析带单位的时间 + value = strings.TrimSpace(value) + if len(value) == 0 { + return 0 + } + + unit := value[len(value)-1] + numStr := value[:len(value)-1] + + var num int + _, _ = fmt.Sscanf(numStr, "%d", &num) + + switch unit { + case 's': + return time.Duration(num) * time.Second + case 'm': + return time.Duration(num) * time.Minute + case 'h': + return time.Duration(num) * time.Hour + case 'd': + return time.Duration(num) * 24 * time.Hour + default: + // 没有单位,尝试直接解析为秒 + _, _ = fmt.Sscanf(value, "%d", &num) + return time.Duration(num) * time.Second + } +} + +// formatNginxDuration 格式化时间为 Nginx 格式 +func formatNginxDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + + seconds := int(d.Seconds()) + if seconds%3600 == 0 { + return fmt.Sprintf("%dh", seconds/3600) + } + if seconds%60 == 0 { + return fmt.Sprintf("%dm", seconds/60) + } + return fmt.Sprintf("%ds", seconds) +} + +// streamDir 返回 stream 配置目录 +func (s *App) streamDir() string { + return filepath.Join(app.Root, "server/nginx/conf/stream") +} diff --git a/pkg/webserver/nginx/parser.go b/pkg/webserver/nginx/parser.go index 41431bc3..6724130b 100644 --- a/pkg/webserver/nginx/parser.go +++ b/pkg/webserver/nginx/parser.go @@ -47,6 +47,17 @@ func NewParserFromFile(filePath string) (*Parser, error) { return &Parser{cfg: cfg, cfgPath: filePath}, nil } +// NewParserFromString 从字符串创建解析器 +func NewParserFromString(content string) (*Parser, error) { + p := parser.NewStringParser(content, parser.WithSkipIncludeParsingErr(), parser.WithSkipValidDirectivesErr()) + cfg, err := p.Parse() + if err != nil { + return nil, fmt.Errorf("failed to parse config: %w", err) + } + + return &Parser{cfg: cfg, cfgPath: ""}, nil +} + func (p *Parser) Config() *config.Config { return p.cfg } @@ -184,6 +195,24 @@ func (p *Parser) Set(key string, directives []*config.Directive, after ...string return nil } +// SetOne 设置单个指令,如: SetOne("server.listen", []string{"80"}) +func (p *Parser) SetOne(key string, params []string) error { + parts := strings.Split(key, ".") + if len(parts) < 2 { + return fmt.Errorf("key must have at least 2 parts: %s", key) + } + + directiveName := parts[len(parts)-1] + blockKey := strings.Join(parts[:len(parts)-1], ".") + + return p.Set(blockKey, []*config.Directive{ + { + Name: directiveName, + Parameters: p.slices2Parameters(params), + }, + }) +} + // Dump 将指令结构导出为配置内容 func (p *Parser) Dump() string { return dumper.DumpConfig(p.cfg, dumper.IndentedStyle) diff --git a/web/src/api/apps/nginx/index.ts b/web/src/api/apps/nginx/index.ts index 14127b14..8a28da5f 100644 --- a/web/src/api/apps/nginx/index.ts +++ b/web/src/api/apps/nginx/index.ts @@ -10,5 +10,19 @@ export default { // 获取错误日志 errorLog: (): any => http.Get('/apps/nginx/error_log'), // 清空错误日志 - clearErrorLog: (): any => http.Post('/apps/nginx/clear_error_log') + clearErrorLog: (): any => http.Post('/apps/nginx/clear_error_log'), + + // Stream Server 接口 + stream: { + listServers: (): any => http.Get('/apps/nginx/stream/servers'), + createServer: (data: any): any => http.Post('/apps/nginx/stream/servers', data), + updateServer: (name: string, data: any): any => + http.Put(`/apps/nginx/stream/servers/${name}`, data), + deleteServer: (name: string): any => http.Delete(`/apps/nginx/stream/servers/${name}`), + listUpstreams: (): any => http.Get('/apps/nginx/stream/upstreams'), + createUpstream: (data: any): any => http.Post('/apps/nginx/stream/upstreams', data), + updateUpstream: (name: string, data: any): any => + http.Put(`/apps/nginx/stream/upstreams/${name}`, data), + deleteUpstream: (name: string): any => http.Delete(`/apps/nginx/stream/upstreams/${name}`) + } } diff --git a/web/src/api/apps/openresty/index.ts b/web/src/api/apps/openresty/index.ts index 3a7f09b5..e9e16b61 100644 --- a/web/src/api/apps/openresty/index.ts +++ b/web/src/api/apps/openresty/index.ts @@ -10,5 +10,19 @@ export default { // 获取错误日志 errorLog: (): any => http.Get('/apps/openresty/error_log'), // 清空错误日志 - clearErrorLog: (): any => http.Post('/apps/openresty/clear_error_log') + clearErrorLog: (): any => http.Post('/apps/openresty/clear_error_log'), + + // Stream Server 接口 + stream: { + listServers: (): any => http.Get('/apps/openresty/stream/servers'), + createServer: (data: any): any => http.Post('/apps/openresty/stream/servers', data), + updateServer: (name: string, data: any): any => + http.Put(`/apps/openresty/stream/servers/${name}`, data), + deleteServer: (name: string): any => http.Delete(`/apps/openresty/stream/servers/${name}`), + listUpstreams: (): any => http.Get('/apps/openresty/stream/upstreams'), + createUpstream: (data: any): any => http.Post('/apps/openresty/stream/upstreams', data), + updateUpstream: (name: string, data: any): any => + http.Put(`/apps/openresty/stream/upstreams/${name}`, data), + deleteUpstream: (name: string): any => http.Delete(`/apps/openresty/stream/upstreams/${name}`) + } } diff --git a/web/src/views/apps/mariadb/IndexView.vue b/web/src/views/apps/mariadb/IndexView.vue index de284eff..ec948b74 100644 --- a/web/src/views/apps/mariadb/IndexView.vue +++ b/web/src/views/apps/mariadb/IndexView.vue @@ -3,135 +3,10 @@ defineOptions({ name: 'apps-mariadb-index' }) -import copy2clipboard from '@vavt/copy2clipboard' -import { NButton, NDataTable, NInput } from 'naive-ui' -import { useGettext } from 'vue3-gettext' - import mariadb from '@/api/apps/mariadb' -import ServiceStatus from '@/components/common/ServiceStatus.vue' - -const { $gettext } = useGettext() -const currentTab = ref('status') - -const { data: rootPassword } = useRequest(mariadb.rootPassword, { - initialData: '' -}) -const { data: config } = useRequest(mariadb.config, { - initialData: '' -}) -const { data: slowLog } = useRequest(mariadb.slowLog, { - initialData: '' -}) -const { data: load } = useRequest(mariadb.load, { - initialData: [] -}) - -const loadColumns: any = [ - { - title: $gettext('Property'), - key: 'name', - minWidth: 200, - resizable: true, - ellipsis: { tooltip: true } - }, - { - title: $gettext('Current Value'), - key: 'value', - minWidth: 200, - ellipsis: { tooltip: true } - } -] - -const handleSaveConfig = () => { - useRequest(mariadb.saveConfig(config.value)).onSuccess(() => { - window.$message.success($gettext('Saved successfully')) - }) -} - -const handleClearLog = () => { - useRequest(mariadb.clearLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleClearSlowLog = () => { - useRequest(mariadb.clearSlowLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleSetRootPassword = async () => { - await mariadb.setRootPassword(rootPassword.value) - window.$message.success($gettext('Modified successfully')) -} - -const handleCopyRootPassword = () => { - copy2clipboard(rootPassword.value).then(() => { - window.$message.success($gettext('Copied successfully')) - }) -} +import MysqlIndex from '@/views/apps/mysql/MysqlIndex.vue' diff --git a/web/src/views/apps/mysql/IndexView.vue b/web/src/views/apps/mysql/IndexView.vue index 05020c2b..5f61a3dc 100644 --- a/web/src/views/apps/mysql/IndexView.vue +++ b/web/src/views/apps/mysql/IndexView.vue @@ -3,135 +3,10 @@ defineOptions({ name: 'apps-mysql-index' }) -import copy2clipboard from '@vavt/copy2clipboard' -import { NButton, NDataTable, NInput } from 'naive-ui' -import { useGettext } from 'vue3-gettext' - import mysql from '@/api/apps/mysql' -import ServiceStatus from '@/components/common/ServiceStatus.vue' - -const { $gettext } = useGettext() -const currentTab = ref('status') - -const { data: rootPassword } = useRequest(mysql.rootPassword, { - initialData: '' -}) -const { data: config } = useRequest(mysql.config, { - initialData: '' -}) -const { data: slowLog } = useRequest(mysql.slowLog, { - initialData: '' -}) -const { data: load } = useRequest(mysql.load, { - initialData: [] -}) - -const loadColumns: any = [ - { - title: $gettext('Property'), - key: 'name', - minWidth: 200, - resizable: true, - ellipsis: { tooltip: true } - }, - { - title: $gettext('Current Value'), - key: 'value', - minWidth: 200, - ellipsis: { tooltip: true } - } -] - -const handleSaveConfig = () => { - useRequest(mysql.saveConfig(config.value)).onSuccess(() => { - window.$message.success($gettext('Saved successfully')) - }) -} - -const handleClearLog = () => { - useRequest(mysql.clearLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleClearSlowLog = () => { - useRequest(mysql.clearSlowLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleSetRootPassword = async () => { - await mysql.setRootPassword(rootPassword.value) - window.$message.success($gettext('Modified successfully')) -} - -const handleCopyRootPassword = () => { - copy2clipboard(rootPassword.value).then(() => { - window.$message.success($gettext('Copied successfully')) - }) -} +import MysqlIndex from './MysqlIndex.vue' diff --git a/web/src/views/apps/mysql/MysqlIndex.vue b/web/src/views/apps/mysql/MysqlIndex.vue new file mode 100644 index 00000000..7df4402e --- /dev/null +++ b/web/src/views/apps/mysql/MysqlIndex.vue @@ -0,0 +1,140 @@ + + + diff --git a/web/src/views/apps/nginx/IndexView.vue b/web/src/views/apps/nginx/IndexView.vue index 449755b2..8fec20c5 100644 --- a/web/src/views/apps/nginx/IndexView.vue +++ b/web/src/views/apps/nginx/IndexView.vue @@ -3,100 +3,10 @@ defineOptions({ name: 'apps-nginx-index' }) -import { NButton, NDataTable } from 'naive-ui' -import { useGettext } from 'vue3-gettext' - import nginx from '@/api/apps/nginx' -import ServiceStatus from '@/components/common/ServiceStatus.vue' - -const { $gettext } = useGettext() -const currentTab = ref('status') - -const { data: config } = useRequest(nginx.config, { - initialData: '' -}) -const { data: errorLog } = useRequest(nginx.errorLog, { - initialData: '' -}) -const { data: load } = useRequest(nginx.load, { - initialData: [] -}) - -const columns: any = [ - { - title: $gettext('Property'), - key: 'name', - minWidth: 200, - resizable: true, - ellipsis: { tooltip: true } - }, - { - title: $gettext('Current Value'), - key: 'value', - minWidth: 200, - ellipsis: { tooltip: true } - } -] - -const handleSaveConfig = () => { - useRequest(nginx.saveConfig(config.value)).onSuccess(() => { - window.$message.success($gettext('Saved successfully')) - }) -} - -const handleClearErrorLog = () => { - useRequest(nginx.clearErrorLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} +import NginxIndex from './NginxIndex.vue' diff --git a/web/src/views/apps/nginx/NginxIndex.vue b/web/src/views/apps/nginx/NginxIndex.vue new file mode 100644 index 00000000..351d20b6 --- /dev/null +++ b/web/src/views/apps/nginx/NginxIndex.vue @@ -0,0 +1,757 @@ + + + diff --git a/web/src/views/apps/openresty/IndexView.vue b/web/src/views/apps/openresty/IndexView.vue index 3e1251e6..2509b903 100644 --- a/web/src/views/apps/openresty/IndexView.vue +++ b/web/src/views/apps/openresty/IndexView.vue @@ -3,100 +3,10 @@ defineOptions({ name: 'apps-openresty-index' }) -import { NButton, NDataTable } from 'naive-ui' -import { useGettext } from 'vue3-gettext' - import openresty from '@/api/apps/openresty' -import ServiceStatus from '@/components/common/ServiceStatus.vue' - -const { $gettext } = useGettext() -const currentTab = ref('status') - -const { data: config } = useRequest(openresty.config, { - initialData: '' -}) -const { data: errorLog } = useRequest(openresty.errorLog, { - initialData: '' -}) -const { data: load } = useRequest(openresty.load, { - initialData: [] -}) - -const columns: any = [ - { - title: $gettext('Property'), - key: 'name', - minWidth: 200, - resizable: true, - ellipsis: { tooltip: true } - }, - { - title: $gettext('Current Value'), - key: 'value', - minWidth: 200, - ellipsis: { tooltip: true } - } -] - -const handleSaveConfig = () => { - useRequest(openresty.saveConfig(config.value)).onSuccess(() => { - window.$message.success($gettext('Saved successfully')) - }) -} - -const handleClearErrorLog = () => { - useRequest(openresty.clearErrorLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} +import NginxIndex from '@/views/apps/nginx/NginxIndex.vue' diff --git a/web/src/views/apps/percona/IndexView.vue b/web/src/views/apps/percona/IndexView.vue index b55318f6..227cf9c7 100644 --- a/web/src/views/apps/percona/IndexView.vue +++ b/web/src/views/apps/percona/IndexView.vue @@ -3,135 +3,10 @@ defineOptions({ name: 'apps-percona-index' }) -import copy2clipboard from '@vavt/copy2clipboard' -import { NButton, NDataTable, NInput } from 'naive-ui' -import { useGettext } from 'vue3-gettext' - import percona from '@/api/apps/percona' -import ServiceStatus from '@/components/common/ServiceStatus.vue' - -const { $gettext } = useGettext() -const currentTab = ref('status') - -const { data: rootPassword } = useRequest(percona.rootPassword, { - initialData: '' -}) -const { data: config } = useRequest(percona.config, { - initialData: '' -}) -const { data: slowLog } = useRequest(percona.slowLog, { - initialData: '' -}) -const { data: load } = useRequest(percona.load, { - initialData: [] -}) - -const loadColumns: any = [ - { - title: $gettext('Property'), - key: 'name', - minWidth: 200, - resizable: true, - ellipsis: { tooltip: true } - }, - { - title: $gettext('Current Value'), - key: 'value', - minWidth: 200, - ellipsis: { tooltip: true } - } -] - -const handleSaveConfig = () => { - useRequest(percona.saveConfig(config.value)).onSuccess(() => { - window.$message.success($gettext('Saved successfully')) - }) -} - -const handleClearLog = () => { - useRequest(percona.clearLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleClearSlowLog = () => { - useRequest(percona.clearSlowLog()).onSuccess(() => { - window.$message.success($gettext('Cleared successfully')) - }) -} - -const handleSetRootPassword = async () => { - await percona.setRootPassword(rootPassword.value) - window.$message.success($gettext('Modified successfully')) -} - -const handleCopyRootPassword = () => { - copy2clipboard(rootPassword.value).then(() => { - window.$message.success($gettext('Copied successfully')) - }) -} +import MysqlIndex from '@/views/apps/mysql/MysqlIndex.vue' diff --git a/web/src/views/login/IndexView.vue b/web/src/views/login/IndexView.vue index 80437d33..3db2df1b 100644 --- a/web/src/views/login/IndexView.vue +++ b/web/src/views/login/IndexView.vue @@ -205,7 +205,7 @@ watch(isLogin, async () => { diff --git a/web/src/views/website/ProxyBuilderModal.vue b/web/src/views/website/ProxyBuilderModal.vue deleted file mode 100644 index 4f7f1e01..00000000 --- a/web/src/views/website/ProxyBuilderModal.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - - -