From c0e4044dabcb3acac2f7db1530a0bb289650dece Mon Sep 17 00:00:00 2001 From: panshuxiao Date: Sun, 7 Sep 2025 10:34:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=E5=BA=94=E7=94=A8=E5=95=86=E5=BA=97?= =?UTF-8?q?=E7=9A=84=E7=94=A8=E6=88=B7=E5=AE=9E=E4=BE=8B=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=88=B0=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/appstore/user_app_instance.go | 133 +++++++ models/migrations/devstar_v1_0/dv4.go | 23 ++ routers/api/v1/appstore_app.go | 3 +- routers/web/user/setting/appstore.go | 129 ++++++- routers/web/web.go | 1 + services/appstore/k8s_application_mapper.go | 4 +- services/appstore/k8s_manager.go | 6 +- services/appstore/manager.go | 395 +++++++++++++++----- templates/user/settings/appstore.tmpl | 130 +++---- 9 files changed, 651 insertions(+), 173 deletions(-) create mode 100644 models/appstore/user_app_instance.go create mode 100644 models/migrations/devstar_v1_0/dv4.go diff --git a/models/appstore/user_app_instance.go b/models/appstore/user_app_instance.go new file mode 100644 index 0000000000..174a7b534f --- /dev/null +++ b/models/appstore/user_app_instance.go @@ -0,0 +1,133 @@ +// Copyright 2024 The Devstar Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package appstore + +import ( + "context" + + "code.gitea.io/gitea/models/db" + "code.gitea.io/gitea/modules/timeutil" + "xorm.io/builder" +) + +// UserAppInstance 用户应用实例 +type UserAppInstance struct { + ID int64 `xorm:"pk autoincr"` + UserID int64 `xorm:"INDEX NOT NULL"` // 用户ID + AppID string `xorm:"INDEX NOT NULL"` // 应用模板ID + InstanceName string `xorm:"NOT NULL"` // 实例名称(用户自定义) + + // 用户配置 + UserConfig string `xorm:"TEXT"` // 用户配置JSON + MergedApp string `xorm:"TEXT"` // 合并后的完整应用配置JSON + + // 部署信息 + DeployType string `xorm:"NOT NULL"` // 实际部署类型 (kubernetes/docker) + Kubeconfig string `xorm:"TEXT"` // Kubeconfig内容(加密存储) + KubeconfigContext string // Kubeconfig context + + // 元数据 + CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"` + UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"` +} + +// TableName 设置表名 +func (uai *UserAppInstance) TableName() string { + return "user_app_instance" +} + +// BeforeInsert 插入前的钩子 +func (uai *UserAppInstance) BeforeInsert() { + uai.CreatedUnix = timeutil.TimeStampNow() + uai.UpdatedUnix = timeutil.TimeStampNow() +} + +// BeforeUpdate 更新前的钩子 +func (uai *UserAppInstance) BeforeUpdate() { + uai.UpdatedUnix = timeutil.TimeStampNow() +} + +// GetUserAppInstances 按用户查询应用实例 +func GetUserAppInstances(ctx context.Context, userID int64) ([]*UserAppInstance, error) { + return db.Find[UserAppInstance](ctx, FindUserAppInstanceOptions{ + UserID: userID, + }) +} + +// GetUserAppInstance 查询特定应用实例 +func GetUserAppInstance(ctx context.Context, userID, instanceID int64) (*UserAppInstance, error) { + instance := new(UserAppInstance) + has, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", instanceID, userID).Get(instance) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return instance, nil +} + +// GetUserAppInstanceByAppID 按应用ID查询用户的实例 +func GetUserAppInstanceByAppID(ctx context.Context, userID int64, appID string) (*UserAppInstance, error) { + instance := new(UserAppInstance) + has, err := db.GetEngine(ctx).Where("user_id = ? AND app_id = ?", userID, appID).Get(instance) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return instance, nil +} + +// CreateUserAppInstance 创建用户应用实例 +func CreateUserAppInstance(ctx context.Context, instance *UserAppInstance) error { + return db.Insert(ctx, instance) +} + +// UpdateUserAppInstance 更新用户应用实例 +func UpdateUserAppInstance(ctx context.Context, instance *UserAppInstance) error { + _, err := db.GetEngine(ctx).ID(instance.ID).Update(instance) + return err +} + +// DeleteUserAppInstance 删除用户应用实例 +func DeleteUserAppInstance(ctx context.Context, userID, instanceID int64) error { + _, err := db.GetEngine(ctx).Where("id = ? AND user_id = ?", instanceID, userID).Delete(&UserAppInstance{}) + return err +} + +// DeleteUserAppInstanceByAppID 按应用ID删除用户实例 +func DeleteUserAppInstanceByAppID(ctx context.Context, userID int64, appID string) error { + _, err := db.GetEngine(ctx).Where("user_id = ? AND app_id = ?", userID, appID).Delete(&UserAppInstance{}) + return err +} + +// FindUserAppInstanceOptions 查询选项 +type FindUserAppInstanceOptions struct { + db.ListOptions + UserID int64 // 用户ID + AppID string // 应用ID(可选) +} + +// ToConds 转换为查询条件 +func (opts FindUserAppInstanceOptions) ToConds() builder.Cond { + conds := builder.NewCond() + if opts.UserID != 0 { + conds = conds.And(builder.Eq{"user_id": opts.UserID}) + } + if opts.AppID != "" { + conds = conds.And(builder.Eq{"app_id": opts.AppID}) + } + return conds +} + +// ToOrders 转换为排序条件 +func (opts FindUserAppInstanceOptions) ToOrders() string { + return "id DESC" +} + +func init() { + db.RegisterModel(new(UserAppInstance)) +} diff --git a/models/migrations/devstar_v1_0/dv4.go b/models/migrations/devstar_v1_0/dv4.go new file mode 100644 index 0000000000..5c825717bd --- /dev/null +++ b/models/migrations/devstar_v1_0/dv4.go @@ -0,0 +1,23 @@ +package devstar_v1_0 + +// 构建 DevStar Studio v1.0 所需数据库类型 +// 从 dv3 到 dv4 - 添加用户应用实例表 + +import ( + appstore_model "code.gitea.io/gitea/models/appstore" + "xorm.io/xorm" +) + +// AddUserAppInstanceTable 创建用户应用实例表 +func AddUserAppInstanceTable(x *xorm.Engine) error { + // 创建用户应用实例表 + err := x.Sync(new(appstore_model.UserAppInstance)) + if err != nil { + return ErrMigrateDevstarDatabase{ + Step: "create table 'user_app_instance'", + Message: err.Error(), + } + } + + return nil +} diff --git a/routers/api/v1/appstore_app.go b/routers/api/v1/appstore_app.go index 9aa42f9cfd..04f1de888f 100644 --- a/routers/api/v1/appstore_app.go +++ b/routers/api/v1/appstore_app.go @@ -11,7 +11,8 @@ import ( func AppStorePublicApps(ctx *context.APIContext) { // 将 APIContext 转换为 Context webCtx := &context.Context{Base: ctx.Base} - manager := appstore.NewManager(webCtx) + // 对于公开API,使用0作为用户ID + manager := appstore.NewManager(webCtx, 0) apps, err := manager.ListApps() if err != nil { ctx.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()}) diff --git a/routers/web/user/setting/appstore.go b/routers/web/user/setting/appstore.go index 7c9f688456..3b8934b644 100644 --- a/routers/web/user/setting/appstore.go +++ b/routers/web/user/setting/appstore.go @@ -55,7 +55,7 @@ func handleGetApps(ctx *context.Context) { deployment := ctx.FormString("deployment") // docker, kubernetes source := ctx.FormString("source") // local | devstar - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) var apps []appstore.App var err error @@ -215,7 +215,7 @@ func AppStoreStatus(ctx *context.Context) { return } - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) status, err := manager.GetAppStatus(appID) if err != nil { if appErr, ok := err.(*appstore.AppStoreError); ok { @@ -265,7 +265,7 @@ func AppStoreInstall(ctx *context.Context) { } // 创建 manager 并执行安装 - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) if err := manager.InstallApp(appID, configJSON, installTarget, kubeconfig, kubeconfigContext); err != nil { // 根据错误类型返回相应的状态码和消息 if appErr, ok := err.(*appstore.AppStoreError); ok { @@ -332,7 +332,7 @@ func AppStoreUpdate(ctx *context.Context) { } // 创建 manager 并执行更新 - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) if err := manager.UpdateInstalledApp(appID, configJSON, installTarget, kubeconfig, kubeconfigContext); err != nil { // 根据错误类型返回相应的状态码和消息 if appErr, ok := err.(*appstore.AppStoreError); ok { @@ -392,7 +392,7 @@ func AppStoreUninstall(ctx *context.Context) { } // 创建 manager 并执行卸载 - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) if err := manager.UninstallApp(appID, installTarget, kubeconfig, kubeconfigContext); err != nil { // 根据错误类型返回相应的状态码和消息 if appErr, ok := err.(*appstore.AppStoreError); ok { @@ -416,7 +416,68 @@ func AppStoreUninstall(ctx *context.Context) { if installTarget == "kubeconfig" && kubeconfig != "" { ctx.Flash.Success("应用已成功从指定位置卸载") } else { - ctx.Flash.Success("本地卸载功能开发中") + ctx.Flash.Success("应用已成功从默认位置卸载") + } + + // 根据来源页面决定重定向位置 + referer := ctx.Req.Header.Get("Referer") + if strings.Contains(referer, "/admin/appstore") { + ctx.Redirect(setting.AppSubURL + "/admin/appstore") + } else { + ctx.Redirect(setting.AppSubURL + "/user/settings/appstore") + } +} + +// AppStoreResume handles app resuming (restoring original replica count) +func AppStoreResume(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + + if ctx.Req.Method != "POST" { + ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) + return + } + + // 解析表单数据 + appID := ctx.FormString("app_id") + installTarget := ctx.FormString("install_target") + kubeconfig := ctx.FormString("kubeconfig") + kubeconfigContext := ctx.FormString("kubeconfig_context") + + if appID == "" { + ctx.JSON(400, map[string]string{"error": "应用ID不能为空"}) + return + } + + // 创建 manager 并执行恢复 + manager := appstore.NewManager(ctx, ctx.Doer.ID) + if err := manager.ResumeApp(appID, installTarget, []byte(kubeconfig), kubeconfigContext); err != nil { + // 根据错误类型返回相应的状态码和消息 + if appErr, ok := err.(*appstore.AppStoreError); ok { + switch appErr.Code { + case "APP_NOT_FOUND": + ctx.JSON(400, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "KUBERNETES_RESUME_ERROR": + ctx.JSON(500, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "UNSUPPORTED_DEPLOYMENT_TYPE": + ctx.JSON(400, map[string]string{"error": appErr.Message}) + default: + ctx.JSON(500, map[string]string{"error": appErr.Message}) + } + } else { + ctx.JSON(500, map[string]string{"error": "恢复失败: " + err.Error()}) + } + return + } + + // 恢复成功 + if installTarget == "kubeconfig" && kubeconfig != "" { + ctx.Flash.Success("应用已成功在指定位置恢复") + } else { + ctx.Flash.Success("应用已成功恢复") } // 根据来源页面决定重定向位置 @@ -453,7 +514,7 @@ func AppStoreStop(ctx *context.Context) { } // 创建 manager 并执行暂停 - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) if err := manager.StopApp(appID, installTarget, []byte(kubeconfig), kubeconfigContext); err != nil { // 根据错误类型返回相应的状态码和消息 if appErr, ok := err.(*appstore.AppStoreError); ok { @@ -523,7 +584,7 @@ func AddAppAPI(ctx *context.Context) { return } - manager := appstore.NewManager(ctx) + manager := appstore.NewManager(ctx, ctx.Doer.ID) if ctx.Req.Method != "POST" { ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) return @@ -540,3 +601,55 @@ func AddAppAPI(ctx *context.Context) { } ctx.JSON(200, map[string]string{"msg": "ok"}) } + +// AppStoreDelete handles app deletion from database +func AppStoreDelete(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + + if ctx.Req.Method != "POST" { + ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) + return + } + + // 解析表单数据 + appID := ctx.FormString("app_id") + + if appID == "" { + ctx.JSON(400, map[string]string{"error": "应用ID不能为空"}) + return + } + + // 创建 manager 并执行删除 + manager := appstore.NewManager(ctx, ctx.Doer.ID) + if err := manager.RemoveApp(appID); err != nil { + // 根据错误类型返回相应的状态码和消息 + if appErr, ok := err.(*appstore.AppStoreError); ok { + switch appErr.Code { + case "APP_NOT_FOUND": + ctx.JSON(400, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "DATABASE_ERROR": + ctx.JSON(500, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + default: + ctx.JSON(500, map[string]string{"error": appErr.Message}) + } + } else { + ctx.JSON(500, map[string]string{"error": "删除失败: " + err.Error()}) + } + return + } + + // 删除成功 + ctx.Flash.Success("应用已成功删除") + + // 根据来源页面决定重定向位置 + referer := ctx.Req.Header.Get("Referer") + if strings.Contains(referer, "/admin/appstore") { + ctx.Redirect(setting.AppSubURL + "/admin/appstore") + } else { + ctx.Redirect(setting.AppSubURL + "/user/settings/appstore") + } +} diff --git a/routers/web/web.go b/routers/web/web.go index bde9c3f687..2d860090af 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -711,6 +711,7 @@ func registerWebRoutes(m *web.Router) { m.Post("/install/{app_id}", user_setting.AppStoreInstall) m.Post("/update/{app_id}", user_setting.AppStoreUpdate) m.Post("/stop/{app_id}", user_setting.AppStoreStop) + m.Post("/resume/{app_id}", user_setting.AppStoreResume) m.Post("/uninstall/{app_id}", user_setting.AppStoreUninstall) m.Get("/configure/{app_id}", user_setting.AppStoreConfigure) m.Post("/configure/{app_id}", user_setting.AppStoreConfigurePost) diff --git a/services/appstore/k8s_application_mapper.go b/services/appstore/k8s_application_mapper.go index d360dbd671..d4ffcfd4b1 100644 --- a/services/appstore/k8s_application_mapper.go +++ b/services/appstore/k8s_application_mapper.go @@ -103,7 +103,7 @@ func BuildK8sCreateOptions(app *App) (*application.CreateApplicationOptions, err // Replicas var replicasPtr *int32 - if k.Replicas > 0 { + if k.Replicas >= 0 { r := int32(k.Replicas) replicasPtr = &r } @@ -261,7 +261,7 @@ func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applica } var replicasPtr *int32 - if k.Replicas > 0 { + if k.Replicas >= 0 { r := int32(k.Replicas) replicasPtr = &r } diff --git a/services/appstore/k8s_manager.go b/services/appstore/k8s_manager.go index 8eca751448..b905701963 100644 --- a/services/appstore/k8s_manager.go +++ b/services/appstore/k8s_manager.go @@ -10,6 +10,7 @@ import ( k8s "code.gitea.io/gitea/modules/k8s" applicationv1 "code.gitea.io/gitea/modules/k8s/api/application/v1" "code.gitea.io/gitea/modules/k8s/application" + "code.gitea.io/gitea/modules/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -237,10 +238,11 @@ func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, context // 先获取现有的应用,以保留元数据(特别是 resourceVersion) existingApp, err := km.GetAppFromKubernetes(app, kubeconfig, contextName) if err != nil { - // 如果应用不存在,返回错误 + // 如果应用不存在,尝试创建新应用 + log.Warn("Application not found in Kubernetes, attempting to create new application: %v", err) return &AppStoreError{ Code: "APP_NOT_FOUND", - Message: "应用在 Kubernetes 中不存在,无法更新", + Message: "应用在 Kubernetes 中不存在,请先安装应用", Details: err.Error(), } } diff --git a/services/appstore/manager.go b/services/appstore/manager.go index e57916c776..829b44be92 100644 --- a/services/appstore/manager.go +++ b/services/appstore/manager.go @@ -12,6 +12,7 @@ import ( "time" appstore_model "code.gitea.io/gitea/models/appstore" + user_app_instance "code.gitea.io/gitea/models/appstore" applicationv1 "code.gitea.io/gitea/modules/k8s/api/application/v1" "code.gitea.io/gitea/modules/log" gitea_context "code.gitea.io/gitea/services/context" @@ -22,14 +23,16 @@ type Manager struct { parser *Parser ctx context.Context k8s *K8sManager + userID int64 // 当前用户ID } // NewManager creates a new app store manager for database operations -func NewManager(ctx *gitea_context.Context) *Manager { +func NewManager(ctx *gitea_context.Context, userID int64) *Manager { return &Manager{ parser: NewParser(), ctx: *ctx, k8s: NewK8sManager(*ctx), + userID: userID, } } @@ -54,6 +57,32 @@ func (m *Manager) ListApps() ([]App, error) { return apps, nil } +// ListUserAppInstances returns user's installed application instances +func (m *Manager) ListUserAppInstances() ([]App, error) { + // 获取用户的应用实例 + instances, err := user_app_instance.GetUserAppInstances(m.ctx, m.userID) + if err != nil { + return nil, &AppStoreError{ + Code: "DATABASE_ERROR", + Message: "获取用户应用实例失败", + Details: err.Error(), + } + } + + var apps []App + for _, instance := range instances { + // 从实例的 MergedApp JSON 中解析应用信息 + var app App + if err := json.Unmarshal([]byte(instance.MergedApp), &app); err != nil { + log.Error("Failed to unmarshal merged app for instance %d: %v", instance.ID, err) + continue + } + apps = append(apps, app) + } + + return apps, nil +} + // ListAppsFromDevstar 从 devstar.cn 拉取应用列表 func (m *Manager) ListAppsFromDevstar() ([]App, error) { client := &http.Client{Timeout: 10 * time.Second} @@ -350,27 +379,18 @@ func (m *Manager) UpdateApp(app *App) error { return nil } -// RemoveApp removes an application from the database +// RemoveApp removes a user's application instance from the database func (m *Manager) RemoveApp(appID string) error { - // Get existing app from database - existingAppStore, err := appstore_model.GetAppStoreByAppID(m.ctx, appID) - if err != nil { - return &AppStoreError{ - Code: "APP_NOT_FOUND", - Message: "App not found in database", - Details: err.Error(), - } - } - - // Delete from database - if err := appstore_model.DeleteAppStore(m.ctx, existingAppStore.ID); err != nil { + // 删除用户的应用实例 + if err := user_app_instance.DeleteUserAppInstanceByAppID(m.ctx, m.userID, appID); err != nil { return &AppStoreError{ Code: "DATABASE_ERROR", - Message: "Failed to delete app from database", + Message: "删除应用实例失败", Details: err.Error(), } } + log.Info("Successfully removed app instance %s for user %d", appID, m.userID) return nil } @@ -427,6 +447,17 @@ func (m *Manager) InstallApp(appID string, configJSON string, installTarget stri } } + // 确定部署类型 + deployType := mergedApp.Deploy.Type + if deployType == "" { + // 如果 Deploy.Type 为空,根据 DeploymentType 推断 + if mergedApp.DeploymentType == "kubernetes" || mergedApp.DeploymentType == "both" { + deployType = "kubernetes" + } else if mergedApp.DeploymentType == "docker" { + deployType = "docker" + } + } + // 根据安装目标和应用实际部署类型决定安装方式 if installTarget == "kubeconfig" && kubeconfig != "" { // 安装到外部 Kubernetes 集群 @@ -440,7 +471,7 @@ func (m *Manager) InstallApp(appID string, configJSON string, installTarget stri } else { // 根据应用实际部署类型决定本地安装方式 log.Info("InstallApp: mergedApp.Deploy.Type = %s", mergedApp.Deploy.Type) - switch mergedApp.Deploy.Type { + switch deployType { case "kubernetes": // 应用要部署到 Kubernetes,安装到本地 K8s 集群 if err := m.InstallAppToKubernetes(mergedApp, nil, ""); err != nil { @@ -467,13 +498,61 @@ func (m *Manager) InstallApp(appID string, configJSON string, installTarget stri } } + // 安装成功后,保存用户应用实例到数据库 + mergedAppJSON, err := json.Marshal(mergedApp) + if err != nil { + log.Error("Failed to marshal merged app: %v", err) + return &AppStoreError{ + Code: "JSON_MARSHAL_ERROR", + Message: "保存应用配置失败", + Details: err.Error(), + } + } + + instance := &user_app_instance.UserAppInstance{ + UserID: m.userID, + AppID: appID, + InstanceName: mergedApp.Name, // 使用应用名称作为实例名称 + UserConfig: configJSON, + MergedApp: string(mergedAppJSON), + DeployType: deployType, + Kubeconfig: kubeconfig, + KubeconfigContext: kubeconfigContext, + } + + if err := user_app_instance.CreateUserAppInstance(m.ctx, instance); err != nil { + log.Error("Failed to create user app instance: %v", err) + return &AppStoreError{ + Code: "DATABASE_ERROR", + Message: "保存应用实例失败", + Details: err.Error(), + } + } + + log.Info("Successfully installed app %s for user %d", appID, m.userID) return nil } // UpdateInstalledApp updates an already installed application with new configuration // The update flow mirrors InstallApp: merge config → choose target → call K8s/Docker updater func (m *Manager) UpdateInstalledApp(appID string, configJSON string, installTarget string, kubeconfig string, kubeconfigContext string) error { - // 获取应用信息 + // 获取用户的应用实例 + instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID) + if err != nil { + return &AppStoreError{ + Code: "DATABASE_ERROR", + Message: "获取应用实例失败", + Details: err.Error(), + } + } + if instance == nil { + return &AppStoreError{ + Code: "APP_NOT_INSTALLED", + Message: "应用未安装", + } + } + + // 获取应用模板信息 app, err := m.GetApp(appID) if err != nil { return &AppStoreError{ @@ -493,52 +572,87 @@ func (m *Manager) UpdateInstalledApp(appID string, configJSON string, installTar } } + // 确定部署类型 + deployType := mergedApp.Deploy.Type + if deployType == "" { + deployType = instance.DeployType // 使用实例中保存的部署类型 + } + // 根据安装目标和应用实际部署类型决定更新方式 if installTarget == "kubeconfig" && kubeconfig != "" { // 更新外部 Kubernetes 集群中的应用实例 - switch mergedApp.Deploy.Type { - case "kubernetes": + if deployType == "kubernetes" { if err := m.UpdateAppInKubernetes(mergedApp, []byte(kubeconfig), kubeconfigContext); err != nil { return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "Kubernetes 更新失败", Details: err.Error()} } - return nil - case "docker": + } else { return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "外部环境 Docker 更新功能开发中"} - default: - return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "外部环境更新功能开发中"} } - } - - // 本地更新(依据应用部署类型) - switch mergedApp.Deploy.Type { - case "kubernetes": - if err := m.UpdateAppInKubernetes(mergedApp, nil, ""); err != nil { - return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "本地 Kubernetes 更新失败", Details: err.Error()} - } - return nil - case "docker": - // TODO: 实现 Docker 更新逻辑 - return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地 Docker 更新功能开发中"} - default: - // 未知部署类型,优先尝试 K8s(若 deployment_type 支持) - if mergedApp.DeploymentType == "kubernetes" || mergedApp.DeploymentType == "both" { + } else { + // 本地更新(依据应用部署类型) + if deployType == "kubernetes" { if err := m.UpdateAppInKubernetes(mergedApp, nil, ""); err != nil { return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "本地 Kubernetes 更新失败", Details: err.Error()} } - return nil + } else { + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地 Docker 更新功能开发中"} } - return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地更新功能开发中"} } + + // 更新成功后,更新用户应用实例 + mergedAppJSON, err := json.Marshal(mergedApp) + if err != nil { + log.Error("Failed to marshal merged app: %v", err) + return &AppStoreError{ + Code: "JSON_MARSHAL_ERROR", + Message: "保存应用配置失败", + Details: err.Error(), + } + } + + instance.UserConfig = configJSON + instance.MergedApp = string(mergedAppJSON) + instance.DeployType = deployType + instance.Kubeconfig = kubeconfig + instance.KubeconfigContext = kubeconfigContext + + if err := user_app_instance.UpdateUserAppInstance(m.ctx, instance); err != nil { + log.Error("Failed to update user app instance: %v", err) + return &AppStoreError{ + Code: "DATABASE_ERROR", + Message: "更新应用实例失败", + Details: err.Error(), + } + } + + log.Info("Successfully updated app %s for user %d", appID, m.userID) + return nil } // UninstallApp uninstalls an application based on the provided parameters func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig string, kubeconfigContext string) error { - // 获取应用信息 - app, err := m.GetApp(appID) + // 获取用户的应用实例 + instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID) if err != nil { return &AppStoreError{ - Code: "APP_NOT_FOUND", - Message: "获取应用失败", + Code: "DATABASE_ERROR", + Message: "获取应用实例失败", + Details: err.Error(), + } + } + if instance == nil { + return &AppStoreError{ + Code: "APP_NOT_INSTALLED", + Message: "应用未安装", + } + } + + // 从实例中解析应用信息 + var app App + if err := json.Unmarshal([]byte(instance.MergedApp), &app); err != nil { + return &AppStoreError{ + Code: "JSON_UNMARSHAL_ERROR", + Message: "解析应用配置失败", Details: err.Error(), } } @@ -546,60 +660,47 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st // 根据安装目标和应用实际部署类型决定卸载方式 if installTarget == "kubeconfig" && kubeconfig != "" { // 从外部 Kubernetes 集群卸载 - if err := m.UninstallAppFromKubernetes(app, []byte(kubeconfig), kubeconfigContext); err != nil { + if instance.DeployType == "kubernetes" { + if err := m.UninstallAppFromKubernetes(&app, []byte(kubeconfig), kubeconfigContext); err != nil { + return &AppStoreError{ + Code: "KUBERNETES_UNINSTALL_ERROR", + Message: "Kubernetes 卸载失败", + Details: err.Error(), + } + } + } else { return &AppStoreError{ - Code: "KUBERNETES_UNINSTALL_ERROR", - Message: "Kubernetes 卸载失败", - Details: err.Error(), + Code: "NOT_IMPLEMENTED", + Message: "外部环境 Docker 卸载功能开发中", } } } else { - // 优先检查应用实际部署类型 - if app.Deploy.Type == "kubernetes" { - // 应用实际部署在 Kubernetes,从本地 K8s 集群卸载 - if err := m.UninstallAppFromKubernetes(app, nil, ""); err != nil { + // 本地卸载(依据实例中保存的部署类型) + if instance.DeployType == "kubernetes" { + if err := m.UninstallAppFromKubernetes(&app, nil, ""); err != nil { return &AppStoreError{ Code: "KUBERNETES_UNINSTALL_ERROR", Message: "本地 Kubernetes 卸载失败", Details: err.Error(), } } - return nil - } else if app.Deploy.Type == "docker" { - // 应用实际部署在 Docker,从本地 Docker 卸载 - // TODO: 实现 Docker 卸载逻辑 + } else { return &AppStoreError{ Code: "NOT_IMPLEMENTED", Message: "本地 Docker 卸载功能开发中", } - } else if app.Deploy.Type == "" { - // 如果 Deploy.Type 为空,根据应用支持的部署类型尝试卸载 - if app.DeploymentType == "kubernetes" || app.DeploymentType == "both" { - // 尝试从 Kubernetes 卸载 - if err := m.UninstallAppFromKubernetes(app, nil, ""); err != nil { - return &AppStoreError{ - Code: "KUBERNETES_UNINSTALL_ERROR", - Message: "本地 Kubernetes 卸载失败", - Details: err.Error(), - } - } - return nil - } else if app.DeploymentType == "docker" { - // TODO: 实现 Docker 卸载逻辑 - return &AppStoreError{ - Code: "NOT_IMPLEMENTED", - Message: "本地 Docker 卸载功能开发中", - } - } - } - - // 如果以上都不匹配,返回未知部署类型错误 - return &AppStoreError{ - Code: "UNKNOWN_DEPLOYMENT_TYPE", - Message: "未知的部署类型,无法确定卸载方式", } } + // 卸载成功后,删除用户应用实例 + if err := user_app_instance.DeleteUserAppInstanceByAppID(m.ctx, m.userID, appID); err != nil { + log.Error("Failed to delete user app instance: %v", err) + // 注意:这里不返回错误,因为 Kubernetes 资源已经成功卸载 + // 数据库记录删除失败不应该影响卸载操作的成功 + log.Warn("Kubernetes resources uninstalled successfully, but failed to delete database record for app %s, user %d", appID, m.userID) + } + + log.Info("Successfully uninstalled app %s for user %d", appID, m.userID) return nil } @@ -635,24 +736,51 @@ func (m *Manager) IsInstalledAppReady(status *applicationv1.ApplicationStatus) b // GetAppStatus 获取应用的安装和运行状态 func (m *Manager) GetAppStatus(appID string) (map[string]interface{}, error) { - app, err := m.GetApp(appID) + // 获取用户的应用实例 + instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID) if err != nil { return nil, &AppStoreError{ - Code: "APP_NOT_FOUND", - Message: "应用不存在", + Code: "DATABASE_ERROR", + Message: "获取应用实例失败", + Details: err.Error(), + } + } + if instance == nil { + // 应用未安装 + return map[string]interface{}{ + "phase": "Not Installed", + "replicas": 0, + "readyReplicas": 0, + "ready": false, + }, nil + } + + // 从实例中解析应用信息 + var app App + if err := json.Unmarshal([]byte(instance.MergedApp), &app); err != nil { + return nil, &AppStoreError{ + Code: "JSON_UNMARSHAL_ERROR", + Message: "解析应用配置失败", Details: err.Error(), } } - // 检查应用是否已安装 - // 优先检查实际部署类型,如果为空则检查支持的部署类型 - if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + // 检查应用是否支持 Kubernetes 部署 + if instance.DeployType == "kubernetes" { // 尝试从 Kubernetes 获取状态 - k8sApp, err := m.GetAppFromKubernetes(app, nil, "") + var kubeconfig []byte + var contextName string + if instance.Kubeconfig != "" { + kubeconfig = []byte(instance.Kubeconfig) + contextName = instance.KubeconfigContext + } + + k8sApp, err := m.GetAppFromKubernetes(&app, kubeconfig, contextName) if err != nil { - // 应用未安装或获取状态失败 + // 如果应用实例存在于数据库中,但 Kubernetes 中找不到,可能是被暂停了 + // 返回暂停状态而不是未安装状态 return map[string]interface{}{ - "phase": "Not Installed", + "phase": "Paused", "replicas": 0, "readyReplicas": 0, "ready": false, @@ -698,20 +826,36 @@ func (m *Manager) GetAppStatus(appID string) (map[string]interface{}, error) { // StopApp stops an application by setting replicas to 0 func (m *Manager) StopApp(appID string, installTarget string, kubeconfig []byte, kubeconfigContext string) error { - // 获取应用信息 - app, err := m.GetApp(appID) + // 获取用户的应用实例 + instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID) if err != nil { return &AppStoreError{ - Code: "APP_NOT_FOUND", - Message: "获取应用失败", + Code: "DATABASE_ERROR", + Message: "获取应用实例失败", + Details: err.Error(), + } + } + if instance == nil { + return &AppStoreError{ + Code: "APP_NOT_INSTALLED", + Message: "应用未安装", + } + } + + // 从实例中解析应用信息 + var app App + if err := json.Unmarshal([]byte(instance.MergedApp), &app); err != nil { + return &AppStoreError{ + Code: "JSON_UNMARSHAL_ERROR", + Message: "解析应用配置失败", Details: err.Error(), } } // 检查应用是否支持 Kubernetes 部署 - if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + if instance.DeployType == "kubernetes" { // 创建应用的副本并设置副本数为 0 - stoppedApp := *app + stoppedApp := app if stoppedApp.Deploy.Kubernetes != nil { stoppedApp.Deploy.Kubernetes.Replicas = 0 } @@ -730,6 +874,63 @@ func (m *Manager) StopApp(appID string, installTarget string, kubeconfig []byte, // 如果应用不支持 Kubernetes 部署,返回错误 return &AppStoreError{ Code: "UNSUPPORTED_DEPLOYMENT_TYPE", - Message: "应用不支持 Kubernetes 部署,无法停止", + Message: fmt.Sprintf("应用部署类型 '%s' 不支持暂停功能,目前仅支持 Kubernetes 部署的应用", instance.DeployType), + } +} + +// ResumeApp resumes an application by restoring its original replica count +func (m *Manager) ResumeApp(appID string, installTarget string, kubeconfig []byte, kubeconfigContext string) error { + // 获取用户的应用实例 + instance, err := user_app_instance.GetUserAppInstanceByAppID(m.ctx, m.userID, appID) + if err != nil { + return &AppStoreError{ + Code: "DATABASE_ERROR", + Message: "获取应用实例失败", + Details: err.Error(), + } + } + if instance == nil { + return &AppStoreError{ + Code: "APP_NOT_INSTALLED", + Message: "应用未安装", + } + } + + // 从实例中解析应用信息 + var app App + if err := json.Unmarshal([]byte(instance.MergedApp), &app); err != nil { + return &AppStoreError{ + Code: "JSON_UNMARSHAL_ERROR", + Message: "解析应用配置失败", + Details: err.Error(), + } + } + + // 检查应用是否支持 Kubernetes 部署 + if instance.DeployType == "kubernetes" { + // 恢复应用的原始副本数(从 MergedApp 中获取) + resumedApp := app + if resumedApp.Deploy.Kubernetes != nil { + // 如果 MergedApp 中没有指定副本数,默认恢复为 1 + if resumedApp.Deploy.Kubernetes.Replicas == 0 { + resumedApp.Deploy.Kubernetes.Replicas = 1 + } + } + + // 调用 k8s 层的 UpdateAppInKubernetes 函数来恢复应用 + if err := m.k8s.UpdateAppInKubernetes(&resumedApp, kubeconfig, kubeconfigContext); err != nil { + return &AppStoreError{ + Code: "KUBERNETES_RESUME_ERROR", + Message: "恢复应用失败", + Details: err.Error(), + } + } + return nil + } + + // 如果应用不支持 Kubernetes 部署,返回错误 + return &AppStoreError{ + Code: "UNSUPPORTED_DEPLOYMENT_TYPE", + Message: fmt.Sprintf("应用部署类型 '%s' 不支持恢复功能,目前仅支持 Kubernetes 部署的应用", instance.DeployType), } } diff --git a/templates/user/settings/appstore.tmpl b/templates/user/settings/appstore.tmpl index 64c0fbc00d..b411746217 100644 --- a/templates/user/settings/appstore.tmpl +++ b/templates/user/settings/appstore.tmpl @@ -128,6 +128,25 @@ font-size: 1em !important; } } + +/* 修复应用详情页面中网站和仓库按钮的文字对齐问题 */ +#app-details-modal .ui.buttons .ui.button { + display: flex !important; + align-items: center !important; + justify-content: center !important; + height: 2.5em !important; + line-height: 1 !important; +} + +#app-details-modal .ui.buttons .ui.button i.icon { + margin-right: 0.5em !important; + line-height: 1 !important; +} + +#app-details-modal .ui.buttons .ui.button:not(.icon) { + padding-left: 1em !important; + padding-right: 1em !important; +}
@@ -331,7 +350,7 @@
{{ctx.Locale.Tr "cancel"}}
更新应用
-
暂停应用
+
暂停应用
卸载应用
@@ -1046,8 +1065,8 @@ async function checkAppInstallStatus(app) { const replicas = data.replicas ?? data.status?.replicas ?? 0; const readyReplicas = data.readyReplicas ?? data.status?.readyReplicas ?? 0; - // 判断是否已安装且就绪 - const isInstalled = phase !== 'Not Installed' && phase !== 'Unknown' && (replicas === 0 || readyReplicas > 0); + // 判断是否已安装(包括暂停状态) + const isInstalled = phase !== 'Not Installed' && phase !== 'Unknown'; if (isInstalled) { installBtn.className = 'ui green compact button disabled'; @@ -1087,41 +1106,54 @@ function uninstallCurrentAppFromDetails() { uninstallForm.submit(); } -// 暂停应用(从详情弹窗触发) -function stopCurrentAppFromDetails() { +// 暂停/恢复应用(从详情弹窗触发) +function togglePauseResumeFromDetails() { if (!currentApp) return; const appId = currentApp.id || currentApp.app_id; + // 根据当前状态决定操作 + const isRunning = currentAppStatus && currentAppStatus.replicas > 0; + const action = isRunning ? 'stop' : 'resume'; + const actionText = isRunning ? '暂停' : '恢复'; + const confirmText = isRunning ? + '确定要暂停应用吗?暂停后应用将停止运行,但数据会保留。' : + '确定要恢复应用吗?应用将重新开始运行。'; + // 显示确认对话框 - if (!confirm('确定要暂停应用吗?暂停后应用将停止运行,但数据会保留。')) { + if (!confirm(confirmText)) { return; } - // 创建暂停应用的请求 - const stopForm = document.createElement('form'); - stopForm.method = 'POST'; - stopForm.action = `/user/settings/appstore/stop/${appId}`; + // 创建暂停/恢复应用的请求 + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `/user/settings/appstore/${action}/${appId}`; const csrfInput = document.createElement('input'); csrfInput.type = 'hidden'; csrfInput.name = '_csrf'; csrfInput.value = document.querySelector('meta[name="_csrf"]')?.content || ''; - stopForm.appendChild(csrfInput); + form.appendChild(csrfInput); const appIdInput = document.createElement('input'); appIdInput.type = 'hidden'; appIdInput.name = 'app_id'; appIdInput.value = appId; - stopForm.appendChild(appIdInput); + form.appendChild(appIdInput); const redirectInput = document.createElement('input'); redirectInput.type = 'hidden'; redirectInput.name = 'redirect_to'; redirectInput.value = window.location.pathname; - stopForm.appendChild(redirectInput); + form.appendChild(redirectInput); - document.body.appendChild(stopForm); - stopForm.submit(); + document.body.appendChild(form); + form.submit(); +} + +// 暂停应用(从详情弹窗触发)- 保留向后兼容 +function stopCurrentAppFromDetails() { + togglePauseResumeFromDetails(); } // 加载并渲染运行状态(基于 K8s Application CRD) @@ -1169,13 +1201,13 @@ async function loadAndRenderAppStatus(app) { // 根据应用状态动态控制操作按钮的显示 function updateActionButtonsVisibility() { const updateBtn = document.querySelector('#app-details-modal .ui.primary.button[onclick="updateInstalledAppFromDetails()"]'); - const stopBtn = document.querySelector('#app-details-modal .ui.orange.button[onclick="stopCurrentAppFromDetails()"]'); + const pauseResumeBtn = document.getElementById('pause-resume-btn'); const uninstallBtn = document.querySelector('#app-details-modal .ui.red.button[onclick="uninstallCurrentAppFromDetails()"]'); if (!currentAppStatus) { // 状态未知,隐藏所有操作按钮 if (updateBtn) updateBtn.style.display = 'none'; - if (stopBtn) stopBtn.style.display = 'none'; + if (pauseResumeBtn) pauseResumeBtn.style.display = 'none'; if (uninstallBtn) uninstallBtn.style.display = 'none'; return; } @@ -1189,9 +1221,21 @@ function updateActionButtonsVisibility() { updateBtn.style.display = isInstalled ? 'inline-block' : 'none'; } - // 暂停按钮:只有正在运行的应用才能暂停 - if (stopBtn) { - stopBtn.style.display = isRunning ? 'inline-block' : 'none'; + // 暂停/恢复按钮:已安装的应用可以暂停或恢复 + if (pauseResumeBtn) { + if (isInstalled) { + pauseResumeBtn.style.display = 'inline-block'; + // 根据应用状态更新按钮文本和样式 + if (isRunning) { + pauseResumeBtn.textContent = '暂停应用'; + pauseResumeBtn.className = 'ui orange button'; + } else if (isStopped) { + pauseResumeBtn.textContent = '恢复应用'; + pauseResumeBtn.className = 'ui green button'; + } + } else { + pauseResumeBtn.style.display = 'none'; + } } // 卸载按钮:只有已安装的应用才能卸载 @@ -1295,49 +1339,9 @@ async function installApp() { installBtn.disabled = true; } - // 异步提交,成功后更新按钮状态 - try { - const formData = new FormData(installForm); - const response = await fetch(installForm.action, { - method: 'POST', - body: formData - }); - - if (response.ok) { - // 安装成功,更新按钮状态 - const appId = currentApp.id || currentApp.app_id; - const cardInstallBtn = document.getElementById(`install-btn-${appId}`); - if (cardInstallBtn) { - cardInstallBtn.className = 'ui green compact button disabled'; - cardInstallBtn.innerHTML = '已安装'; - cardInstallBtn.onclick = null; - } - - closeInstallModal(); - // 显示成功消息 - alert('应用安装成功!'); - } else { - // 安装失败,恢复按钮状态 - if (installBtn) { - installBtn.className = 'ui primary button'; - installBtn.textContent = '安装应用'; - installBtn.disabled = false; - } - const errorData = await response.json(); - alert('安装失败: ' + (errorData.error || '未知错误')); - } - } catch (error) { - // 网络错误,恢复按钮状态 - if (installBtn) { - installBtn.className = 'ui primary button'; - installBtn.textContent = '安装应用'; - installBtn.disabled = false; - } - alert('安装失败: ' + error.message); - } - - // 清理表单 - document.body.removeChild(installForm); + // 与卸载一致:使用表单直接提交,后端重定向以显示绿色提示 + installForm.submit(); + return; } function showAddAppModal() {