2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 05:31:44 +08:00

feat: 添加 Nginx Stream 支持 (#1210)

* 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: 耗子 <haozi@loli.email>
This commit is contained in:
Copilot
2026-01-10 18:33:04 +08:00
committed by GitHub
parent 295936d792
commit 91cf5c80bf
15 changed files with 1637 additions and 774 deletions

View File

@@ -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) {

View File

@@ -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 解析超时时间
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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}`)
}
}

View File

@@ -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}`)
}
}

View File

@@ -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'
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<n-flex vertical>
<service-status service="mysqld" />
<n-card :title="$gettext('Root Password')">
<n-flex>
<n-input-group>
<n-input v-model:value="rootPassword" type="password" show-password-on="click" />
<n-button type="primary" ghost @click="handleCopyRootPassword">
{{ $gettext('Copy') }}
</n-button>
</n-input-group>
<n-button type="primary" @click="handleSetRootPassword">
{{ $gettext('Save Changes') }}
</n-button>
</n-flex>
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the MariaDB main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="loadColumns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<n-button type="primary" @click="handleClearLog">
{{ $gettext('Clear Log') }}
</n-button>
<realtime-log service="mysqld" />
</n-tab-pane>
<n-tab-pane name="slow-log" :tab="$gettext('Slow Query Log')">
<n-button type="primary" @click="handleClearSlowLog">
{{ $gettext('Clear Slow Log') }}
</n-button>
<realtime-log :path="slowLog" />
</n-tab-pane>
</n-tabs>
</common-page>
<mysql-index :api="mariadb" name="MariaDB" />
</template>

View File

@@ -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'
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<n-flex vertical>
<service-status service="mysqld" />
<n-card :title="$gettext('Root Password')">
<n-flex>
<n-input-group>
<n-input v-model:value="rootPassword" type="password" show-password-on="click" />
<n-button type="primary" ghost @click="handleCopyRootPassword">
{{ $gettext('Copy') }}
</n-button>
</n-input-group>
<n-button type="primary" @click="handleSetRootPassword">
{{ $gettext('Save Changes') }}
</n-button>
</n-flex>
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the MySQL main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="loadColumns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<n-button type="primary" @click="handleClearLog">
{{ $gettext('Clear Log') }}
</n-button>
<realtime-log service="mysqld" />
</n-tab-pane>
<n-tab-pane name="slow-log" :tab="$gettext('Slow Query Log')">
<n-button type="primary" @click="handleClearSlowLog">
{{ $gettext('Clear Slow Log') }}
</n-button>
<realtime-log :path="slowLog" />
</n-tab-pane>
</n-tabs>
</common-page>
<mysql-index :api="mysql" name="MySQL" />
</template>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
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 props = defineProps<{
api: typeof mysql
name: string
}>()
const { $gettext } = useGettext()
const currentTab = ref('status')
const { data: rootPassword } = useRequest(props.api.rootPassword, {
initialData: ''
})
const { data: config } = useRequest(props.api.config, {
initialData: ''
})
const { data: slowLog } = useRequest(props.api.slowLog, {
initialData: ''
})
const { data: load } = useRequest(props.api.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(props.api.saveConfig(config.value)).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
})
}
const handleClearLog = () => {
useRequest(props.api.clearLog()).onSuccess(() => {
window.$message.success($gettext('Cleared successfully'))
})
}
const handleClearSlowLog = () => {
useRequest(props.api.clearSlowLog()).onSuccess(() => {
window.$message.success($gettext('Cleared successfully'))
})
}
const handleSetRootPassword = () => {
useRequest(props.api.setRootPassword(rootPassword.value)).onSuccess(() => {
window.$message.success($gettext('Modified successfully'))
})
}
const handleCopyRootPassword = () => {
copy2clipboard(rootPassword.value).then(() => {
window.$message.success($gettext('Copied successfully'))
})
}
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<n-flex vertical>
<service-status service="mysqld" />
<n-card :title="$gettext('Root Password')">
<n-flex>
<n-input-group>
<n-input v-model:value="rootPassword" type="password" show-password-on="click" />
<n-button type="primary" ghost @click="handleCopyRootPassword">
{{ $gettext('Copy') }}
</n-button>
</n-input-group>
<n-button type="primary" @click="handleSetRootPassword">
{{ $gettext('Save Changes') }}
</n-button>
</n-flex>
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the %{ name } main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!',
{ name }
)
}}
</n-alert>
<common-editor v-model:value="config" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="loadColumns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<n-button type="primary" @click="handleClearLog">
{{ $gettext('Clear Log') }}
</n-button>
<realtime-log service="mysqld" />
</n-tab-pane>
<n-tab-pane name="slow-log" :tab="$gettext('Slow Query Log')">
<n-button type="primary" @click="handleClearSlowLog">
{{ $gettext('Clear Slow Log') }}
</n-button>
<realtime-log :path="slowLog" />
</n-tab-pane>
</n-tabs>
</common-page>
</template>

View File

@@ -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'
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<service-status service="nginx" show-reload />
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the OpenResty main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" lang="nginx" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="columns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<realtime-log service="nginx" />
</n-tab-pane>
<n-tab-pane name="error-log" :tab="$gettext('Error Logs')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="handleClearErrorLog">
{{ $gettext('Clear Log') }}
</n-button>
</n-flex>
<realtime-log :path="errorLog" />
</n-flex>
</n-tab-pane>
</n-tabs>
</common-page>
<nginx-index :api="nginx" service="nginx" />
</template>

View File

@@ -0,0 +1,757 @@
<script setup lang="ts">
import {
NButton,
NDataTable,
NDynamicTags,
NInputGroup,
NInputNumber,
NPopconfirm,
NSelect
} from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import nginx from '@/api/apps/nginx'
import ServiceStatus from '@/components/common/ServiceStatus.vue'
const props = defineProps<{
api: typeof nginx
service: string
}>()
const { $gettext } = useGettext()
const currentTab = ref('status')
const streamTab = ref('server')
// 时间单位常量(纳秒)
const SECOND = 1000000000
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
// 从纳秒解析为 {value, unit} 格式
const parseDuration = (ns: number): { value: number; unit: string } => {
if (!ns || ns <= 0) return { value: 5, unit: 's' }
if (ns >= HOUR && ns % HOUR === 0) {
return { value: ns / HOUR, unit: 'h' }
}
if (ns >= MINUTE && ns % MINUTE === 0) {
return { value: ns / MINUTE, unit: 'm' }
}
return { value: Math.floor(ns / SECOND), unit: 's' }
}
// 构建纳秒时间
const buildDuration = (value: number, unit: string): number => {
switch (unit) {
case 'h':
return value * HOUR
case 'm':
return value * MINUTE
default:
return value * SECOND
}
}
// 更新超时时间值
const updateResolverTimeoutValue = (value: number) => {
const parsed = parseDuration(streamUpstreamModel.value.resolver_timeout)
streamUpstreamModel.value.resolver_timeout = buildDuration(value, parsed.unit)
}
// 更新超时时间单位
const updateResolverTimeoutUnit = (unit: string) => {
const parsed = parseDuration(streamUpstreamModel.value.resolver_timeout)
streamUpstreamModel.value.resolver_timeout = buildDuration(parsed.value, unit)
}
const { data: config } = useRequest(props.api.config, {
initialData: ''
})
const { data: errorLog } = useRequest(props.api.errorLog, {
initialData: ''
})
const { data: load } = useRequest(props.api.load, {
initialData: []
})
// Stream Server 数据
const {
data: streamServers,
loading: streamServersLoading,
refresh: loadStreamServers
} = usePagination(props.api.stream.listServers, {
initialData: []
})
// Stream Upstream 数据
const {
data: streamUpstreams,
loading: streamUpstreamsLoading,
refresh: loadStreamUpstreams
} = usePagination(props.api.stream.listUpstreams, {
initialData: []
})
// 创建/编辑 Stream Server 模态框
const streamServerModal = ref(false)
const streamServerModalTitle = ref('')
const streamServerEditName = ref('')
const streamServerModel = ref({
name: '',
listen: '',
udp: false,
proxy_pass: '',
proxy_protocol: false,
proxy_timeout: 0,
proxy_connect_timeout: 0,
ssl: false,
ssl_certificate: '',
ssl_certificate_key: ''
})
// 创建/编辑 Stream Upstream 模态框
const streamUpstreamModal = ref(false)
const streamUpstreamModalTitle = ref('')
const streamUpstreamEditName = ref('')
const streamUpstreamModel = ref({
name: '',
algo: '',
servers: {} as Record<string, string>,
resolver: [] as string[],
resolver_timeout: 5 * SECOND
})
// Upstream 服务器编辑
const upstreamServerAddr = ref('')
const upstreamServerOptions = ref('')
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 }
}
]
// Stream Server 列表列
const streamServerColumns: any = [
{
title: $gettext('Name'),
key: 'name',
minWidth: 150,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Listen'),
key: 'listen',
minWidth: 120,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Protocol'),
key: 'protocol',
minWidth: 80,
render(row: any) {
return row.udp ? 'UDP' : 'TCP'
}
},
{
title: $gettext('Proxy Pass'),
key: 'proxy_pass',
minWidth: 200,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: 'SSL',
key: 'ssl',
minWidth: 60,
render(row: any) {
return row.ssl ? $gettext('Yes') : $gettext('No')
}
},
{
title: $gettext('Actions'),
key: 'actions',
width: 200,
render(row: any) {
return [
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => handleEditStreamServer(row)
},
{
default: () => $gettext('Edit')
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleDeleteStreamServer(row.name)
},
{
default: () => {
return $gettext('Are you sure you want to delete %{ name }?', { name: row.name })
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px'
},
{
default: () => $gettext('Delete')
}
)
}
}
)
]
}
}
]
// Stream Upstream 列表列
const streamUpstreamColumns: any = [
{
title: $gettext('Name'),
key: 'name',
minWidth: 150,
resizable: true,
ellipsis: { tooltip: true }
},
{
title: $gettext('Algorithm'),
key: 'algo',
minWidth: 120,
resizable: true,
ellipsis: { tooltip: true },
render(row: any) {
return row.algo || $gettext('Round Robin')
}
},
{
title: $gettext('Servers'),
key: 'servers',
minWidth: 200,
resizable: true,
ellipsis: { tooltip: true },
render(row: any) {
const servers = row.servers || {}
return Object.keys(servers).length + $gettext(' server(s)')
}
},
{
title: $gettext('Actions'),
key: 'actions',
width: 200,
render(row: any) {
return [
h(
NButton,
{
size: 'small',
type: 'info',
onClick: () => handleEditStreamUpstream(row)
},
{
default: () => $gettext('Edit')
}
),
h(
NPopconfirm,
{
onPositiveClick: () => handleDeleteStreamUpstream(row.name)
},
{
default: () => {
return $gettext('Are you sure you want to delete %{ name }?', { name: row.name })
},
trigger: () => {
return h(
NButton,
{
size: 'small',
type: 'error',
style: 'margin-left: 15px'
},
{
default: () => $gettext('Delete')
}
)
}
}
)
]
}
}
]
// 监听标签页切换
watch(currentTab, (val) => {
if (val === 'stream') {
loadStreamServers()
loadStreamUpstreams()
}
})
watch(streamTab, (val) => {
if (val === 'server') {
loadStreamServers()
} else if (val === 'upstream') {
loadStreamUpstreams()
}
})
const handleSaveConfig = () => {
useRequest(props.api.saveConfig(config.value)).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
})
}
const handleClearErrorLog = () => {
useRequest(props.api.clearErrorLog()).onSuccess(() => {
window.$message.success($gettext('Cleared successfully'))
})
}
// Stream Server 操作
const handleCreateStreamServer = () => {
streamServerModalTitle.value = $gettext('Add Stream Server')
streamServerEditName.value = ''
streamServerModel.value = {
name: '',
listen: '',
udp: false,
proxy_pass: '',
proxy_protocol: false,
proxy_timeout: 0,
proxy_connect_timeout: 0,
ssl: false,
ssl_certificate: '',
ssl_certificate_key: ''
}
streamServerModal.value = true
}
const handleEditStreamServer = (row: any) => {
streamServerModalTitle.value = $gettext('Edit Stream Server')
streamServerEditName.value = row.name
streamServerModel.value = {
name: row.name,
listen: row.listen,
udp: row.udp || false,
proxy_pass: row.proxy_pass,
proxy_protocol: row.proxy_protocol || false,
proxy_timeout: row.proxy_timeout ? row.proxy_timeout / 1000000000 : 0,
proxy_connect_timeout: row.proxy_connect_timeout ? row.proxy_connect_timeout / 1000000000 : 0,
ssl: row.ssl || false,
ssl_certificate: row.ssl_certificate || '',
ssl_certificate_key: row.ssl_certificate_key || ''
}
streamServerModal.value = true
}
const handleSaveStreamServer = () => {
const data = {
...streamServerModel.value,
proxy_timeout: streamServerModel.value.proxy_timeout * 1000000000,
proxy_connect_timeout: streamServerModel.value.proxy_connect_timeout * 1000000000
}
const request = streamServerEditName.value
? props.api.stream.updateServer(streamServerEditName.value, data)
: props.api.stream.createServer(data)
useRequest(request).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
streamServerModal.value = false
loadStreamServers()
})
}
const handleDeleteStreamServer = (name: string) => {
useRequest(props.api.stream.deleteServer(name)).onSuccess(() => {
window.$message.success($gettext('Deleted successfully'))
loadStreamServers()
})
}
// Stream Upstream 操作
const handleCreateStreamUpstream = () => {
streamUpstreamModalTitle.value = $gettext('Add Stream Upstream')
streamUpstreamEditName.value = ''
streamUpstreamModel.value = {
name: '',
algo: '',
servers: {},
resolver: [],
resolver_timeout: 5 * SECOND
}
upstreamServerAddr.value = ''
upstreamServerOptions.value = ''
streamUpstreamModal.value = true
}
const handleEditStreamUpstream = (row: any) => {
streamUpstreamModalTitle.value = $gettext('Edit Stream Upstream')
streamUpstreamEditName.value = row.name
streamUpstreamModel.value = {
name: row.name,
algo: row.algo || '',
servers: { ...row.servers },
resolver: row.resolver,
resolver_timeout: row.resolver_timeout || 5 * SECOND
}
upstreamServerAddr.value = ''
upstreamServerOptions.value = ''
streamUpstreamModal.value = true
}
const handleAddUpstreamServer = () => {
if (!upstreamServerAddr.value) {
window.$message.warning($gettext('Please enter server address'))
return
}
streamUpstreamModel.value.servers[upstreamServerAddr.value] = upstreamServerOptions.value
upstreamServerAddr.value = ''
upstreamServerOptions.value = ''
}
const handleRemoveUpstreamServer = (addr: string) => {
delete streamUpstreamModel.value.servers[addr]
}
const handleSaveStreamUpstream = () => {
if (Object.keys(streamUpstreamModel.value.servers).length === 0) {
window.$message.warning($gettext('Please add at least one server'))
return
}
const data = {
name: streamUpstreamModel.value.name,
algo: streamUpstreamModel.value.algo,
servers: streamUpstreamModel.value.servers,
resolver: streamUpstreamModel.value.resolver,
resolver_timeout: streamUpstreamModel.value.resolver_timeout
}
const request = streamUpstreamEditName.value
? props.api.stream.updateUpstream(streamUpstreamEditName.value, data)
: props.api.stream.createUpstream(data)
useRequest(request).onSuccess(() => {
window.$message.success($gettext('Saved successfully'))
streamUpstreamModal.value = false
loadStreamUpstreams()
})
}
const handleDeleteStreamUpstream = (name: string) => {
useRequest(props.api.stream.deleteUpstream(name)).onSuccess(() => {
window.$message.success($gettext('Deleted successfully'))
loadStreamUpstreams()
})
}
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<service-status :service="service" show-reload />
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the OpenResty main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" lang="nginx" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="stream" :tab="$gettext('Stream')">
<n-tabs v-model:value="streamTab" type="line" placement="left" animated>
<n-tab-pane name="server" :tab="$gettext('Server')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="handleCreateStreamServer">
{{ $gettext('Add Server') }}
</n-button>
</n-flex>
<n-data-table
striped
:scroll-x="800"
:loading="streamServersLoading"
:columns="streamServerColumns"
:data="streamServers"
:row-key="(row: any) => row.name"
/>
</n-flex>
</n-tab-pane>
<n-tab-pane name="upstream" :tab="$gettext('Upstream')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="handleCreateStreamUpstream">
{{ $gettext('Add Upstream') }}
</n-button>
</n-flex>
<n-data-table
striped
:scroll-x="600"
:loading="streamUpstreamsLoading"
:columns="streamUpstreamColumns"
:data="streamUpstreams"
:row-key="(row: any) => row.name"
/>
</n-flex>
</n-tab-pane>
</n-tabs>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="columns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<realtime-log :service="service" />
</n-tab-pane>
<n-tab-pane name="error-log" :tab="$gettext('Error Logs')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="handleClearErrorLog">
{{ $gettext('Clear Log') }}
</n-button>
</n-flex>
<realtime-log :path="errorLog" />
</n-flex>
</n-tab-pane>
</n-tabs>
</common-page>
<!-- Stream Server 模态框 -->
<n-modal
v-model:show="streamServerModal"
preset="card"
:title="streamServerModalTitle"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
@close="streamServerModal = false"
>
<n-form :model="streamServerModel">
<n-form-item path="name" :label="$gettext('Name')">
<n-input
v-model:value="streamServerModel.name"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Only letters, numbers, underscores and hyphens')"
/>
</n-form-item>
<n-form-item path="listen" :label="$gettext('Listen Address')">
<n-input
v-model:value="streamServerModel.listen"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g. 12345 or 0.0.0.0:12345')"
/>
</n-form-item>
<n-form-item path="proxy_pass" :label="$gettext('Proxy Pass')">
<n-input
v-model:value="streamServerModel.proxy_pass"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g. 127.0.0.1:3306 or upstream_name')"
/>
</n-form-item>
<n-form-item path="udp" :label="$gettext('UDP Protocol')">
<n-switch v-model:value="streamServerModel.udp" />
</n-form-item>
<n-form-item path="proxy_protocol" :label="$gettext('Proxy Protocol')">
<n-switch v-model:value="streamServerModel.proxy_protocol" />
</n-form-item>
<n-form-item path="proxy_timeout" :label="$gettext('Proxy Timeout (seconds)')">
<n-input-number v-model:value="streamServerModel.proxy_timeout" :min="0" />
</n-form-item>
<n-form-item path="proxy_connect_timeout" :label="$gettext('Connect Timeout (seconds)')">
<n-input-number v-model:value="streamServerModel.proxy_connect_timeout" :min="0" />
</n-form-item>
<n-form-item path="ssl" :label="$gettext('Enable SSL')">
<n-switch v-model:value="streamServerModel.ssl" />
</n-form-item>
<n-form-item
v-if="streamServerModel.ssl"
path="ssl_certificate"
:label="$gettext('SSL Certificate Path')"
>
<n-input
v-model:value="streamServerModel.ssl_certificate"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g. /path/to/cert.pem')"
/>
</n-form-item>
<n-form-item
v-if="streamServerModel.ssl"
path="ssl_certificate_key"
:label="$gettext('SSL Private Key Path')"
>
<n-input
v-model:value="streamServerModel.ssl_certificate_key"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('e.g. /path/to/key.pem')"
/>
</n-form-item>
</n-form>
<n-button type="info" block @click="handleSaveStreamServer">{{ $gettext('Submit') }}</n-button>
</n-modal>
<!-- Stream Upstream 模态框 -->
<n-modal
v-model:show="streamUpstreamModal"
preset="card"
:title="streamUpstreamModalTitle"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
@close="streamUpstreamModal = false"
>
<n-form :model="streamUpstreamModel">
<n-form-item path="name" :label="$gettext('Name')">
<n-input
v-model:value="streamUpstreamModel.name"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Only letters, numbers, underscores and hyphens')"
/>
</n-form-item>
<n-form-item path="algo" :label="$gettext('Load Balancing Algorithm')">
<n-select
v-model:value="streamUpstreamModel.algo"
:options="[
{ label: $gettext('Round Robin (Default)'), value: '' },
{ label: 'least_conn', value: 'least_conn' },
{ label: 'ip_hash', value: 'ip_hash' },
{ label: 'hash $remote_addr', value: 'hash $remote_addr' },
{ label: 'random', value: 'random' },
{ label: 'least_time connect', value: 'least_time connect' },
{ label: 'least_time first_byte', value: 'least_time first_byte' }
]"
/>
</n-form-item>
<n-form-item :label="$gettext('Servers')">
<n-flex vertical wh-full>
<n-flex>
<n-input
v-model:value="upstreamServerAddr"
type="text"
flex-1
:placeholder="$gettext('Server address, e.g. 127.0.0.1:3306')"
/>
<n-input
v-model:value="upstreamServerOptions"
type="text"
flex-1
:placeholder="$gettext('Options (optional), e.g. weight=5 backup')"
/>
<n-button type="primary" @click="handleAddUpstreamServer">
{{ $gettext('Add') }}
</n-button>
</n-flex>
<n-table :bordered="false" :single-line="false" size="small">
<thead>
<tr>
<th>{{ $gettext('Address') }}</th>
<th>{{ $gettext('Options') }}</th>
<th w-100>{{ $gettext('Actions') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(options, addr) in streamUpstreamModel.servers" :key="addr">
<td>{{ addr }}</td>
<td>{{ options || '-' }}</td>
<td>
<n-button
size="small"
type="error"
@click="handleRemoveUpstreamServer(addr as string)"
>
{{ $gettext('Delete') }}
</n-button>
</td>
</tr>
<tr v-if="Object.keys(streamUpstreamModel.servers).length === 0">
<td colspan="3" text-center>
{{ $gettext('No servers added yet') }}
</td>
</tr>
</tbody>
</n-table>
</n-flex>
</n-form-item>
<n-form-item path="resolver" :label="$gettext('DNS Resolver')">
<n-dynamic-tags
v-model:value="streamUpstreamModel.resolver"
:placeholder="$gettext('e.g., 8.8.8.8')"
/>
</n-form-item>
<n-form-item
v-if="streamUpstreamModel.resolver.length"
path="resolver_timeout"
:label="$gettext('Resolver Timeout')"
>
<n-input-group>
<n-input-number
:value="parseDuration(streamUpstreamModel.resolver_timeout).value"
:min="1"
:max="3600"
style="flex: 1"
@update:value="(v: number | null) => updateResolverTimeoutValue(v ?? 5)"
/>
<n-select
:value="parseDuration(streamUpstreamModel.resolver_timeout).unit"
:options="[
{ label: $gettext('Seconds'), value: 's' },
{ label: $gettext('Minutes'), value: 'm' },
{ label: $gettext('Hours'), value: 'h' }
]"
style="width: 100px"
@update:value="(v: string) => updateResolverTimeoutUnit(v)"
/>
</n-input-group>
</n-form-item>
</n-form>
<n-button type="info" block @click="handleSaveStreamUpstream">
{{ $gettext('Submit') }}
</n-button>
</n-modal>
</template>

View File

@@ -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'
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<service-status service="nginx" show-reload />
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the OpenResty main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" lang="nginx" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="columns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<realtime-log service="nginx" />
</n-tab-pane>
<n-tab-pane name="error-log" :tab="$gettext('Error Logs')">
<n-flex vertical>
<n-flex>
<n-button type="primary" @click="handleClearErrorLog">
{{ $gettext('Clear Log') }}
</n-button>
</n-flex>
<realtime-log :path="errorLog" />
</n-flex>
</n-tab-pane>
</n-tabs>
</common-page>
<nginx-index :api="openresty" service="openresty" />
</template>

View File

@@ -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'
</script>
<template>
<common-page show-footer>
<n-tabs v-model:value="currentTab" type="line" animated>
<n-tab-pane name="status" :tab="$gettext('Running Status')">
<n-flex vertical>
<service-status service="mysqld" />
<n-card :title="$gettext('Root Password')">
<n-flex>
<n-input-group>
<n-input v-model:value="rootPassword" type="password" show-password-on="click" />
<n-button type="primary" ghost @click="handleCopyRootPassword">
{{ $gettext('Copy') }}
</n-button>
</n-input-group>
<n-button type="primary" @click="handleSetRootPassword">
{{ $gettext('Save Changes') }}
</n-button>
</n-flex>
</n-card>
</n-flex>
</n-tab-pane>
<n-tab-pane name="config" :tab="$gettext('Modify Configuration')">
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'This modifies the Percona main configuration file. If you do not understand the meaning of each parameter, please do not modify it randomly!'
)
}}
</n-alert>
<common-editor v-model:value="config" height="60vh" />
<n-flex>
<n-button type="primary" @click="handleSaveConfig">
{{ $gettext('Save') }}
</n-button>
</n-flex>
</n-flex>
</n-tab-pane>
<n-tab-pane name="load" :tab="$gettext('Load Status')">
<n-data-table
striped
remote
:scroll-x="400"
:loading="false"
:columns="loadColumns"
:data="load"
/>
</n-tab-pane>
<n-tab-pane name="run-log" :tab="$gettext('Runtime Logs')">
<n-button type="primary" @click="handleClearLog">
{{ $gettext('Clear Log') }}
</n-button>
<realtime-log service="mysqld" />
</n-tab-pane>
<n-tab-pane name="slow-log" :tab="$gettext('Slow Query Log')">
<n-button type="primary" @click="handleClearSlowLog">
{{ $gettext('Clear Slow Log') }}
</n-button>
<realtime-log :path="slowLog" />
</n-tab-pane>
</n-tabs>
</common-page>
<mysql-index :api="percona" name="Percona" />
</template>

View File

@@ -205,7 +205,7 @@ watch(isLogin, async () => {
<n-image
:src="'data:image/png;base64,' + captchaImage"
preview-disabled
class="cursor-pointer h-50"
class="h-50 cursor-pointer"
style="border-radius: 4px"
@click="refreshCaptcha"
/>

View File

@@ -1,206 +0,0 @@
<script setup lang="ts">
import { NInput } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const config = defineModel<string>('config', { type: String, required: true })
const setting = ref({
auto_resolve: true,
sni: true,
cache: false,
cache_time: 1,
no_buffer: false,
proxy_pass: '',
host: '$host',
match_type: '^~',
match: '/',
replace: []
})
const handleSubmit = () => {
if (setting.value.cache && setting.value.no_buffer) {
window.$message.error(
$gettext('Disabled buffer and enabled cache cannot be used simultaneously')
)
return
}
if (setting.value.match.length === 0) {
window.$message.error($gettext('Matching expression cannot be empty'))
return
}
if (setting.value.proxy_pass.length === 0) {
window.$message.error($gettext('Proxy address cannot be empty'))
return
}
if (setting.value.match_type === '=' && setting.value.match[0] !== '/') {
window.$message.error($gettext('Exact match expression must start with /'))
return
}
if (
(setting.value.match_type === '^~' || setting.value.match_type === ' ') &&
setting.value.match[0] !== '/'
) {
window.$message.error($gettext('Prefix match expression must start with /'))
return
}
try {
new URL(setting.value.proxy_pass)
} catch (error) {
window.$message.error($gettext('Proxy address format error'))
return
}
let builder: string
builder = 'location'
switch (setting.value.match_type) {
case '=':
builder += ' ='
break
case '^~':
builder += ' ^~'
break
case '~':
builder += ' ~'
break
case '~*':
builder += ' ~*'
break
}
builder += ` ${setting.value.match}\n{\n`
if (setting.value.auto_resolve) {
builder += ` set $empty "";\n proxy_pass ${setting.value.proxy_pass}$empty;\n`
} else {
builder += ` proxy_pass ${setting.value.proxy_pass};\n`
}
if (setting.value.host) {
builder += ` proxy_set_header Host ${setting.value.host};\n`
}
builder += ` proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Host $host;\n proxy_set_header X-Forwarded-Port $server_port;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Scheme $scheme;\n`
builder += ` proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection $http_connection;\n proxy_set_header Early-Data $ssl_early_data;\n proxy_set_header Accept-Encoding "";\n proxy_http_version 1.1;\n proxy_ssl_protocols TLSv1.2 TLSv1.3;\n proxy_ssl_session_reuse off;\n`
if (setting.value.sni) {
builder += ` proxy_ssl_server_name on;\n`
}
if (setting.value.auto_resolve) {
builder += ` resolver 8.8.8.8 ipv6=off;\n resolver_timeout 10s;\n`
}
if (setting.value.cache) {
builder += ` proxy_ignore_headers X-Accel-Expires Expires Cache-Control Set-Cookie;\n proxy_cache cache_one;\n proxy_cache_key $scheme$host$uri$is_args$args;\n proxy_cache_valid 200 304 301 302 ${setting.value.cache_time}m;\n proxy_cache_lock on;\n proxy_cache_lock_timeout 5s;\n proxy_cache_lock_age 5s;\n proxy_cache_background_update on;\n proxy_cache_use_stale error timeout invalid_header updating http_500 http_502 http_503 http_504;\n proxy_cache_revalidate on;\n add_header X-Cache $upstream_cache_status;\n`
}
if (setting.value.no_buffer) {
builder += ` proxy_buffering off;\n proxy_request_buffering off;\n`
}
if (setting.value.replace.length > 0) {
builder += ` sub_filter_once off;\n sub_filter_types *;\n`
for (const item of setting.value.replace) {
builder += ` sub_filter "${(item as any).key}" "${(item as any).value}";\n`
}
}
builder += `}\n`
config.value = builder
show.value = false
window.$message.success($gettext('Configuration generated successfully'))
}
// 通过代理地址尝试自动获取发送域名
watch(
() => setting.value.proxy_pass,
(val) => {
if (val.length > 0) {
try {
const url = new URL(val)
setting.value.host = url.hostname
} catch (error) {}
}
}
)
</script>
<template>
<n-modal
v-model:show="show"
preset="card"
:title="$gettext('Generate Reverse Proxy Configuration')"
style="width: 40vw"
size="huge"
:bordered="false"
:segmented="false"
>
<n-flex vertical>
<n-alert type="warning">
{{
$gettext(
'After generating the reverse proxy configuration, the original rewrite rules will be overwritten.'
)
}}
</n-alert>
<n-alert type="info">
{{
$gettext(
'If you need to proxy static resources like JS/CSS, please remove the static log recording part from the original configuration.'
)
}}
</n-alert>
<n-form inline>
<n-form-item :label="$gettext('Auto Refresh Resolution')">
<n-switch v-model:value="setting.auto_resolve" />
</n-form-item>
<n-form-item :label="$gettext('Enable SNI')">
<n-switch v-model:value="setting.sni" />
</n-form-item>
<n-form-item :label="$gettext('Enable Cache')">
<n-switch v-model:value="setting.cache" />
</n-form-item>
<n-form-item :label="$gettext('Disable Buffer')">
<n-switch v-model:value="setting.no_buffer" />
</n-form-item>
</n-form>
<n-form>
<n-form-item :label="$gettext('Match Type')">
<n-select
v-model:value="setting.match_type"
:options="[
{ label: $gettext('Exact Match (=)'), value: '=' },
{ label: $gettext('Priority Prefix Match (^~)'), value: '^~' },
{ label: $gettext('Normal Prefix Match ( )'), value: ' ' },
{ label: $gettext('Case Sensitive Regex Match (~)'), value: '~' },
{ label: $gettext('Case Insensitive Regex Match (~*)'), value: '~*' }
]"
/>
</n-form-item>
<n-form-item :label="$gettext('Match Expression')">
<n-input v-model:value="setting.match" placeholder="/" />
</n-form-item>
<n-form-item :label="$gettext('Proxy Address')">
<n-input v-model:value="setting.proxy_pass" placeholder="http://127.0.0.1:3000" />
</n-form-item>
<n-form-item :label="$gettext('Send Domain')">
<n-input v-model:value="setting.host" placeholder="$host" />
</n-form-item>
<n-form-item v-if="setting.cache" :label="$gettext('Cache Time')">
<n-input-number
v-model:value="setting.cache_time"
w-full
:min="1"
:step="1"
:placeholder="$gettext('Cache time (minutes)')"
>
<template #suffix> {{ $gettext('minutes') }} </template>
</n-input-number>
</n-form-item>
<n-form-item :label="$gettext('Content Replacement')">
<n-dynamic-input
v-model:value="setting.replace"
preset="pair"
:max="5"
:key-placeholder="$gettext('Target content')"
:value-placeholder="$gettext('Replacement content')"
/>
</n-form-item>
</n-form>
<n-button type="info" block @click="handleSubmit"> {{ $gettext('Submit') }} </n-button>
</n-flex>
</n-modal>
</template>
<style scoped lang="scss"></style>