diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index 7d647d32..5b4b858f 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -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) diff --git a/go.mod b/go.mod index cd50c7bf..6a5caf94 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 38008f95..ac1748f3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/biz/project.go b/internal/biz/project.go new file mode 100644 index 00000000..54910147 --- /dev/null +++ b/internal/biz/project.go @@ -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 +} diff --git a/internal/biz/website.go b/internal/biz/website.go index 752a11da..6cba72ee 100644 --- a/internal/biz/website.go +++ b/internal/biz/website.go @@ -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"` diff --git a/internal/data/data.go b/internal/data/data.go index a430a0c1..b848a8b9 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet( NewDatabaseUserRepo, NewEnvironmentRepo, NewMonitorRepo, + NewProjectRepo, NewSafeRepo, NewSettingRepo, NewSSHRepo, diff --git a/internal/data/project.go b/internal/data/project.go new file mode 100644 index 00000000..47928336 --- /dev/null +++ b/internal/data/project.go @@ -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) +} diff --git a/internal/http/request/project.go b/internal/http/request/project.go new file mode 100644 index 00000000..373eb1c4 --- /dev/null +++ b/internal/http/request/project.go @@ -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"` +} diff --git a/internal/migration/v1.go b/internal/migration/v1.go index 4833227d..682cc12b 100644 --- a/internal/migration/v1.go +++ b/internal/migration/v1.go @@ -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{}) + }, + }) } diff --git a/internal/route/http.go b/internal/route/http.go index e385f536..4beadf25 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -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) diff --git a/internal/service/project.go b/internal/service/project.go new file mode 100644 index 00000000..f069b743 --- /dev/null +++ b/internal/service/project.go @@ -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) +} diff --git a/internal/service/service.go b/internal/service/service.go index 065972ba..1a0ed291 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -26,6 +26,7 @@ var ProviderSet = wire.NewSet( NewHomeService, NewMonitorService, NewProcessService, + NewProjectService, NewSafeService, NewSettingService, NewSSHService, diff --git a/pkg/types/project.go b/pkg/types/project.go new file mode 100644 index 00000000..4fdcf144 --- /dev/null +++ b/pkg/types/project.go @@ -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"` // 只读路径 +} diff --git a/web/src/views/project/IndexView.vue b/web/src/views/project/IndexView.vue index e9c0c773..75a8e50f 100644 --- a/web/src/views/project/IndexView.vue +++ b/web/src/views/project/IndexView.vue @@ -13,6 +13,7 @@ const currentTab = ref('general') +