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

feat: 项目管理阶段1

This commit is contained in:
2026-01-11 00:08:12 +08:00
parent d8ccc9d549
commit 035046449d
14 changed files with 750 additions and 2 deletions

View File

@@ -83,6 +83,8 @@ func initWeb() (*app.Web, error) {
homeService := service.NewHomeService(locale, config, taskRepo, websiteRepo, appRepo, environmentRepo, settingRepo, cronRepo, backupRepo)
taskService := service.NewTaskService(taskRepo)
websiteService := service.NewWebsiteService(websiteRepo, settingRepo)
projectRepo := data.NewProjectRepo(locale, db)
projectService := service.NewProjectService(projectRepo)
databaseService := service.NewDatabaseService(databaseRepo)
databaseServerService := service.NewDatabaseServerService(databaseServerRepo)
databaseUserService := service.NewDatabaseUserService(databaseUserRepo)
@@ -143,7 +145,7 @@ func initWeb() (*app.Web, error) {
s3fsApp := s3fs.NewApp(locale)
supervisorApp := supervisor.NewApp(locale)
loader := bootstrap.NewLoader(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, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, 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, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, webHookService, loader)
wsService := service.NewWsService(locale, config, logger, sshRepo)
ws := route.NewWs(wsService)
mux, err := bootstrap.NewRouter(locale, middlewares, http, ws)

1
go.mod
View File

@@ -7,6 +7,7 @@ require (
github.com/bddjr/hlfhr v1.4.0
github.com/beevik/ntp v1.5.0
github.com/coder/websocket v1.8.14
github.com/coreos/go-systemd/v22 v22.6.0
github.com/creack/pty v1.1.24
github.com/dchest/captcha v1.1.0
github.com/expr-lang/expr v1.17.7

2
go.sum
View File

@@ -50,6 +50,8 @@ github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkE
github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=

25
internal/biz/project.go Normal file
View File

@@ -0,0 +1,25 @@
package biz
import (
"time"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/types"
)
type Project struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;unique" json:"name"` // 项目名称
Type types.ProjectType `gorm:"not null;index;default:'general'" json:"type"` // 项目类型
Path string `gorm:"not null;default:''" json:"path"` // 项目路径
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ProjectRepo interface {
List(page, limit uint) ([]*types.ProjectDetail, int64, error)
Get(id uint) (*types.ProjectDetail, error)
Create(req *request.ProjectCreate) (*types.ProjectDetail, error)
Update(req *request.ProjectUpdate) error
Delete(id uint) error
}

View File

@@ -19,7 +19,7 @@ const (
type Website struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"not null;default:'';unique" json:"name"`
Type WebsiteType `gorm:"not null;default:'static'" json:"type"`
Type WebsiteType `gorm:"not null;index;default:'static'" json:"type"`
Status bool `gorm:"not null;default:true" json:"status"`
Path string `gorm:"not null;default:''" json:"path"`
SSL bool `gorm:"not null;default:false" json:"ssl"`

View File

@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
NewDatabaseUserRepo,
NewEnvironmentRepo,
NewMonitorRepo,
NewProjectRepo,
NewSafeRepo,
NewSettingRepo,
NewSSHRepo,

487
internal/data/project.go Normal file
View File

@@ -0,0 +1,487 @@
package data
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/coreos/go-systemd/v22/unit"
"github.com/leonelquinteros/gotext"
"github.com/samber/lo"
"github.com/spf13/cast"
"gorm.io/gorm"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
"github.com/acepanel/panel/pkg/types"
)
type projectRepo struct {
systemdDir string
t *gotext.Locale
db *gorm.DB
}
func NewProjectRepo(t *gotext.Locale, db *gorm.DB) biz.ProjectRepo {
return &projectRepo{
systemdDir: "/etc/systemd/system",
t: t,
db: db,
}
}
func (r *projectRepo) List(page, limit uint) ([]*types.ProjectDetail, int64, error) {
var projects []*biz.Project
var total int64
if err := r.db.Model(&biz.Project{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := r.db.Offset(int((page - 1) * limit)).Limit(int(limit)).Order("id desc").Find(&projects).Error; err != nil {
return nil, 0, err
}
details := make([]*types.ProjectDetail, 0, len(projects))
for _, p := range projects {
detail, err := r.parseProjectDetail(p)
if err != nil {
// 如果解析失败,返回基本信息
detail = &types.ProjectDetail{
ID: p.ID,
Name: p.Name,
Type: p.Type,
}
}
details = append(details, detail)
}
return details, total, nil
}
func (r *projectRepo) Get(id uint) (*types.ProjectDetail, error) {
project := new(biz.Project)
if err := r.db.First(project, id).Error; err != nil {
return nil, err
}
return r.parseProjectDetail(project)
}
func (r *projectRepo) Create(req *request.ProjectCreate) (*types.ProjectDetail, error) {
// 检查项目名是否已存在
var count int64
if err := r.db.Model(&biz.Project{}).Where("name = ?", req.Name).Count(&count).Error; err != nil {
return nil, err
}
if count > 0 {
return nil, errors.New(r.t.Get("project name already exists"))
}
project := &biz.Project{
Name: req.Name,
Type: req.Type,
Path: req.RootDir,
}
err := r.db.Transaction(func(tx *gorm.DB) error {
// 创建数据库记录
if err := tx.Create(project).Error; err != nil {
return err
}
// 生成 systemd unit 文件
if err := r.generateUnitFile(project.ID, req); err != nil {
return fmt.Errorf("%s: %w", r.t.Get("failed to generate systemd config"), err)
}
return nil
})
if err != nil {
return nil, err
}
return r.parseProjectDetail(project)
}
func (r *projectRepo) Update(req *request.ProjectUpdate) error {
project := new(biz.Project)
if err := r.db.First(project, req.ID).Error; err != nil {
return err
}
// 如果名称变更,需要重命名 unit 文件
if req.Name != project.Name {
oldPath := r.unitFilePath(project.Name)
newPath := r.unitFilePath(req.Name)
if err := os.Rename(oldPath, newPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("%s: %w", r.t.Get("failed to rename systemd config"), err)
}
project.Name = req.Name
}
project.Path = req.RootDir
if err := r.db.Save(project).Error; err != nil {
return err
}
// 更新 systemd unit 文件
return r.updateUnitFile(project.Name, req)
}
func (r *projectRepo) Delete(id uint) error {
project := new(biz.Project)
if err := r.db.First(project, id).Error; err != nil {
return err
}
// 删除 systemd unit 文件
unitPath := r.unitFilePath(project.Name)
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("%s: %w", r.t.Get("failed to delete systemd config"), err)
}
return r.db.Delete(project).Error
}
// unitFilePath 返回 systemd unit 文件路径
func (r *projectRepo) unitFilePath(name string) string {
return filepath.Join(r.systemdDir, fmt.Sprintf("acepanel-project-%s.service", name))
}
// parseProjectDetail 从数据库记录和 systemd unit 文件解析项目详情
func (r *projectRepo) parseProjectDetail(project *biz.Project) (*types.ProjectDetail, error) {
detail := &types.ProjectDetail{
ID: project.ID,
Name: project.Name,
Type: project.Type,
RootDir: project.Path,
}
// 读取并解析 systemd unit 文件
unitPath := r.unitFilePath(project.Name)
file, err := os.Open(unitPath)
if err != nil {
if os.IsNotExist(err) {
return detail, nil
}
return nil, err
}
defer func(file *os.File) { _ = file.Close() }(file)
options, err := unit.DeserializeOptions(file)
if err != nil {
return nil, fmt.Errorf("%s: %w", r.t.Get("failed to parse systemd config"), err)
}
// 解析各个字段
for _, opt := range options {
switch opt.Section {
case "Unit":
r.parseUnitSection(detail, opt)
case "Service":
r.parseServiceSection(detail, opt)
}
}
return detail, nil
}
// parseUnitSection 解析 [Unit] 部分
func (r *projectRepo) parseUnitSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
switch opt.Name {
case "Description":
detail.Description = opt.Value
case "Requires":
detail.Requires = append(detail.Requires, opt.Value)
case "Wants":
detail.Wants = append(detail.Wants, opt.Value)
case "After":
detail.After = append(detail.After, opt.Value)
case "Before":
detail.Before = append(detail.Before, opt.Value)
}
}
// parseServiceSection 解析 [Service] 部分
func (r *projectRepo) parseServiceSection(detail *types.ProjectDetail, opt *unit.UnitOption) {
switch opt.Name {
case "WorkingDirectory":
detail.WorkingDir = opt.Value
case "ExecStartPre":
detail.ExecStartPre = opt.Value
case "ExecStartPost":
detail.ExecStartPost = opt.Value
case "ExecStart":
detail.ExecStart = opt.Value
case "ExecStop":
detail.ExecStop = opt.Value
case "ExecReload":
detail.ExecReload = opt.Value
case "User":
detail.User = opt.Value
case "Restart":
detail.Restart = opt.Value
case "RestartSec":
detail.RestartSec = opt.Value
case "StartLimitBurst":
if v, err := strconv.Atoi(opt.Value); err == nil {
detail.RestartMax = v
}
case "TimeoutStartSec":
if v, err := strconv.Atoi(opt.Value); err == nil {
detail.TimeoutStartSec = v
}
case "TimeoutStopSec":
if v, err := strconv.Atoi(opt.Value); err == nil {
detail.TimeoutStopSec = v
}
case "Environment":
// 格式: KEY=VALUE
if kv := r.parseEnvironment(opt.Value); kv != nil {
detail.Environments = append(detail.Environments, *kv)
}
case "StandardOutput":
detail.StandardOutput = opt.Value
case "StandardError":
detail.StandardError = opt.Value
case "MemoryLimit":
if v, err := r.parseBytes(opt.Value); err == nil {
detail.MemoryLimit = v
}
case "CPUQuota":
if v, err := r.parsePercent(opt.Value); err == nil {
detail.CPUQuota = v
}
case "NoNewPrivileges":
detail.NoNewPrivileges = opt.Value == "true" || opt.Value == "yes"
case "ProtectTmp":
detail.ProtectTmp = opt.Value == "true" || opt.Value == "yes"
case "ProtectHome":
detail.ProtectHome = opt.Value == "true" || opt.Value == "yes"
case "ProtectSystem":
detail.ProtectSystem = opt.Value
case "ReadWritePaths":
detail.ReadWritePaths = append(detail.ReadWritePaths, opt.Value)
case "ReadOnlyPaths":
detail.ReadOnlyPaths = append(detail.ReadOnlyPaths, opt.Value)
}
}
// parseEnvironment 解析环境变量
func (r *projectRepo) parseEnvironment(value string) *types.KV {
parts := strings.SplitN(value, "=", 2)
if len(parts) != 2 {
return nil
}
return &types.KV{Key: parts[0], Value: parts[1]}
}
// parseBytes 解析字节大小 (如 512M, 1G)
func (r *projectRepo) parseBytes(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, errors.New("empty value")
}
multiplier := float64(1)
suffix := value[len(value)-1]
switch suffix {
case 'K', 'k':
multiplier = 1024
value = value[:len(value)-1]
case 'M', 'm':
multiplier = 1024 * 1024
value = value[:len(value)-1]
case 'G', 'g':
multiplier = 1024 * 1024 * 1024
value = value[:len(value)-1]
}
return cast.ToFloat64(value) * multiplier, nil
}
// formatBytes 格式化字节大小
func (r *projectRepo) formatBytes(bytes float64) string {
b := int64(bytes)
if b >= 1024*1024*1024 && b%(1024*1024*1024) == 0 {
return fmt.Sprintf("%dG", b/(1024*1024*1024))
}
if b >= 1024*1024 && b%(1024*1024) == 0 {
return fmt.Sprintf("%dM", b/(1024*1024))
}
if b >= 1024 && b%1024 == 0 {
return fmt.Sprintf("%dK", b/1024)
}
return strconv.FormatInt(b, 10)
}
// parsePercent 解析百分比 (如 50%)
func (r *projectRepo) parsePercent(value string) (float64, error) {
value = strings.TrimSuffix(value, "%")
return strconv.ParseFloat(value, 64)
}
// generateUnitFile 生成 systemd unit 文件
func (r *projectRepo) generateUnitFile(id uint, req *request.ProjectCreate) error {
options := []*unit.UnitOption{
// [Unit] section
unit.NewUnitOption("Unit", "Description", fmt.Sprintf("AcePanel Project: %s", req.Name)),
unit.NewUnitOption("Unit", "After", "network.target"),
// [Service] section
unit.NewUnitOption("Service", "Type", "simple"),
unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)),
}
if req.ExecStart != "" {
options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
}
if req.User != "" {
options = append(options, unit.NewUnitOption("Service", "User", req.User))
}
if req.Restart != "" {
options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
} else {
options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
}
// 环境变量
for _, env := range req.Environments {
options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
}
// [Install] section
options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
// 写入文件
unitPath := r.unitFilePath(req.Name)
reader := unit.Serialize(options)
content, err := io.ReadAll(reader)
if err != nil {
return err
}
return os.WriteFile(unitPath, content, 0644)
}
// updateUnitFile 更新 systemd unit 文件
func (r *projectRepo) updateUnitFile(name string, req *request.ProjectUpdate) error {
options := []*unit.UnitOption{
// [Unit] section
unit.NewUnitOption("Unit", "Description", req.Description),
}
// Unit 依赖
for _, v := range req.Requires {
options = append(options, unit.NewUnitOption("Unit", "Requires", v))
}
for _, v := range req.Wants {
options = append(options, unit.NewUnitOption("Unit", "Wants", v))
}
for _, v := range req.After {
options = append(options, unit.NewUnitOption("Unit", "After", v))
}
if len(req.After) == 0 {
options = append(options, unit.NewUnitOption("Unit", "After", "network.target"))
}
for _, v := range req.Before {
options = append(options, unit.NewUnitOption("Unit", "Before", v))
}
// [Service] section
options = append(options, unit.NewUnitOption("Service", "Type", "simple"))
options = append(options, unit.NewUnitOption("Service", "WorkingDirectory", lo.If(req.WorkingDir != "", req.WorkingDir).Else(req.RootDir)))
if req.ExecStartPre != "" {
options = append(options, unit.NewUnitOption("Service", "ExecStartPre", req.ExecStartPre))
}
if req.ExecStart != "" {
options = append(options, unit.NewUnitOption("Service", "ExecStart", req.ExecStart))
}
if req.ExecStartPost != "" {
options = append(options, unit.NewUnitOption("Service", "ExecStartPost", req.ExecStartPost))
}
if req.ExecStop != "" {
options = append(options, unit.NewUnitOption("Service", "ExecStop", req.ExecStop))
}
if req.ExecReload != "" {
options = append(options, unit.NewUnitOption("Service", "ExecReload", req.ExecReload))
}
if req.User != "" {
options = append(options, unit.NewUnitOption("Service", "User", req.User))
}
if req.Restart != "" {
options = append(options, unit.NewUnitOption("Service", "Restart", req.Restart))
} else {
options = append(options, unit.NewUnitOption("Service", "Restart", "on-failure"))
}
if req.RestartSec != "" {
options = append(options, unit.NewUnitOption("Service", "RestartSec", req.RestartSec))
}
if req.RestartMax > 0 {
options = append(options, unit.NewUnitOption("Service", "StartLimitBurst", strconv.Itoa(req.RestartMax)))
}
if req.TimeoutStartSec > 0 {
options = append(options, unit.NewUnitOption("Service", "TimeoutStartSec", strconv.Itoa(req.TimeoutStartSec)))
}
if req.TimeoutStopSec > 0 {
options = append(options, unit.NewUnitOption("Service", "TimeoutStopSec", strconv.Itoa(req.TimeoutStopSec)))
}
// 环境变量
for _, env := range req.Environments {
options = append(options, unit.NewUnitOption("Service", "Environment", fmt.Sprintf("%s=%s", env.Key, env.Value)))
}
// 输出
if req.StandardOutput != "" {
options = append(options, unit.NewUnitOption("Service", "StandardOutput", req.StandardOutput))
}
if req.StandardError != "" {
options = append(options, unit.NewUnitOption("Service", "StandardError", req.StandardError))
}
// 资源限制
if req.MemoryLimit > 0 {
options = append(options, unit.NewUnitOption("Service", "MemoryLimit", r.formatBytes(req.MemoryLimit)))
}
if req.CPUQuota != "" {
options = append(options, unit.NewUnitOption("Service", "CPUQuota", req.CPUQuota))
}
// 安全选项
if req.NoNewPrivileges {
options = append(options, unit.NewUnitOption("Service", "NoNewPrivileges", "true"))
}
if req.ProtectTmp {
options = append(options, unit.NewUnitOption("Service", "ProtectTmp", "true"))
}
if req.ProtectHome {
options = append(options, unit.NewUnitOption("Service", "ProtectHome", "true"))
}
if req.ProtectSystem != "" {
options = append(options, unit.NewUnitOption("Service", "ProtectSystem", req.ProtectSystem))
}
for _, v := range req.ReadWritePaths {
options = append(options, unit.NewUnitOption("Service", "ReadWritePaths", v))
}
for _, v := range req.ReadOnlyPaths {
options = append(options, unit.NewUnitOption("Service", "ReadOnlyPaths", v))
}
// [Install] section
options = append(options, unit.NewUnitOption("Install", "WantedBy", "multi-user.target"))
// 写入文件
unitPath := r.unitFilePath(name)
reader := unit.Serialize(options)
content, err := io.ReadAll(reader)
if err != nil {
return err
}
return os.WriteFile(unitPath, content, 0644)
}

View File

@@ -0,0 +1,51 @@
package request
import "github.com/acepanel/panel/pkg/types"
type ProjectCreate struct {
Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
Type types.ProjectType `form:"type" json:"type" validate:"required|in:general,php,java,go,python,nodejs"`
Description string `form:"description" json:"description"`
RootDir string `form:"root_dir" json:"root_dir" validate:"required"`
WorkingDir string `form:"working_dir" json:"working_dir"`
ExecStart string `form:"exec_start" json:"exec_start"`
User string `form:"user" json:"user"`
Restart string `json:"restart"`
Environments []types.KV
}
type ProjectUpdate struct {
ID uint `form:"id" json:"id" validate:"required|exists:projects,id"`
Name string `form:"name" json:"name" validate:"required|regex:^[a-zA-Z0-9_-]+$"`
Description string `form:"description" json:"description"`
RootDir string `form:"root_dir" json:"root_dir" validate:"required"`
WorkingDir string `form:"working_dir" json:"working_dir"`
ExecStartPre string `form:"exec_start_pre" json:"exec_start_pre"`
ExecStartPost string `form:"exec_start_post" json:"exec_start_post"`
ExecStart string `form:"exec_start" json:"exec_start"`
ExecStop string `form:"exec_stop" json:"exec_stop"`
ExecReload string `form:"exec_reload" json:"exec_reload"`
User string `form:"user" json:"user"`
Restart string `json:"restart"`
RestartSec string `json:"restart_sec"`
RestartMax int `json:"restart_max"`
TimeoutStartSec int `json:"timeout_start_sec"`
TimeoutStopSec int `json:"timeout_stop_sec"`
Environments []types.KV `form:"environments" json:"environments"`
StandardOutput string `form:"standard_output" json:"standard_output"`
StandardError string `form:"standard_error" json:"standard_error"`
Requires []string `form:"requires" json:"requires"`
Wants []string `form:"wants" json:"wants"`
After []string `form:"after" json:"after"`
Before []string `form:"before" json:"before"`
MemoryLimit float64 `form:"memory_limit" json:"memory_limit"`
CPUQuota string `form:"cpu_quota" json:"cpu_quota"`
NoNewPrivileges bool `form:"no_new_privileges" json:"no_new_privileges"`
ProtectTmp bool `form:"protect_tmp" json:"protect_tmp"`
ProtectHome bool `form:"protect_home" json:"protect_home"`
ProtectSystem string `form:"protect_system" json:"protect_system"`
ReadWritePaths []string `form:"read_write_paths" json:"read_write_paths"`
ReadOnlyPaths []string `form:"read_only_paths" json:"read_only_paths"`
}

View File

@@ -50,4 +50,13 @@ func init() {
)
},
})
Migrations = append(Migrations, &gormigrate.Migration{
ID: "20260110-add-project",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(&biz.Project{})
},
Rollback: func(tx *gorm.DB) error {
return tx.Migrator().DropTable(&biz.Project{})
},
})
}

View File

@@ -22,6 +22,7 @@ type Http struct {
home *service.HomeService
task *service.TaskService
website *service.WebsiteService
project *service.ProjectService
database *service.DatabaseService
databaseServer *service.DatabaseServerService
databaseUser *service.DatabaseUserService
@@ -61,6 +62,7 @@ func NewHttp(
home *service.HomeService,
task *service.TaskService,
website *service.WebsiteService,
project *service.ProjectService,
database *service.DatabaseService,
databaseServer *service.DatabaseServerService,
databaseUser *service.DatabaseUserService,
@@ -99,6 +101,7 @@ func NewHttp(
home: home,
task: task,
website: website,
project: project,
database: database,
databaseServer: databaseServer,
databaseUser: databaseUser,
@@ -199,6 +202,14 @@ func (route *Http) Register(r *chi.Mux) {
r.Post("/{id}/obtain_cert", route.website.ObtainCert)
})
r.Route("/project", func(r chi.Router) {
r.Get("/", route.project.List)
r.Post("/", route.project.Create)
r.Get("/{id}", route.project.Get)
r.Put("/{id}", route.project.Update)
r.Delete("/{id}", route.project.Delete)
})
r.Route("/database", func(r chi.Router) {
r.Get("/", route.database.List)
r.Post("/", route.database.Create)

101
internal/service/project.go Normal file
View File

@@ -0,0 +1,101 @@
package service
import (
"net/http"
"github.com/libtnb/chix"
"github.com/acepanel/panel/internal/biz"
"github.com/acepanel/panel/internal/http/request"
)
type ProjectService struct {
projectRepo biz.ProjectRepo
}
func NewProjectService(project biz.ProjectRepo) *ProjectService {
return &ProjectService{
projectRepo: project,
}
}
func (s *ProjectService) List(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.Paginate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
projects, total, err := s.projectRepo.List(req.Page, req.Limit)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, chix.M{
"total": total,
"items": projects,
})
}
func (s *ProjectService) Get(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
project, err := s.projectRepo.Get(req.ID)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, project)
}
func (s *ProjectService) Create(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ProjectCreate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
project, err := s.projectRepo.Create(req)
if err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, project)
}
func (s *ProjectService) Update(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ProjectUpdate](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.projectRepo.Update(req); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}
func (s *ProjectService) Delete(w http.ResponseWriter, r *http.Request) {
req, err := Bind[request.ID](r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, "%v", err)
return
}
if err = s.projectRepo.Delete(req.ID); err != nil {
Error(w, http.StatusInternalServerError, "%v", err)
return
}
Success(w, nil)
}

View File

@@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet(
NewHomeService,
NewMonitorService,
NewProcessService,
NewProjectService,
NewSafeService,
NewSettingService,
NewSSHService,

56
pkg/types/project.go Normal file
View File

@@ -0,0 +1,56 @@
package types
type ProjectType string
const (
ProjectTypeGeneral ProjectType = "general"
ProjectTypePHP ProjectType = "php"
ProjectTypeJava ProjectType = "java"
ProjectTypeGo ProjectType = "go"
ProjectTypePython ProjectType = "python"
ProjectTypeNodejs ProjectType = "nodejs"
)
type ProjectDetail struct {
ID uint `json:"id"` // 项目 ID
Name string `json:"name"` // 项目名称
Type ProjectType `json:"type"` // 项目类型
Description string `json:"description"` // 项目描述
RootDir string `json:"root_dir"` // 项目路径
WorkingDir string `json:"working_dir"` // 运行目录
ExecStartPre string `json:"exec_start_pre"` // 启动前命令
ExecStartPost string `json:"exec_start_post"` // 启动后命令
ExecStart string `json:"exec_start"` // 启动命令
ExecStop string `json:"exec_stop"` // 停止命令
ExecReload string `json:"exec_reload"` // 重载命令
User string `json:"user"` // 运行用户
Restart string `json:"restart"` // 重启策略
RestartSec string `json:"restart_sec"` // 重启间隔
RestartMax int `json:"restart_max"` // 最大重启次数
TimeoutStartSec int `json:"timeout_start_sec"` // 启动超时(秒)
TimeoutStopSec int `json:"timeout_stop_sec"` // 停止超时(秒)
Environments []KV `json:"environments"` // 环境变量
StandardOutput string `json:"standard_output"` // 标准输出 journal/file:/path
StandardError string `json:"standard_error"` // 标准错误 journal/file:/path
Requires []string `json:"requires"` // 依赖服务(强依赖)
Wants []string `json:"wants"` // 依赖服务(弱依赖)
After []string `json:"after"` // 启动顺序(在...之后启动)
Before []string `json:"before"` // 启动顺序(在...之前启动)
// 运行状态
Status string `json:"status"` // 运行状态
PID int `json:"pid"` // 进程ID
Memory int64 `json:"memory"` // 内存使用(字节)
CPU float64 `json:"cpu"` // CPU使用率
Uptime string `json:"uptime"` // 运行时间
MemoryLimit float64 `json:"memory_limit"` // 内存限制(字节)
CPUQuota float64 `json:"cpu_quota"` // CPU限制百分比
// 安全相关
NoNewPrivileges bool `json:"no_new_privileges"` // 无新特权
ProtectTmp bool `json:"protect_tmp"` // 保护临时目录
ProtectHome bool `json:"protect_home"` // 保护主目录
ProtectSystem string `json:"protect_system"` // 保护系统 full/strict
ReadWritePaths []string `json:"read_write_paths"` // 读写路径
ReadOnlyPaths []string `json:"read_only_paths"` // 只读路径
}

View File

@@ -13,6 +13,7 @@ const currentTab = ref('general')
<n-tab name="general" :tab="$gettext('General')" />
<n-tab name="php" :tab="$gettext('PHP')" />
<n-tab name="java" :tab="$gettext('Java')" />
<n-tab name="go" :tab="$gettext('go')" />
<n-tab name="python" :tab="$gettext('Python')" />
<n-tab name="nodejs" :tab="$gettext('Node.js')" />
</n-tabs>