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:
@@ -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
9
go.mod
@@ -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
21
go.sum
@@ -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
13
internal/biz/template.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingRepo,
|
||||
NewSSHRepo,
|
||||
NewTaskRepo,
|
||||
NewTemplateRepo,
|
||||
NewUserRepo,
|
||||
NewUserTokenRepo,
|
||||
NewWebHookRepo,
|
||||
|
||||
@@ -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
156
internal/data/template.go
Normal 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
|
||||
}
|
||||
14
internal/http/request/template.go
Normal file
14
internal/http/request/template.go
Normal 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"`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -33,6 +33,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewSSHService,
|
||||
NewSystemctlService,
|
||||
NewTaskService,
|
||||
NewTemplateService,
|
||||
NewUserService,
|
||||
NewUserTokenService,
|
||||
NewWebHookService,
|
||||
|
||||
110
internal/service/template.go
Normal file
110
internal/service/template.go
Normal 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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 监控信息
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// 标准代理头
|
||||
|
||||
17
web/src/api/panel/template/index.ts
Normal file
17
web/src/api/panel/template/index.ts
Normal 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`)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
252
web/src/views/app/TemplateDeployModal.vue
Normal file
252
web/src/views/app/TemplateDeployModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')">
|
||||
|
||||
Reference in New Issue
Block a user