2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 09:13:49 +08:00

feat: 初步实现compose template

This commit is contained in:
2026-01-13 23:31:37 +08:00
parent 908509e06b
commit c07a60d1c8
28 changed files with 866 additions and 47 deletions

View File

@@ -128,6 +128,8 @@ func initWeb() (*app.Web, error) {
toolboxLogService := service.NewToolboxLogService(locale, db, containerImageRepo, settingRepo)
webHookRepo := data.NewWebHookRepo(locale, db, logger)
webHookService := service.NewWebHookService(webHookRepo)
templateRepo := data.NewTemplateRepo()
templateService := service.NewTemplateService(locale, templateRepo, settingRepo)
apacheApp := apache.NewApp(locale)
codeserverApp := codeserver.NewApp()
dockerApp := docker.NewApp()
@@ -150,7 +152,7 @@ func initWeb() (*app.Web, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(apacheApp, codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, webHookService, loader)
http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, projectService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, logService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, toolboxLogService, webHookService, templateService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)

9
go.mod
View File

@@ -49,7 +49,7 @@ require (
github.com/robfig/cron/v3 v3.0.1
github.com/samber/lo v1.52.0
github.com/sethvargo/go-limiter v1.1.0
github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shirou/gopsutil/v4 v4.25.12
github.com/spf13/cast v1.10.0
github.com/stretchr/testify v1.11.1
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701
@@ -69,6 +69,7 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
@@ -79,17 +80,19 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/libtnb/securecookie v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/ncruces/julianday v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/timtadh/data-structures v0.6.2 // indirect
github.com/timtadh/lexmachine v0.2.3 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect

21
go.sum
View File

@@ -67,6 +67,8 @@ github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pM
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@@ -114,6 +116,7 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
@@ -233,6 +236,8 @@ github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696 h1:GN0Y3DG27mMruX53
github.com/libtnb/testify v0.0.0-20260103194301-c7a63ea79696/go.mod h1:HeQeTfKU6tj2Lx1z79UacwYeDioo6M4ZD7BDDI6+rrg=
github.com/libtnb/utils v1.2.1 h1:LJmReRREnpqfHyy9PZtNgBh3ZaIGct81b8ZaAsolMkM=
github.com/libtnb/utils v1.2.1/go.mod h1:o6LEDeC42PXI21uLWdWJWTVYvR9BtAZfzzTGJVQoQiU=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
@@ -273,6 +278,8 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -297,8 +304,8 @@ github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRo
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sethvargo/go-limiter v1.1.0 h1:eLeZVQ2zqJOiEs03GguqmBVG6/T6lsZB+6PP1t7J6fA=
github.com/sethvargo/go-limiter v1.1.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shirou/gopsutil/v4 v4.25.12 h1:e7PvW/0RmJ8p8vPGJH4jvNkOyLmbkXgXW4m6ZPic6CY=
github.com/shirou/gopsutil/v4 v4.25.12/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@@ -329,10 +336,10 @@ github.com/timtadh/getopt v1.0.0/go.mod h1:L3EL6YN2G0eIAhYBo9b7SB9d/kEQmdnwthIlM
github.com/timtadh/lexmachine v0.2.2/go.mod h1:GBJvD5OAfRn/gnp92zb9KTgHLB7akKyxmVivoYCcjQI=
github.com/timtadh/lexmachine v0.2.3 h1:ZqlfHnfMcAygtbNM5Gv7jQf8hmM8LfVzDjfCrq235NQ=
github.com/timtadh/lexmachine v0.2.3/go.mod h1:oK1NW+93fQSIF6s+J6sXBFWsCPCFbNmrwKV1i0aqvW0=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701 h1:JgeHIJzRSEdcuLXufZrni5+a4yDnBhQG+DdKhqCFhq0=
github.com/tufanbarisyildirim/gonginx v0.0.0-20250620092546-c3e307e36701/go.mod h1:ALbEe81QPWOZjDKCKNWodG2iqCMtregG8+ebQgjx2+4=
@@ -419,6 +426,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
@@ -452,6 +460,7 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=

13
internal/biz/template.go Normal file
View File

@@ -0,0 +1,13 @@
package biz
import (
"github.com/acepanel/panel/pkg/api"
"github.com/acepanel/panel/pkg/types"
)
type TemplateRepo interface {
List() (api.Templates, error)
Get(slug string) (*api.Template, error)
Callback(slug string) error
CreateCompose(name, compose string, envs []types.KV, autoFirewall bool) error
}

View File

@@ -12,7 +12,7 @@ import (
"time"
"github.com/leonelquinteros/gotext"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/v4/disk"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/app"

View File

@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
NewSettingRepo,
NewSSHRepo,
NewTaskRepo,
NewTemplateRepo,
NewUserRepo,
NewUserTokenRepo,
NewWebHookRepo,

View File

@@ -19,6 +19,7 @@ import (
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/systemctl"
"github.com/acepanel/panel/pkg/types"
)
@@ -206,6 +207,15 @@ func (r *projectRepo) parseProjectDetail(project *biz.Project) (*types.ProjectDe
}
}
// 获取运行状态
if info, err := systemctl.GetServiceInfo(project.Name); err == nil {
detail.Status = info.Status
detail.PID = info.PID
detail.Memory = info.Memory
detail.CPU = info.CPU
detail.Uptime = info.Uptime
}
return detail, nil
}

156
internal/data/template.go Normal file
View File

@@ -0,0 +1,156 @@
package data
import (
"os"
"path/filepath"
"regexp"
"strings"
"github.com/acepanel/panel/internal/app"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/pkg/api"
"github.com/acepanel/panel/pkg/firewall"
"github.com/acepanel/panel/pkg/types"
)
type templateRepo struct {
api *api.API
firewall *firewall.Firewall
}
func NewTemplateRepo() biz.TemplateRepo {
return &templateRepo{
api: api.NewAPI(app.Version, app.Locale),
firewall: firewall.NewFirewall(),
}
}
// List 获取所有模版
func (r *templateRepo) List() (api.Templates, error) {
templates, err := r.api.Templates()
if err != nil {
return nil, err
}
return *templates, nil
}
// Get 获取模版详情
func (r *templateRepo) Get(slug string) (*api.Template, error) {
return r.api.TemplateBySlug(slug)
}
// Callback 模版下载回调
func (r *templateRepo) Callback(slug string) error {
return r.api.TemplateCallback(slug)
}
// CreateCompose 创建编排
func (r *templateRepo) CreateCompose(name, compose string, envs []types.KV, autoFirewall bool) error {
dir := filepath.Join(app.Root, "server", "compose", name)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte(compose), 0644); err != nil {
return err
}
var sb strings.Builder
for _, kv := range envs {
sb.WriteString(kv.Key)
sb.WriteString("=")
sb.WriteString(kv.Value)
sb.WriteString("\n")
}
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(sb.String()), 0644); err != nil {
return err
}
// 自动放行端口
if autoFirewall {
ports := r.parsePortsFromCompose(compose)
for _, port := range ports {
_ = r.firewall.Port(firewall.FireInfo{
Family: "ipv4",
PortStart: port.Port,
PortEnd: port.Port,
Protocol: port.Protocol,
Strategy: firewall.StrategyAccept,
Direction: "in",
}, firewall.OperationAdd)
}
}
return nil
}
type composePort struct {
Port uint
Protocol firewall.Protocol
}
// parsePortsFromCompose 从 compose 文件中解析端口
func (r *templateRepo) parsePortsFromCompose(compose string) []composePort {
var ports []composePort
seen := make(map[string]bool)
// 匹配 ports 部分的端口映射
// 支持格式: "8080:80", "8080:80/tcp", "8080:80/udp", "80", "80/tcp"
portRegex := regexp.MustCompile(`(?m)^\s*-\s*["']?(\d+)(?::\d+)?(?:/(\w+))?["']?\s*$`)
matches := portRegex.FindAllStringSubmatch(compose, -1)
for _, match := range matches {
if len(match) < 2 {
continue
}
portStr := match[1]
protocol := firewall.ProtocolTCP
if len(match) > 2 && match[2] != "" {
switch strings.ToLower(match[2]) {
case "udp":
protocol = firewall.ProtocolUDP
case "tcp":
protocol = firewall.ProtocolTCP
}
}
// 去重
key := portStr + "/" + string(protocol)
if seen[key] {
continue
}
seen[key] = true
var port uint
if _, _, found := strings.Cut(portStr, ":"); found {
// 格式: host:container
parts := strings.Split(portStr, ":")
if len(parts) > 0 {
port = parseUint(parts[0])
}
} else {
port = parseUint(portStr)
}
if port > 0 && port <= 65535 {
ports = append(ports, composePort{
Port: port,
Protocol: protocol,
})
}
}
return ports
}
func parseUint(s string) uint {
var n uint
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + uint(c-'0')
} else {
break
}
}
return n
}

View File

@@ -0,0 +1,14 @@
package request
import "github.com/acepanel/panel/pkg/types"
type TemplateSlug struct {
Slug string `uri:"slug" validate:"required"`
}
type TemplateCreate struct {
Slug string `json:"slug" validate:"required"`
Name string `json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
Envs []types.KV `json:"envs"`
AutoFirewall bool `json:"auto_firewall"`
}

View File

@@ -54,6 +54,7 @@ type Http struct {
toolboxDisk *service.ToolboxDiskService
toolboxLog *service.ToolboxLogService
webhook *service.WebHookService
template *service.TemplateService
apps *apploader.Loader
}
@@ -96,6 +97,7 @@ func NewHttp(
toolboxDisk *service.ToolboxDiskService,
toolboxLog *service.ToolboxLogService,
webhook *service.WebHookService,
template *service.TemplateService,
apps *apploader.Loader,
) *Http {
return &Http{
@@ -137,6 +139,7 @@ func NewHttp(
toolboxDisk: toolboxDisk,
toolboxLog: toolboxLog,
webhook: webhook,
template: template,
apps: apps,
}
}
@@ -523,6 +526,13 @@ func (route *Http) Register(r *chi.Mux) {
r.Delete("/{id}", route.webhook.Delete)
})
r.Route("/template", func(r chi.Router) {
r.Get("/", route.template.List)
r.Get("/{slug}", route.template.Get)
r.Post("/", route.template.Create)
r.Post("/{slug}/callback", route.template.Callback)
})
r.Route("/apps", func(r chi.Router) {
route.apps.Register(r)
})

View File

@@ -932,6 +932,7 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error {
{Key: biz.SettingKeyMonitorDays, Value: "30"},
{Key: biz.SettingKeyBackupPath, Value: filepath.Join(app.Root, "backup")},
{Key: biz.SettingKeyWebsitePath, Value: filepath.Join(app.Root, "sites")},
{Key: biz.SettingKeyProjectPath, Value: filepath.Join(app.Root, "projects")},
{Key: biz.SettingKeyWebsiteTLSVersions, Value: `["TLSv1.2","TLSv1.3"]`},
{Key: biz.SettingKeyWebsiteCipherSuites, Value: `ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305`},
{Key: biz.SettingKeyOfflineMode, Value: "false"},

View File

@@ -10,8 +10,8 @@ import (
"github.com/leonelquinteros/gotext"
"github.com/libtnb/chix"
"github.com/libtnb/utils/collect"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/spf13/cast"
"github.com/acepanel/panel/internal/app"

View File

@@ -10,7 +10,7 @@ import (
"time"
"github.com/libtnb/chix"
"github.com/shirou/gopsutil/process"
"github.com/shirou/gopsutil/v4/process"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/types"
@@ -159,7 +159,9 @@ func (s *ProcessService) processProcessBasic(proc *process.Process) types.Proces
data.Username = username
}
data.PPID, _ = proc.Ppid()
data.Status, _ = proc.Status()
if status, err := proc.Status(); err == nil && len(status) > 0 {
data.Status = status[0]
}
data.Background, _ = proc.Background()
if ct, err := proc.CreateTime(); err == nil {
data.StartTime = time.Unix(ct/1000, 0).Format(time.DateTime)
@@ -188,7 +190,6 @@ func (s *ProcessService) processProcessFull(proc *process.Process) types.Process
data.DiskRead = ioStat.ReadBytes
}
data.Nets, _ = proc.NetIOCounters(false)
data.Connections, _ = proc.Connections()
data.CmdLine, _ = proc.Cmdline()
data.OpenFiles, _ = proc.OpenFiles()

View File

@@ -33,6 +33,7 @@ var ProviderSet = wire.NewSet(
NewSSHService,
NewSystemctlService,
NewTaskService,
NewTemplateService,
NewUserService,
NewUserTokenService,
NewWebHookService,

View File

@@ -0,0 +1,110 @@
package service
import (
"net/http"
"github.com/leonelquinteros/gotext"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
)
type TemplateService struct {
t *gotext.Locale
templateRepo biz.TemplateRepo
settingRepo biz.SettingRepo
}
func NewTemplateService(t *gotext.Locale, template biz.TemplateRepo, setting biz.SettingRepo) *TemplateService {
return &TemplateService{
t: t,
templateRepo: template,
settingRepo: setting,
}
}
// List 获取所有模版
func (s *TemplateService) List(w http.ResponseWriter, r *http.Request) {
if offline, _ := s.settingRepo.GetBool(biz.SettingKeyOfflineMode); offline {
Error(w, http.StatusForbidden, s.t.Get("Unable to get template list in offline mode"))
return
}
templates, err := s.templateRepo.List()
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, templates)
}
// Get 获取模版详情
func (s *TemplateService) Get(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.TemplateSlug](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if offline, _ := s.settingRepo.GetBool(biz.SettingKeyOfflineMode); offline {
Error(w, http.StatusForbidden, s.t.Get("Unable to get template in offline mode"))
return
}
template, err := s.templateRepo.Get(req.Slug)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, template)
}
// Create 使用模版创建编排
func (s *TemplateService) Create(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.TemplateCreate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if offline, _ := s.settingRepo.GetBool(biz.SettingKeyOfflineMode); offline {
Error(w, http.StatusForbidden, s.t.Get("Unable to create compose from template in offline mode"))
return
}
// 获取模版
template, err := s.templateRepo.Get(req.Slug)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 创建编排
if err = s.templateRepo.CreateCompose(req.Name, template.Compose, req.Envs, req.AutoFirewall); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
// 回调
_ = s.templateRepo.Callback(req.Slug)
Success(w, nil)
}
// Callback 模版下载回调
func (s *TemplateService) Callback(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.TemplateSlug](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.templateRepo.Callback(req.Slug); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/go-resty/resty/v2"
"github.com/libtnb/utils/copier"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/v4/host"
)
type API struct {

View File

@@ -1,11 +1,105 @@
package systemctl
import (
"strconv"
"strings"
"time"
"github.com/shirou/gopsutil/v4/process"
"github.com/acepanel/panel/pkg/shell"
)
// ServiceInfo 服务详细信息
type ServiceInfo struct {
Status string // 运行状态 (active, inactive, failed, etc.)
PID int // 主进程 PID
Memory int64 // 内存使用(字节)
CPU float64 // CPU 使用率
Uptime string // 运行时间
}
// GetServiceInfo 获取服务详细信息
func GetServiceInfo(name string) (*ServiceInfo, error) {
output, err := shell.Execf("systemctl show '%s' --property=ActiveState,MainPID,ExecMainStartTimestamp --no-pager", name)
if err != nil {
return nil, err
}
info := &ServiceInfo{}
for _, line := range strings.Split(output, "\n") {
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key, value := parts[0], parts[1]
switch key {
case "ActiveState":
info.Status = value
case "MainPID":
if pid, err := strconv.Atoi(value); err == nil {
info.PID = pid
}
case "ExecMainStartTimestamp":
// 格式: Mon 2024-01-01 12:00:00 UTC
if value != "" && value != "n/a" {
info.Uptime = calcUptime(value)
}
}
}
// 如果有 PID使用 gopsutil 获取进程信息
if info.PID > 0 {
if proc, err := process.NewProcess(int32(info.PID)); err == nil {
// 获取内存信息
if memInfo, err := proc.MemoryInfo(); err == nil && memInfo != nil {
info.Memory = int64(memInfo.RSS)
}
// 获取 CPU 使用率
if cpu, err := proc.CPUPercent(); err == nil {
info.CPU = cpu
}
}
}
return info, nil
}
// calcUptime 计算运行时间
func calcUptime(startTime string) string {
// 解析时间格式: Mon 2024-01-01 12:00:00 UTC
// 或者: Mon 2024-01-01 12:00:00 CST
layouts := []string{
"Mon 2006-01-02 15:04:05 MST",
"Mon 2006-01-02 15:04:05 -0700",
}
var t time.Time
var err error
for _, layout := range layouts {
t, err = time.Parse(layout, startTime)
if err == nil {
break
}
}
if err != nil {
return ""
}
duration := time.Since(t)
days := int(duration.Hours() / 24)
hours := int(duration.Hours()) % 24
minutes := int(duration.Minutes()) % 60
if days > 0 {
return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(minutes) + "m"
}
if hours > 0 {
return strconv.Itoa(hours) + "h " + strconv.Itoa(minutes) + "m"
}
return strconv.Itoa(minutes) + "m"
}
// Status 获取服务状态
func Status(name string) (bool, error) {
output, _ := shell.Execf("systemctl is-active '%s'", name) // 不判断错误,因为 is-active 在服务未启用时会返回 3

View File

@@ -13,12 +13,12 @@ import (
"time"
"github.com/go-resty/resty/v2"
"github.com/shirou/gopsutil/cpu"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/load"
"github.com/shirou/gopsutil/mem"
"github.com/shirou/gopsutil/net"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
"github.com/acepanel/panel/pkg/shell"
"github.com/acepanel/panel/pkg/types"

View File

@@ -1,8 +1,8 @@
package types
import (
"github.com/shirou/gopsutil/net"
"github.com/shirou/gopsutil/process"
"github.com/shirou/gopsutil/v4/net"
"github.com/shirou/gopsutil/v4/process"
)
type ProcessData struct {
@@ -35,5 +35,4 @@ type ProcessData struct {
OpenFiles []process.OpenFilesStat `json:"open_files"`
Connections []net.ConnectionStat `json:"connections"`
Nets []net.IOCountersStat `json:"nets"`
}

View File

@@ -3,12 +3,12 @@ package types
import (
"time"
"github.com/shirou/gopsutil/cpu"
"github.com/shirou/gopsutil/disk"
"github.com/shirou/gopsutil/host"
"github.com/shirou/gopsutil/load"
"github.com/shirou/gopsutil/mem"
"github.com/shirou/gopsutil/net"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/load"
"github.com/shirou/gopsutil/v4/mem"
"github.com/shirou/gopsutil/v4/net"
)
// CurrentInfo 监控信息

View File

@@ -229,7 +229,7 @@ func generateProxyConfig(proxy types.Proxy) string {
if proxy.Host != "" {
sb.WriteString(fmt.Sprintf(" proxy_set_header Host \"%s\";\n", proxy.Host))
} else {
sb.WriteString(" proxy_set_header Host $host;\n")
sb.WriteString(" proxy_set_header Host $proxy_host;\n")
}
// 标准代理头

View File

@@ -0,0 +1,17 @@
import { http } from '@/utils'
export default {
// 获取模版列表
list: (): any => http.Get('/template'),
// 获取模版详情
get: (slug: string): any => http.Get(`/template/${slug}`),
// 使用模版创建编排
create: (data: {
slug: string
name: string
envs: { key: string; value: string }[]
auto_firewall: boolean
}): any => http.Post('/template', data),
// 模版下载回调
callback: (slug: string): any => http.Post(`/template/${slug}/callback`)
}

View File

@@ -16,7 +16,7 @@ withDefaults(defineProps<Props>(), {
<template>
<app-page :show-footer="showFooter">
<div class="flex flex-col flex-1 gap-10 min-h-0">
<div class="flex flex-col flex-1 gap-10" :class="{ 'min-h-0': flex }">
<header v-if="showHeader">
<slot v-if="$slots.header" name="header" />
<n-card v-else size="small">
@@ -24,7 +24,7 @@ withDefaults(defineProps<Props>(), {
</n-card>
</header>
<n-card
class="flex-1 min-h-0 overflow-auto"
:class="flex ? 'flex-1 min-h-0' : 'flex-1'"
:content-class="flex ? 'flex flex-col min-h-0 h-full' : undefined"
>
<slot />

View File

@@ -0,0 +1,252 @@
<script setup lang="ts">
import templateApi from '@/api/panel/template'
import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue'
import { useGettext } from 'vue3-gettext'
import type { Template, TemplateEnvironment } from './types'
const { $gettext } = useGettext()
const props = defineProps<{
template: Template | null
}>()
const emit = defineEmits<{
success: []
}>()
const show = defineModel<boolean>('show', { type: Boolean, required: true })
const doSubmit = ref(false)
const currentTab = ref('basic')
// 启动终端
const upModal = ref(false)
const upCommand = ref('')
const deployModel = reactive({
name: '',
autoStart: true,
autoFirewall: false,
envs: {} as Record<string, string>
})
// 初始化环境变量默认值
const initEnvDefaults = () => {
if (!props.template?.environments) return
const envs: Record<string, string> = {}
props.template.environments.forEach((env: TemplateEnvironment) => {
envs[env.name] = env.default || ''
})
deployModel.envs = envs
}
// 根据类型渲染环境变量输入组件
const getEnvInputType = (env: TemplateEnvironment) => {
switch (env.type) {
case 'password':
return 'password'
case 'number':
case 'port':
return 'number'
default:
return 'text'
}
}
// 获取 select 选项
const getSelectOptions = (env: TemplateEnvironment) => {
if (!env.options) return []
return Object.entries(env.options).map(([value, label]) => ({
label,
value
}))
}
// 提交部署
const handleSubmit = async () => {
if (!props.template) return
if (!deployModel.name.trim()) {
window.$message.warning($gettext('Please enter compose name'))
return
}
doSubmit.value = true
try {
// 构建环境变量数组
const envs = Object.entries(deployModel.envs).map(([key, value]) => ({
key,
value: String(value)
}))
// 创建 compose
await templateApi.create({
slug: props.template.slug,
name: deployModel.name,
envs,
auto_firewall: deployModel.autoFirewall
})
window.$message.success($gettext('Created successfully'))
if (deployModel.autoStart) {
// 自动启动
upCommand.value = `docker compose -f /opt/ace/server/compose/${deployModel.name}/docker-compose.yml up -d`
upModal.value = true
} else {
show.value = false
emit('success')
}
} finally {
doSubmit.value = false
}
}
// 启动完成
const handleUpComplete = () => {
show.value = false
emit('success')
}
const resetForm = () => {
deployModel.name = ''
deployModel.autoStart = true
deployModel.autoFirewall = false
deployModel.envs = {}
currentTab.value = 'basic'
initEnvDefaults()
}
watch(show, (val) => {
if (val) {
resetForm()
}
})
watch(
() => props.template,
() => {
if (props.template) {
initEnvDefaults()
}
}
)
</script>
<template>
<n-modal
v-model:show="show"
:title="$gettext('Deploy Template') + (template ? ` - ${template.name}` : '')"
preset="card"
style="width: 60vw"
size="huge"
:bordered="false"
:segmented="false"
:mask-closable="!doSubmit"
:closable="!doSubmit"
>
<n-tabs v-model:value="currentTab" type="line" animated>
<!-- 基本设置 -->
<n-tab-pane name="basic" :tab="$gettext('Basic Settings')">
<n-form :model="deployModel" label-placement="left" label-width="120">
<n-form-item path="name" :label="$gettext('Compose Name')">
<n-input
v-model:value="deployModel.name"
type="text"
@keydown.enter.prevent
:placeholder="$gettext('Enter compose name')"
/>
</n-form-item>
<n-divider title-placement="left">{{ $gettext('Deploy Options') }}</n-divider>
<n-row :gutter="[24, 0]">
<n-col :span="8">
<n-form-item path="autoStart" :label="$gettext('Auto Start')">
<n-switch v-model:value="deployModel.autoStart" />
</n-form-item>
</n-col>
<n-col :span="8">
<n-form-item path="autoFirewall" :label="$gettext('Auto Firewall')">
<n-switch v-model:value="deployModel.autoFirewall" />
<template #feedback>
<span>
{{ $gettext('Automatically allow ports defined in compose') }}
</span>
</template>
</n-form-item>
</n-col>
</n-row>
</n-form>
</n-tab-pane>
<!-- 环境变量 -->
<n-tab-pane
v-if="template?.environments?.length"
name="environment"
:tab="$gettext('Environment Variables')"
>
<n-form :model="deployModel" label-placement="left" label-width="160">
<n-form-item v-for="env in template.environments" :key="env.name" :label="env.name">
<!-- Select 类型 -->
<n-select
v-if="env.type === 'select'"
v-model:value="deployModel.envs[env.name]"
:options="getSelectOptions(env)"
:placeholder="$gettext('Select value')"
/>
<!-- Number/Port 类型 -->
<n-input-number
v-else-if="env.type === 'number' || env.type === 'port'"
v-model:value="deployModel.envs[env.name]"
:min="env.type === 'port' ? 1 : undefined"
:max="env.type === 'port' ? 65535 : undefined"
style="width: 100%"
:placeholder="env.default || ''"
/>
<!-- Password 类型 -->
<n-input
v-else-if="env.type === 'password'"
v-model:value="deployModel.envs[env.name]"
type="password"
show-password-on="click"
:placeholder="env.default || ''"
/>
<!-- Text 类型 (默认) -->
<n-input
v-else
v-model:value="deployModel.envs[env.name]"
:type="getEnvInputType(env)"
:placeholder="env.default || ''"
/>
</n-form-item>
</n-form>
</n-tab-pane>
<!-- Compose 预览 -->
<n-tab-pane name="compose" :tab="$gettext('Compose Preview')">
<common-editor :value="template?.compose || ''" lang="yaml" height="50vh" read-only />
</n-tab-pane>
</n-tabs>
<template #footer>
<n-flex justify="end">
<n-button @click="show = false" :disabled="doSubmit">
{{ $gettext('Cancel') }}
</n-button>
<n-button type="primary" :loading="doSubmit" :disabled="doSubmit" @click="handleSubmit">
{{ $gettext('Deploy') }}
</n-button>
</n-flex>
</template>
</n-modal>
<pty-terminal-modal
v-model:show="upModal"
:title="$gettext('Starting Compose') + ' - ' + deployModel.name"
:command="upCommand"
@complete="handleUpComplete"
/>
</template>

View File

@@ -1,7 +1,111 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { NButton, NCard, NEllipsis, NFlex, NGrid, NGridItem, NSpin, NTag } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
import template from '@/api/panel/template'
import TemplateDeployModal from './TemplateDeployModal.vue'
import type { Template } from './types'
const { $gettext } = useGettext()
const selectedCategory = ref<string>('')
const deployModalShow = ref(false)
const selectedTemplate = ref<Template | null>(null)
const { loading, data, refresh } = usePagination(template.list, {
initialData: []
})
// 获取所有分类
const categories = computed(() => {
const cats = new Set<string>()
data.value?.forEach((t: Template) => {
t.categories?.forEach((c) => cats.add(c))
})
return Array.from(cats)
})
// 过滤后的模版列表
const filteredTemplates = computed(() => {
if (!selectedCategory.value) {
return data.value || []
}
return (data.value || []).filter((t: Template) => t.categories?.includes(selectedCategory.value))
})
const handleCategoryChange = (category: string) => {
selectedCategory.value = category
}
const handleDeploy = (tpl: Template) => {
selectedTemplate.value = tpl
deployModalShow.value = true
}
onMounted(() => {
refresh()
})
</script>
<template>
<n-empty></n-empty>
</template>
<n-flex vertical :size="20">
<n-flex>
<n-tag
:type="selectedCategory === '' ? 'primary' : 'default'"
:bordered="selectedCategory !== ''"
style="cursor: pointer"
@click="handleCategoryChange('')"
>
{{ $gettext('All') }}
</n-tag>
<n-tag
v-for="cat in categories"
:key="cat"
:type="selectedCategory === cat ? 'primary' : 'default'"
:bordered="selectedCategory !== cat"
style="cursor: pointer"
@click="handleCategoryChange(cat)"
>
{{ cat }}
</n-tag>
</n-flex>
<style scoped lang="scss"></style>
<n-spin :show="loading">
<n-grid :x-gap="16" :y-gap="16" cols="1 s:2 m:3 l:4" responsive="screen">
<n-grid-item v-for="tpl in filteredTemplates" :key="tpl.slug">
<n-card hoverable style="height: 100%">
<n-flex vertical :size="12">
<n-flex justify="space-between" align="center">
<span>{{ tpl.name }}</span>
<n-tag size="small" type="info">{{ tpl.version }}</n-tag>
</n-flex>
<n-ellipsis :line-clamp="2" :tooltip="{ width: 300 }">
{{ tpl.description }}
</n-ellipsis>
<n-flex :size="4" style="margin-top: auto">
<n-tag v-for="cat in tpl.categories" :key="cat" size="small">
{{ cat }}
</n-tag>
</n-flex>
</n-flex>
<template #action>
<n-flex justify="end">
<n-button size="small" type="primary" @click="handleDeploy(tpl)">
{{ $gettext('Deploy') }}
</n-button>
</n-flex>
</template>
</n-card>
</n-grid-item>
</n-grid>
<n-empty v-if="!loading && filteredTemplates.length === 0" />
</n-spin>
</n-flex>
<template-deploy-modal
v-model:show="deployModalShow"
:template="selectedTemplate"
@success="refresh"
/>
</template>

View File

@@ -17,3 +17,23 @@ export interface Channel {
version: string
log: string
}
export interface TemplateEnvironment {
name: string
type: 'text' | 'password' | 'number' | 'port' | 'select'
options?: Record<string, string>
default: string
}
export interface Template {
created_at: string
updated_at: string
slug: string
icon: string
name: string
description: string
categories: string[]
version: string
compose: string
environments: TemplateEnvironment[]
}

View File

@@ -76,19 +76,21 @@ const dropdownOptions = computed<DropdownOption[]>(() => {
// 渲染状态标签
const renderStatus = (status: string) => {
switch (status) {
case 'R':
case 'running':
return h(NTag, { type: 'success' }, { default: () => $gettext('Running') })
case 'S':
case 'blocked':
return h(NTag, { type: 'error' }, { default: () => $gettext('Blocked') })
case 'sleep':
return h(NTag, { type: 'warning' }, { default: () => $gettext('Sleeping') })
case 'T':
case 'stop':
return h(NTag, { type: 'error' }, { default: () => $gettext('Stopped') })
case 'I':
case 'idle':
return h(NTag, { type: 'primary' }, { default: () => $gettext('Idle') })
case 'Z':
case 'zombie':
return h(NTag, { type: 'error' }, { default: () => $gettext('Zombie') })
case 'W':
case 'wait':
return h(NTag, { type: 'warning' }, { default: () => $gettext('Waiting') })
case 'L':
case 'lock':
return h(NTag, { type: 'info' }, { default: () => $gettext('Locked') })
default:
return h(NTag, { type: 'default' }, { default: () => status })

View File

@@ -666,7 +666,7 @@ const updateTimeoutUnit = (proxy: any, unit: string) => {
<n-form-item-gi :span="12" :label="$gettext('Proxy Host')">
<n-input
v-model:value="proxy.host"
:placeholder="$gettext('Default: $host, or extracted from Proxy Pass')"
:placeholder="$gettext('Default: $proxy_host, or extracted from Proxy Pass')"
/>
</n-form-item-gi>
<n-form-item-gi :span="12" :label="$gettext('Proxy SNI')">