From d86772249cc63b4ffb52d202fbce49ecdc6cde69 Mon Sep 17 00:00:00 2001 From: panshuxiao Date: Tue, 2 Sep 2025 11:45:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=95=86=E5=BA=97=E7=9A=84=E8=AF=A6=E6=83=85=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/k8s/application/application.go | 2 + routers/web/user/setting/appstore.go | 190 ++++++- routers/web/web.go | 3 + services/appstore/examples/nginx.json | 6 +- services/appstore/k8s_application_mapper.go | 9 - services/appstore/k8s_manager.go | 76 ++- services/appstore/manager.go | 254 ++++++++-- services/appstore/parser.go | 86 +++- templates/user/settings/appstore.tmpl | 529 ++++++++++++++++++-- 9 files changed, 1053 insertions(+), 102 deletions(-) diff --git a/modules/k8s/application/application.go b/modules/k8s/application/application.go index 5ebff8e684..ac0e67ef77 100644 --- a/modules/k8s/application/application.go +++ b/modules/k8s/application/application.go @@ -212,6 +212,8 @@ func UpdateApplication(ctx context.Context, client dynamic_client.Interface, opt // 更新资源 result, err := client.Resource(applicationGroupVersionResource). Namespace(opts.Namespace).Update(ctx, unstructuredObj, opts.UpdateOptions) + log.Info("Updated Application: %v", result) + log.Info("Error: %v", err) if err != nil { return nil, application_errors.ErrOperateApplication{ Action: "update Application via Dynamic Client", diff --git a/routers/web/user/setting/appstore.go b/routers/web/user/setting/appstore.go index 3dc0844dac..7c9f688456 100644 --- a/routers/web/user/setting/appstore.go +++ b/routers/web/user/setting/appstore.go @@ -52,7 +52,7 @@ func handleGetApps(ctx *context.Context) { category := ctx.FormString("category") tag := ctx.FormString("tag") search := ctx.FormString("search") - deployment := ctx.FormString("deployment") // docker, kubernetes, all + deployment := ctx.FormString("deployment") // docker, kubernetes source := ctx.FormString("source") // local | devstar manager := appstore.NewManager(ctx) @@ -82,7 +82,7 @@ func handleGetApps(ctx *context.Context) { continue } } - if deployment != "" && deployment != "all" { + if deployment != "" { // 处理部署类型过滤,包括 'both' 类型 appDeployment := a.DeploymentType if appDeployment == "" { @@ -139,8 +139,8 @@ func handleGetApps(ctx *context.Context) { } // 处理部署类型过滤,包括 'both' 类型 - matchDeployment := deployment == "" || deployment == "all" - if deployment != "" && deployment != "all" { + matchDeployment := deployment == "" + if deployment != "" { appDeployment := app.DeploymentType if appDeployment == "" { // 如果没有 deployment_type 字段,尝试从其他字段获取 @@ -194,7 +194,7 @@ func handleGetCategories(ctx *context.Context) { func handleGetTags(ctx *context.Context) { tags, err := appstore_model.GetTags(ctx) if err != nil { - ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + ctx.JSON(http.StatusBadRequest, map[string]interface{}{ "error": err.Error(), }) return @@ -205,8 +205,42 @@ func handleGetTags(ctx *context.Context) { }) } +// AppStoreStatus handles app instance status requests +func AppStoreStatus(ctx *context.Context) { + appID := ctx.PathParam("app_id") + if appID == "" { + ctx.JSON(http.StatusBadRequest, map[string]interface{}{ + "error": "应用ID不能为空", + }) + return + } + + manager := appstore.NewManager(ctx) + status, err := manager.GetAppStatus(appID) + if err != nil { + if appErr, ok := err.(*appstore.AppStoreError); ok { + ctx.JSON(http.StatusNotFound, map[string]interface{}{ + "error": appErr.Message, + }) + } else { + ctx.JSON(http.StatusInternalServerError, map[string]interface{}{ + "error": "获取应用状态失败", + }) + } + return + } + + ctx.JSON(http.StatusOK, status) +} + // AppStoreInstall handles app installation func AppStoreInstall(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 @@ -266,8 +300,81 @@ func AppStoreInstall(ctx *context.Context) { } } +// AppStoreUpdate handles app update +func AppStoreUpdate(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") + configJSON := ctx.FormString("config") + 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 + } + + // 权限校验:仅管理员可选择默认位置 + if installTarget == "local" && (ctx.Doer == nil || !ctx.Doer.IsAdmin) { + ctx.JSON(403, map[string]string{"error": "仅管理员可选择默认位置"}) + return + } + + // 创建 manager 并执行更新 + manager := appstore.NewManager(ctx) + if err := manager.UpdateInstalledApp(appID, configJSON, installTarget, kubeconfig, kubeconfigContext); err != nil { + // 根据错误类型返回相应的状态码和消息 + if appErr, ok := err.(*appstore.AppStoreError); ok { + switch appErr.Code { + case "APP_NOT_FOUND", "CONFIG_MERGE_ERROR": + ctx.JSON(400, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "KUBERNETES_UPDATE_ERROR": + ctx.JSON(500, map[string]string{"error": appErr.Message + ": " + appErr.Details}) + case "NOT_IMPLEMENTED": + ctx.JSON(501, 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("应用已成功更新到默认位置") + } + + // 优先根据表单的 redirect_to 回跳(前端会带上当前路径) + if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" { + ctx.RedirectToCurrentSite(redirectTo) + } else { + ctx.Redirect(setting.AppSubURL + "/user/settings/appstore") + } +} + // AppStoreUninstall handles app uninstallation func AppStoreUninstall(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 @@ -321,6 +428,67 @@ func AppStoreUninstall(ctx *context.Context) { } } +// AppStoreStop handles app stopping (setting replicas to 0) +func AppStoreStop(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) + if err := manager.StopApp(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_STOP_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("应用已成功暂停") + } + + // 根据来源页面决定重定向位置 + 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") + } +} + // AppStoreConfigure handles app configuration func AppStoreConfigure(ctx *context.Context) { // TODO: Load app configuration form @@ -332,6 +500,12 @@ func AppStoreConfigure(ctx *context.Context) { // AppStoreConfigurePost handles app configuration form submission func AppStoreConfigurePost(ctx *context.Context) { + // 检查用户登录状态 + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + // TODO: Handle configuration form submission ctx.Flash.Success("App configuration feature coming soon") if redirectTo := ctx.FormString("redirect_to"); redirectTo != "" { @@ -343,6 +517,12 @@ func AppStoreConfigurePost(ctx *context.Context) { // 添加应用API func AddAppAPI(ctx *context.Context) { + // 检查 CSRF token + if !ctx.IsSigned { + ctx.JSON(401, map[string]string{"error": "未登录"}) + return + } + manager := appstore.NewManager(ctx) if ctx.Req.Method != "POST" { ctx.JSON(405, map[string]string{"error": "Method Not Allowed"}) diff --git a/routers/web/web.go b/routers/web/web.go index 11c21416d7..bde9c3f687 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -706,8 +706,11 @@ func registerWebRoutes(m *web.Router) { m.Group("/appstore", func() { m.Get("", user_setting.AppStore) m.Get("/api/{action}", user_setting.AppStoreAPI) + m.Get("/api/status/{app_id}", user_setting.AppStoreStatus) m.Post("/api/add", user_setting.AddAppAPI) 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("/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/examples/nginx.json b/services/appstore/examples/nginx.json index d6158f7ef1..65e1c59a4a 100644 --- a/services/appstore/examples/nginx.json +++ b/services/appstore/examples/nginx.json @@ -1,6 +1,6 @@ { - "id": "nginx", - "name": "Nginx Web Server", + "id": "nginx-9", + "name": "Nginx Web Server-9", "description": "High-performance HTTP server and reverse proxy", "category": "web-server", "tags": ["web", "proxy", "http", "server"], @@ -56,7 +56,7 @@ "arch": "x86_64" }, "deploy": { - "type": "docker", + "type": "", "docker": { "image": "nginx", "tag": "1.24.0", diff --git a/services/appstore/k8s_application_mapper.go b/services/appstore/k8s_application_mapper.go index 8583ef4bf1..d360dbd671 100644 --- a/services/appstore/k8s_application_mapper.go +++ b/services/appstore/k8s_application_mapper.go @@ -139,9 +139,6 @@ func BuildK8sGetOptions(app *App, namespaceOverride string, wait bool) (*applica if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } @@ -160,9 +157,6 @@ func BuildK8sDeleteOptions(app *App, namespaceOverride string) (*application.Del if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } @@ -192,9 +186,6 @@ func BuildK8sUpdateOptions(app *App, namespaceOverride string, existing *applica if app == nil { return nil, fmt.Errorf("nil app") } - if strings.ToLower(strings.TrimSpace(app.Deploy.Type)) != "kubernetes" { - return nil, fmt.Errorf("deploy.type must be 'kubernetes'") - } if app.Deploy.Kubernetes == nil { return nil, fmt.Errorf("deploy.kubernetes is required") } diff --git a/services/appstore/k8s_manager.go b/services/appstore/k8s_manager.go index 9cc6dd2675..8eca751448 100644 --- a/services/appstore/k8s_manager.go +++ b/services/appstore/k8s_manager.go @@ -5,6 +5,7 @@ package appstore import ( "context" + "fmt" k8s "code.gitea.io/gitea/modules/k8s" applicationv1 "code.gitea.io/gitea/modules/k8s/api/application/v1" @@ -27,7 +28,14 @@ func NewK8sManager(ctx context.Context) *K8sManager { // InstallAppToKubernetes installs an application to a Kubernetes cluster func (km *K8sManager) InstallAppToKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -70,7 +78,14 @@ func (km *K8sManager) InstallAppToKubernetes(app *App, kubeconfig []byte, contex // UninstallAppFromKubernetes uninstalls an application from a Kubernetes cluster func (km *K8sManager) UninstallAppFromKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -113,7 +128,14 @@ func (km *K8sManager) UninstallAppFromKubernetes(app *App, kubeconfig []byte, co // GetAppFromKubernetes gets an application from a Kubernetes cluster func (km *K8sManager) GetAppFromKubernetes(app *App, kubeconfig []byte, contextName string) (interface{}, error) { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return nil, &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return nil, &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -147,7 +169,14 @@ func (km *K8sManager) GetAppFromKubernetes(app *App, kubeconfig []byte, contextN // ListAppsFromKubernetes lists applications from a Kubernetes cluster func (km *K8sManager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contextName string) (*applicationv1.ApplicationList, error) { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return nil, &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return nil, &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -181,7 +210,14 @@ func (km *K8sManager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contex // UpdateAppInKubernetes updates an application in a Kubernetes cluster func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, contextName string) error { // Validate that the app supports Kubernetes deployment - if app.Deploy.Type != "kubernetes" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type != "" && app.Deploy.Type != "kubernetes" { + return &AppStoreError{ + Code: "DEPLOYMENT_TYPE_ERROR", + Message: "Application is not configured for Kubernetes deployment", + } + } + if app.Deploy.Type == "" && app.DeploymentType != "kubernetes" && app.DeploymentType != "both" { return &AppStoreError{ Code: "DEPLOYMENT_TYPE_ERROR", Message: "Application does not support Kubernetes deployment", @@ -198,8 +234,29 @@ func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, context } } - // Build Kubernetes update options - updateOptions, err := BuildK8sUpdateOptions(app, "", nil) + // 先获取现有的应用,以保留元数据(特别是 resourceVersion) + existingApp, err := km.GetAppFromKubernetes(app, kubeconfig, contextName) + if err != nil { + // 如果应用不存在,返回错误 + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "应用在 Kubernetes 中不存在,无法更新", + Details: err.Error(), + } + } + + // 确保获取到的是 Application 类型 + appData, ok := existingApp.(*applicationv1.Application) + if !ok { + return &AppStoreError{ + Code: "TYPE_ERROR", + Message: "无法获取现有应用数据", + Details: fmt.Sprintf("期望 *applicationv1.Application 类型,实际类型: %T", existingApp), + } + } + + // Build Kubernetes update options,传入现有应用以保留元数据 + updateOptions, err := BuildK8sUpdateOptions(app, "", appData) if err != nil { return &AppStoreError{ Code: "KUBERNETES_OPTIONS_ERROR", @@ -220,3 +277,8 @@ func (km *K8sManager) UpdateAppInKubernetes(app *App, kubeconfig []byte, context return nil } + +// IsApplicationReady 封装底层就绪判断,避免上层直接依赖 k8s/application 的实现 +func (km *K8sManager) IsApplicationReady(status *applicationv1.ApplicationStatus) bool { + return application.IsApplicationStatusReady(status) +} diff --git a/services/appstore/manager.go b/services/appstore/manager.go index f1d9e295dd..e57916c776 100644 --- a/services/appstore/manager.go +++ b/services/appstore/manager.go @@ -6,6 +6,7 @@ package appstore import ( "context" "encoding/json" + "fmt" "net/http" "strings" "time" @@ -252,34 +253,34 @@ func (m *Manager) GetTags() ([]string, error) { // PrepareInstallation prepares an application for installation by merging user config // Returns a complete App structure with merged configuration -func (m *Manager) PrepareInstallation(appID string, userConfig UserConfig) (*App, error) { - // Load the application - app, err := m.GetApp(appID) - if err != nil { - return nil, err - } +// func (m *Manager) PrepareInstallation(appID string, userConfig UserConfig) (*App, error) { +// // Load the application +// app, err := m.GetApp(appID) +// if err != nil { +// return nil, err +// } - // Set the app ID and version in user config if not provided - if userConfig.AppID == "" { - userConfig.AppID = appID - } - if userConfig.Version == "" { - userConfig.Version = app.Version - } +// // Set the app ID and version in user config if not provided +// if userConfig.AppID == "" { +// userConfig.AppID = appID +// } +// if userConfig.Version == "" { +// userConfig.Version = app.Version +// } - // Merge user configuration with app's default configuration - mergedApp, err := m.parser.MergeUserConfig(app, userConfig) - if err != nil { - return nil, err - } +// // Merge user configuration with app's default configuration +// mergedApp, err := m.parser.MergeUserConfig(app, userConfig) +// if err != nil { +// return nil, err +// } - return mergedApp, nil -} +// return mergedApp, nil +// } // AddApp adds a new application to the database func (m *Manager) AddApp(app *App) error { // Validate the app - if err := m.parser.validateApp(app); err != nil { + if err := m.parser.validateAppTemplate(app); err != nil { return err } @@ -303,7 +304,7 @@ func (m *Manager) AddApp(app *App) error { // AddAppFromJSON 通过 parser 解析原始 JSON 并添加应用 func (m *Manager) AddAppFromJSON(jsonBytes []byte) error { - app, err := m.parser.ParseApp(jsonBytes) + app, err := m.parser.ParseAppTemplate(jsonBytes) if err != nil { return err } @@ -469,6 +470,67 @@ func (m *Manager) InstallApp(appID string, configJSON string, installTarget stri 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 { + // 获取应用信息 + app, err := m.GetApp(appID) + if err != nil { + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "获取应用失败", + Details: err.Error(), + } + } + + // 使用 parser 解析和合并用户配置 + mergedApp, err := m.parser.ParseAndMergeUserConfig(app, configJSON) + if err != nil { + return &AppStoreError{ + Code: "CONFIG_MERGE_ERROR", + Message: "配置合并失败", + Details: err.Error(), + } + } + + // 根据安装目标和应用实际部署类型决定更新方式 + if installTarget == "kubeconfig" && kubeconfig != "" { + // 更新外部 Kubernetes 集群中的应用实例 + switch mergedApp.Deploy.Type { + case "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": + 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" { + if err := m.UpdateAppInKubernetes(mergedApp, nil, ""); err != nil { + return &AppStoreError{Code: "KUBERNETES_UPDATE_ERROR", Message: "本地 Kubernetes 更新失败", Details: err.Error()} + } + return nil + } + return &AppStoreError{Code: "NOT_IMPLEMENTED", Message: "本地更新功能开发中"} + } +} + // UninstallApp uninstalls an application based on the provided parameters func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig string, kubeconfigContext string) error { // 获取应用信息 @@ -492,10 +554,9 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st } } } else { - // 根据应用实际部署类型决定本地卸载方式 - switch app.Deploy.Type { - case "kubernetes": - // 应用部署在 Kubernetes,从本地 K8s 集群卸载 + // 优先检查应用实际部署类型 + if app.Deploy.Type == "kubernetes" { + // 应用实际部署在 Kubernetes,从本地 K8s 集群卸载 if err := m.UninstallAppFromKubernetes(app, nil, ""); err != nil { return &AppStoreError{ Code: "KUBERNETES_UNINSTALL_ERROR", @@ -503,21 +564,40 @@ func (m *Manager) UninstallApp(appID string, installTarget string, kubeconfig st Details: err.Error(), } } - case "docker": - // 应用部署在 Docker,从本地 Docker 卸载 + return nil + } else if app.Deploy.Type == "docker" { + // 应用实际部署在 Docker,从本地 Docker 卸载 // TODO: 实现 Docker 卸载逻辑 return &AppStoreError{ Code: "NOT_IMPLEMENTED", Message: "本地 Docker 卸载功能开发中", } - default: - // 未知部署类型,默认尝试 Docker - // TODO: 实现 Docker 卸载逻辑 - return &AppStoreError{ - Code: "NOT_IMPLEMENTED", - Message: "本地卸载功能开发中", + } 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: "未知的部署类型,无法确定卸载方式", + } } return nil @@ -547,3 +627,109 @@ func (m *Manager) ListAppsFromKubernetes(app *App, kubeconfig []byte, contextNam func (m *Manager) UpdateAppInKubernetes(app *App, kubeconfig []byte, contextName string) error { return m.k8s.UpdateAppInKubernetes(app, kubeconfig, contextName) } + +// IsInstalledAppReady 提供给上层的就绪判断入口,避免直接依赖 k8s 层实现 +func (m *Manager) IsInstalledAppReady(status *applicationv1.ApplicationStatus) bool { + return m.k8s.IsApplicationReady(status) +} + +// GetAppStatus 获取应用的安装和运行状态 +func (m *Manager) GetAppStatus(appID string) (map[string]interface{}, error) { + app, err := m.GetApp(appID) + if err != nil { + return nil, &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "应用不存在", + Details: err.Error(), + } + } + + // 检查应用是否已安装 + // 优先检查实际部署类型,如果为空则检查支持的部署类型 + if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + // 尝试从 Kubernetes 获取状态 + k8sApp, err := m.GetAppFromKubernetes(app, nil, "") + if err != nil { + // 应用未安装或获取状态失败 + return map[string]interface{}{ + "phase": "Not Installed", + "replicas": 0, + "readyReplicas": 0, + "ready": false, + }, nil + } + + // 应用已安装,返回状态信息 + if k8sApp != nil { + // 尝试解析 K8s Application 状态 + if appData, ok := k8sApp.(*applicationv1.Application); ok { + // 使用 Application CRD 的实际状态 + phase := string(appData.Status.Phase) + replicas := int(appData.Status.Replicas) + readyReplicas := int(appData.Status.ReadyReplicas) + + // 判断是否就绪 + ready := m.IsInstalledAppReady(&appData.Status) + + return map[string]interface{}{ + "phase": phase, + "replicas": replicas, + "readyReplicas": readyReplicas, + "ready": ready, + }, nil + } + // 如果无法解析,返回错误 + return nil, &AppStoreError{ + Code: "STATUS_PARSE_ERROR", + Message: "无法解析应用状态数据", + Details: fmt.Sprintf("期望 *applicationv1.Application 类型,实际类型: %T", k8sApp), + } + } + } + + // 默认返回未安装状态 + return map[string]interface{}{ + "phase": "Not Installed", + "replicas": 0, + "readyReplicas": 0, + "ready": false, + }, nil +} + +// 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) + if err != nil { + return &AppStoreError{ + Code: "APP_NOT_FOUND", + Message: "获取应用失败", + Details: err.Error(), + } + } + + // 检查应用是否支持 Kubernetes 部署 + if app.Deploy.Type == "kubernetes" || (app.Deploy.Type == "" && (app.DeploymentType == "kubernetes" || app.DeploymentType == "both")) { + // 创建应用的副本并设置副本数为 0 + stoppedApp := *app + if stoppedApp.Deploy.Kubernetes != nil { + stoppedApp.Deploy.Kubernetes.Replicas = 0 + } + + // 调用 k8s 层的 UpdateAppInKubernetes 函数来停止应用 + if err := m.k8s.UpdateAppInKubernetes(&stoppedApp, kubeconfig, kubeconfigContext); err != nil { + return &AppStoreError{ + Code: "KUBERNETES_STOP_ERROR", + Message: "停止应用失败", + Details: err.Error(), + } + } + return nil + } + + // 如果应用不支持 Kubernetes 部署,返回错误 + return &AppStoreError{ + Code: "UNSUPPORTED_DEPLOYMENT_TYPE", + Message: "应用不支持 Kubernetes 部署,无法停止", + } +} diff --git a/services/appstore/parser.go b/services/appstore/parser.go index 65146a6049..d72911d24d 100644 --- a/services/appstore/parser.go +++ b/services/appstore/parser.go @@ -77,8 +77,24 @@ func (p *Parser) MergeUserConfig(app *App, userConfig UserConfig) (*App, error) return &mergedApp, nil } -// ParseApp 从原始 JSON 字节流解析 App -func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { +// // ParseApp 从原始 JSON 字节流解析 App +// func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { +// var app App +// if err := json.Unmarshal(jsonBytes, &app); err != nil { +// return nil, &AppStoreError{ +// Code: "INVALID_JSON", +// Message: "应用JSON格式错误", +// Details: err.Error(), +// } +// } +// if err := p.validateApp(&app); err != nil { +// return nil, err +// } +// return &app, nil +// } + +// ParseAppTemplate parses JSON bytes into an App struct for app templates (deploy.type is optional) +func (p *Parser) ParseAppTemplate(jsonBytes []byte) (*App, error) { var app App if err := json.Unmarshal(jsonBytes, &app); err != nil { return nil, &AppStoreError{ @@ -87,7 +103,7 @@ func (p *Parser) ParseApp(jsonBytes []byte) (*App, error) { Details: err.Error(), } } - if err := p.validateApp(&app); err != nil { + if err := p.validateAppTemplate(&app); err != nil { return nil, err } return &app, nil @@ -98,7 +114,7 @@ func (p *Parser) ParseAndMergeApp(jsonBytes []byte, baseApp *App) (*App, error) if baseApp == nil { return nil, &AppStoreError{Code: "INTERNAL", Message: "baseApp is nil"} } - parsed, err := p.ParseApp(jsonBytes) + parsed, err := p.ParseAppTemplate(jsonBytes) if err != nil { return nil, err } @@ -284,6 +300,68 @@ func (p *Parser) validateApp(app *App) error { return nil } +// validateAppTemplate validates application template (deploy.type is optional) +func (p *Parser) validateAppTemplate(app *App) error { + if app.ID == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: "App ID is required", + } + } + + if app.Name == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App name is required for app: %s", app.ID), + } + } + + if app.Category == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App category is required for app: %s", app.ID), + } + } + + if app.Version == "" { + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("App version is required for app: %s", app.ID), + } + } + + if err := p.validateVersionFormat(app.Version); err != nil { + return err + } + + if err := p.validateAppConfig(&app.Config); err != nil { + return err + } + + // For app templates, deploy.type is optional, but if provided, it must be valid + if app.Deploy.Type != "" { + if err := p.validateAppDeploy(&app.Deploy); err != nil { + return err + } + } + + // Validate deployment_type value when provided + deploymentType := strings.ToLower(strings.TrimSpace(app.DeploymentType)) + if deploymentType != "" { + switch deploymentType { + case "docker", "kubernetes", "both": + // allowed + default: + return &AppStoreError{ + Code: "VALIDATION_ERROR", + Message: fmt.Sprintf("Invalid deployment_type: %s (must be docker|kubernetes|both)", app.DeploymentType), + } + } + } + + return nil +} + // validateVersionFormat validates version string format func (p *Parser) validateVersionFormat(version string) error { // Simple version format validation (semantic versioning) diff --git a/templates/user/settings/appstore.tmpl b/templates/user/settings/appstore.tmpl index b52a4263c3..64c0fbc00d 100644 --- a/templates/user/settings/appstore.tmpl +++ b/templates/user/settings/appstore.tmpl @@ -200,18 +200,14 @@ @@ -292,6 +288,11 @@
+

运行状态

+
+ 正在获取状态... +
+

{{ctx.Locale.Tr "appstore.description"}}

@@ -329,7 +330,9 @@
{{ctx.Locale.Tr "cancel"}}
-
{{ctx.Locale.Tr "appstore.install"}}
+
更新应用
+
暂停应用
+
卸载应用
@@ -352,6 +355,7 @@ let allApps = []; let filteredApps = []; let currentApp = null; +let currentAppStatus = null; // 存储当前应用的运行状态 let storeSource = 'local'; // local | devstar // 安装位置:local | kubeconfig let installTarget = 'local'; @@ -503,11 +507,9 @@ function loadApps() { if (filteredApps.length === 0) { grid.innerHTML = `
-
-
- - {{ctx.Locale.Tr "appstore.no_apps"}} -
+
+ + {{ctx.Locale.Tr "appstore.no_apps"}}
`; @@ -517,6 +519,8 @@ function loadApps() { filteredApps.forEach(app => { const card = createAppCard(app); grid.appendChild(card); + // 异步检查应用安装状态 + checkAppInstallStatus(app); }); } @@ -558,6 +562,12 @@ function createAppCard(app) { deploymentLabel = 'Docker'; } + // 初始显示安装按钮,后续异步检查状态 + const actionButton = ``; + column.innerHTML = `
@@ -588,10 +598,7 @@ function createAppCard(app) { 详情 - + ${actionButton}
@@ -630,9 +637,7 @@ function filterApps() { // Handle deployment type matching, including 'both' type let deploymentMatch = false; - if (selectedDeployment === 'all') { - deploymentMatch = true; - } else if (appDeployment === 'both') { + if (appDeployment === 'both') { // 'both' type matches both 'docker' and 'kubernetes' filters deploymentMatch = true; } else { @@ -705,6 +710,12 @@ function showAppDetails(appId) { if (modal) { modal.style.display = 'block'; } + + // 初始化按钮显示状态(在状态加载完成前先隐藏所有操作按钮) + updateActionButtonsVisibility(); + + // 拉取并展示运行状态 + loadAndRenderAppStatus(app); } function showInstallModal(appId) { @@ -820,7 +831,376 @@ function showInstallModalFromDetails() { } } -function installApp() { +// 更新应用(从详情弹窗触发) +async function updateInstalledAppFromDetails() { + if (!currentApp) return; + + // 关闭详情弹窗,打开更新弹窗(类似安装) + closeDetailsModal(); + showUpdateModal(currentApp.id || currentApp.app_id); +} + +// 显示更新弹窗 +function showUpdateModal(appId) { + const app = allApps.find(a => a.id === appId || a.app_id === appId); + if (!app) return; + + currentApp = app; + document.getElementById('install-app-name').textContent = app.name + ' - 更新'; + + // 生成配置表单(重用安装的逻辑) + const form = document.getElementById('install-form'); + const dyn = document.getElementById('install-dynamic-fields'); + dyn.innerHTML = ''; + + // 配置表单生成逻辑(与 showInstallModal 相同) + let configSchema = null; + let configDefaults = null; + if (app.config && app.config.schema) { + configSchema = app.config.schema; + configDefaults = app.config.default || {}; + } else if (app.Config && app.Config.Schema) { + configSchema = app.Config.Schema; + configDefaults = app.Config.Default || {}; + } + if (!configSchema || typeof configSchema !== 'object') { + dyn.innerHTML = ` +
+ + +
+ `; + } else { + Object.entries(configSchema).forEach(([key, config]) => { + if (!config) return; + let field = document.createElement('div'); + field.className = 'field'; + let inputHtml = ''; + const defaultValue = configDefaults[key] || config.default || ''; + if (config.type === 'int') { + const min = config.min || ''; + const max = config.max || ''; + inputHtml = ``; + } else if (config.type === 'string') { + inputHtml = ``; + } else if (config.type === 'bool' || config.type === 'boolean') { + inputHtml = ` +
+ + +
+ `; + } else if (config.type === 'select' && config.options) { + let optionsHtml = ''; + config.options.forEach(option => { + const selected = option === defaultValue ? 'selected' : ''; + optionsHtml += ``; + }); + inputHtml = ``; + } else { + inputHtml = ``; + } + field.innerHTML = ` + + ${config.description ? `
${config.description}
` : ''} + ${inputHtml} + `; + dyn.appendChild(field); + }); + } + + // 设置安装位置表单 + const radios = document.querySelectorAll('#install-modal input[name="installTargetRadio"]'); + radios.forEach(r => { r.checked = (r.value === installTarget); }); + const kubeconfigFields = document.querySelector('#install-modal #kubeconfig-fields'); + if (kubeconfigFields) { + kubeconfigFields.style.display = (installTarget === 'kubeconfig') ? 'block' : 'none'; + } + const kcContent = document.querySelector('#install-modal #kubeconfig-content'); + const kcContext = document.querySelector('#install-modal #kubeconfig-context'); + if (kcContent) kcContent.value = installKubeconfigContent || ''; + if (kcContext) kcContext.value = installKubeconfigContext || ''; + + // 修改安装按钮文字为"更新" + const installBtn = document.querySelector('#install-modal .ui.primary.button[onclick="installApp()"]'); + if (installBtn) { + installBtn.textContent = '更新应用'; + installBtn.onclick = updateApp; + } + + const modal = document.getElementById('install-modal'); + if (modal) { + modal.style.display = 'block'; + } +} + +// 更新应用(表单提交) +function updateApp() { + if (!currentApp) return; + const form = document.getElementById('install-form'); + const formData = new FormData(form); + const config = {}; + for (let [key, value] of formData.entries()) { + const input = form.querySelector(`[name="${key}"]`); + if (input && input.type === 'checkbox') { + config[key] = input.checked; + } else if (input && input.type === 'number') { + config[key] = parseInt(value) || 0; + } else { + config[key] = value; + } + } + const appId = currentApp.id || currentApp.app_id; + const currentDeploymentFilter = document.querySelector('.deployment-filter .active'); + const deploymentType = currentDeploymentFilter?.getAttribute('data-deployment'); + + // 从安装窗口读取安装位置 + const selectedTarget = document.querySelector('#install-modal input[name="installTargetRadio"]:checked'); + installTarget = selectedTarget ? selectedTarget.value : 'local'; + if (installTarget === 'kubeconfig') { + const kcContentEl = document.querySelector('#install-modal #kubeconfig-content'); + const kcContextEl = document.querySelector('#install-modal #kubeconfig-context'); + installKubeconfigContent = kcContentEl ? kcContentEl.value.trim() : ''; + installKubeconfigContext = kcContextEl ? kcContextEl.value.trim() : ''; + if (!installKubeconfigContent) { + alert('请输入 kubeconfig 内容'); + return; + } + config.deploy = { type: 'kubernetes' }; + } else { + if (deploymentType === 'docker') { + config.deploy = { type: 'docker' }; + } else if (deploymentType === 'kubernetes') { + config.deploy = { type: 'kubernetes' }; + } else { + // 回退到 docker 作为默认选择 + config.deploy = { type: 'docker' }; + } + } + + // 创建表单提交到更新路由 + const updateForm = document.createElement('form'); + updateForm.method = 'POST'; + updateForm.action = `/user/settings/appstore/update/${appId}`; + + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = '_csrf'; + csrfInput.value = document.querySelector('meta[name="_csrf"]')?.content || ''; + updateForm.appendChild(csrfInput); + + const appIdInput = document.createElement('input'); + appIdInput.type = 'hidden'; + appIdInput.name = 'app_id'; + appIdInput.value = appId; + updateForm.appendChild(appIdInput); + + const configInput = document.createElement('input'); + configInput.type = 'hidden'; + configInput.name = 'config'; + configInput.value = JSON.stringify(config); + updateForm.appendChild(configInput); + + const targetInput = document.createElement('input'); + targetInput.type = 'hidden'; + targetInput.name = 'install_target'; + targetInput.value = installTarget; + updateForm.appendChild(targetInput); + + const redirectInput = document.createElement('input'); + redirectInput.type = 'hidden'; + redirectInput.name = 'redirect_to'; + redirectInput.value = window.location.pathname; + updateForm.appendChild(redirectInput); + + if (installTarget === 'kubeconfig') { + const kcInput = document.createElement('input'); + kcInput.type = 'hidden'; + kcInput.name = 'kubeconfig'; + kcInput.value = installKubeconfigContent; + updateForm.appendChild(kcInput); + + const kctxInput = document.createElement('input'); + kctxInput.type = 'hidden'; + kctxInput.name = 'kubeconfig_context'; + kctxInput.value = installKubeconfigContext; + updateForm.appendChild(kctxInput); + } + + document.body.appendChild(updateForm); + updateForm.submit(); + closeInstallModal(); +} + +// 检查应用安装状态并更新按钮 +async function checkAppInstallStatus(app) { + const appId = app.id || app.app_id; + const installBtn = document.getElementById(`install-btn-${appId}`); + if (!installBtn) return; + + try { + const resp = await fetch(`/user/settings/appstore/api/status/${appId}`); + if (resp.ok) { + const data = await resp.json(); + const phase = data.phase || data.status?.phase || 'Unknown'; + 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); + + if (isInstalled) { + installBtn.className = 'ui green compact button disabled'; + installBtn.innerHTML = '已安装'; + installBtn.onclick = null; + } + } + } catch (e) { + // 状态检查失败,保持安装按钮不变 + console.log('Failed to check app status:', e); + } +} + +// 卸载应用(从详情弹窗触发) +function uninstallCurrentAppFromDetails() { + if (!currentApp) return; + const appId = currentApp.id || currentApp.app_id; + const uninstallForm = document.createElement('form'); + uninstallForm.method = 'POST'; + uninstallForm.action = `/user/settings/appstore/uninstall/${appId}`; + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = '_csrf'; + csrfInput.value = document.querySelector('meta[name="_csrf"]')?.content || ''; + uninstallForm.appendChild(csrfInput); + const appIdInput = document.createElement('input'); + appIdInput.type = 'hidden'; + appIdInput.name = 'app_id'; + appIdInput.value = appId; + uninstallForm.appendChild(appIdInput); + const redirectInput = document.createElement('input'); + redirectInput.type = 'hidden'; + redirectInput.name = 'redirect_to'; + redirectInput.value = window.location.pathname; + uninstallForm.appendChild(redirectInput); + document.body.appendChild(uninstallForm); + uninstallForm.submit(); +} + +// 暂停应用(从详情弹窗触发) +function stopCurrentAppFromDetails() { + if (!currentApp) return; + const appId = currentApp.id || currentApp.app_id; + + // 显示确认对话框 + if (!confirm('确定要暂停应用吗?暂停后应用将停止运行,但数据会保留。')) { + return; + } + + // 创建暂停应用的请求 + const stopForm = document.createElement('form'); + stopForm.method = 'POST'; + stopForm.action = `/user/settings/appstore/stop/${appId}`; + + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = '_csrf'; + csrfInput.value = document.querySelector('meta[name="_csrf"]')?.content || ''; + stopForm.appendChild(csrfInput); + + const appIdInput = document.createElement('input'); + appIdInput.type = 'hidden'; + appIdInput.name = 'app_id'; + appIdInput.value = appId; + stopForm.appendChild(appIdInput); + + const redirectInput = document.createElement('input'); + redirectInput.type = 'hidden'; + redirectInput.name = 'redirect_to'; + redirectInput.value = window.location.pathname; + stopForm.appendChild(redirectInput); + + document.body.appendChild(stopForm); + stopForm.submit(); +} + +// 加载并渲染运行状态(基于 K8s Application CRD) +async function loadAndRenderAppStatus(app) { + const statusEl = document.getElementById('details-status'); + if (!statusEl || !app) return; + statusEl.innerHTML = '正在获取状态...'; + try { + const appId = app.id || app.app_id; + const resp = await fetch(`/user/settings/appstore/api/status/${appId}`); + if (!resp.ok) throw new Error(`status: ${resp.status}`); + const data = await resp.json(); + // 期望后端返回 { phase, replicas, readyReplicas, ready } 或类似结构 + const phase = data.phase || data.status?.phase || 'Unknown'; + const replicas = data.replicas ?? data.status?.replicas ?? '-'; + const readyReplicas = data.readyReplicas ?? data.status?.readyReplicas ?? '-'; + const ready = (typeof data.ready === 'boolean') + ? data.ready + : (phase !== 'Not Installed' && phase !== 'Unknown' && (replicas === 0 || readyReplicas > 0)); + + // 存储应用状态到全局变量 + currentAppStatus = { + phase: phase, + replicas: replicas, + readyReplicas: readyReplicas, + ready: ready + }; + + statusEl.innerHTML = ` +
+
${ready ? 'Ready' : 'Not Ready'}
+
${phase} · ${readyReplicas}/${replicas}
+
+ `; + + // 根据状态动态控制按钮显示 + updateActionButtonsVisibility(); + } catch (e) { + statusEl.innerHTML = '获取状态失败'; + currentAppStatus = null; + updateActionButtonsVisibility(); + } +} + +// 根据应用状态动态控制操作按钮的显示 +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 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 (uninstallBtn) uninstallBtn.style.display = 'none'; + return; + } + + const isInstalled = currentAppStatus.phase !== 'Not Installed' && currentAppStatus.phase !== 'Unknown'; + const isRunning = isInstalled && currentAppStatus.replicas > 0; + const isStopped = isInstalled && currentAppStatus.replicas === 0; + + // 更新按钮:只有已安装的应用才能更新 + if (updateBtn) { + updateBtn.style.display = isInstalled ? 'inline-block' : 'none'; + } + + // 暂停按钮:只有正在运行的应用才能暂停 + if (stopBtn) { + stopBtn.style.display = isRunning ? 'inline-block' : 'none'; + } + + // 卸载按钮:只有已安装的应用才能卸载 + if (uninstallBtn) { + uninstallBtn.style.display = isInstalled ? 'inline-block' : 'none'; + } +} + +async function installApp() { if (!currentApp) return; const form = document.getElementById('install-form'); const formData = new FormData(form); @@ -857,11 +1237,8 @@ function installApp() { } else if (deploymentType === 'kubernetes') { config.deploy = { type: 'kubernetes' }; } else { - if (currentApp.deployment_type === 'both') { - config.deploy = { type: 'docker' }; - } else { - config.deploy = { type: currentApp.deployment_type || 'docker' }; - } + // 回退到 docker 作为默认选择 + config.deploy = { type: 'docker' }; } } // 避免全局 beforeunload 提示:标记当前表单忽略脏检测 @@ -907,9 +1284,60 @@ function installApp() { kctxInput.value = installKubeconfigContext; installForm.appendChild(kctxInput); } + // 提交安装表单 document.body.appendChild(installForm); - installForm.submit(); - closeInstallModal(); + + // 显示安装中状态 + const installBtn = document.querySelector('#install-modal .ui.primary.button'); + if (installBtn) { + installBtn.className = 'ui loading primary button'; + installBtn.textContent = '安装中...'; + 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); } function showAddAppModal() { @@ -928,27 +1356,48 @@ function closeAddAppModal() { } } async function submitAddApp() { + console.log('submitAddApp called'); // 调试信息 const jsonText = document.getElementById('add-app-json').value.trim(); + console.log('JSON text:', jsonText); // 调试信息 + let appData; try { appData = JSON.parse(jsonText); + console.log('Parsed app data:', appData); // 调试信息 } catch (e) { + console.error('JSON parse error:', e); // 调试信息 alert('JSON格式错误'); return; } - const resp = await fetch('/user/settings/appstore/api/add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(appData) - }); - if (resp.ok) { - closeAddAppModal(); - loadAppsFromAPI(); // 刷新应用列表 - alert('添加成功'); - } else { - const err = await resp.json(); - alert('添加失败: ' + (err.error || '未知错误')); + + // 获取 CSRF token + const csrfToken = document.querySelector('meta[name="_csrf"]')?.content || ''; + console.log('CSRF token:', csrfToken); // 调试信息 + + try { + const resp = await fetch('/user/settings/appstore/api/add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify(appData) + }); + console.log('Response status:', resp.status); // 调试信息 + + if (resp.ok) { + closeAddAppModal(); + loadAppsFromAPI(); // 刷新应用列表 + alert('添加成功'); + } else { + const err = await resp.json(); + console.error('API error:', err); // 调试信息 + alert('添加失败: ' + (err.error || '未知错误')); + } + } catch (error) { + console.error('Fetch error:', error); // 调试信息 + alert('网络错误: ' + error.message); } }