修改了应用商店的详情页面
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 部署,无法停止",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -200,18 +200,14 @@
|
||||
<!-- Deployment Type Filter -->
|
||||
<div class="ui vertical fluid menu deployment-filter" style="margin-top: 1rem;">
|
||||
<div class="header item">{{ctx.Locale.Tr "appstore.deployment_type"}}</div>
|
||||
<a class="item active" data-deployment="all">
|
||||
<i class="grid layout icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.category_all"}}
|
||||
<a class="item active" data-deployment="kubernetes">
|
||||
<i class="kubernetes icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.deployment_kubernetes"}}
|
||||
</a>
|
||||
<a class="item" data-deployment="docker">
|
||||
<i class="docker icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.deployment_docker"}}
|
||||
</a>
|
||||
<a class="item" data-deployment="kubernetes">
|
||||
<i class="kubernetes icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.deployment_kubernetes"}}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,6 +288,11 @@
|
||||
<div class="ui stackable grid">
|
||||
<div class="eight wide column">
|
||||
<div class="ui segment">
|
||||
<h4 class="ui header">运行状态</h4>
|
||||
<div id="details-status">
|
||||
<span class="ui grey text">正在获取状态...</span>
|
||||
</div>
|
||||
<div class="ui divider"></div>
|
||||
<h4 class="ui header">{{ctx.Locale.Tr "appstore.description"}}</h4>
|
||||
<p id="details-description"></p>
|
||||
|
||||
@@ -329,7 +330,9 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui button" onclick="closeDetailsModal()">{{ctx.Locale.Tr "cancel"}}</div>
|
||||
<div class="ui primary button" onclick="showInstallModalFromDetails()">{{ctx.Locale.Tr "appstore.install"}}</div>
|
||||
<div class="ui primary button" onclick="updateInstalledAppFromDetails()">更新应用</div>
|
||||
<div class="ui orange button" onclick="stopCurrentAppFromDetails()">暂停应用</div>
|
||||
<div class="ui red button" onclick="uninstallCurrentAppFromDetails()">卸载应用</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 = `
|
||||
<div class="sixteen wide column">
|
||||
<div class="ui placeholder segment">
|
||||
<div class="ui icon header">
|
||||
<i class="search icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.no_apps"}}
|
||||
</div>
|
||||
<div class="ui icon header">
|
||||
<i class="search icon"></i>
|
||||
{{ctx.Locale.Tr "appstore.no_apps"}}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -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 = '<span class="ui mini green label">Docker</span>';
|
||||
}
|
||||
|
||||
// 初始显示安装按钮,后续异步检查状态
|
||||
const actionButton = `<button class="ui primary compact button" onclick="showInstallModal('${app.id || app.app_id}')" id="install-btn-${app.id || app.app_id}">
|
||||
<i class="download icon"></i>
|
||||
安装
|
||||
</button>`;
|
||||
|
||||
column.innerHTML = `
|
||||
<div class="ui fluid card">
|
||||
<div class="image">
|
||||
@@ -588,10 +598,7 @@ function createAppCard(app) {
|
||||
<i class="info circle icon"></i>
|
||||
详情
|
||||
</button>
|
||||
<button class="ui primary compact button" onclick="showInstallModal('${app.id || app.app_id}')">
|
||||
<i class="download icon"></i>
|
||||
安装
|
||||
</button>
|
||||
${actionButton}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = `
|
||||
<div class="field">
|
||||
<label>port <span style="color: red;">*</span></label>
|
||||
<input type="number" name="port" value="80" required>
|
||||
</div>
|
||||
`;
|
||||
} 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 = `<input type="number" name="${key}" value="${defaultValue}" ${config.required ? 'required' : ''} ${min ? 'min="' + min + '"' : ''} ${max ? 'max="' + max + '"' : ''}>`;
|
||||
} else if (config.type === 'string') {
|
||||
inputHtml = `<input type="text" name="${key}" value="${defaultValue}" ${config.required ? 'required' : ''}">`;
|
||||
} else if (config.type === 'bool' || config.type === 'boolean') {
|
||||
inputHtml = `
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="${key}" ${defaultValue ? 'checked' : ''}>
|
||||
<label></label>
|
||||
</div>
|
||||
`;
|
||||
} else if (config.type === 'select' && config.options) {
|
||||
let optionsHtml = '';
|
||||
config.options.forEach(option => {
|
||||
const selected = option === defaultValue ? 'selected' : '';
|
||||
optionsHtml += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
inputHtml = `<select name="${key}" class="ui dropdown" ${config.required ? 'required' : ''}>${optionsHtml}</select>`;
|
||||
} else {
|
||||
inputHtml = `<input type="text" name="${key}" value="${defaultValue}" ${config.required ? 'required' : ''}">`;
|
||||
}
|
||||
field.innerHTML = `
|
||||
<label>${key} ${config.required ? '<span style="color: red;">*</span>' : ''}</label>
|
||||
${config.description ? `<div class="ui small text">${config.description}</div>` : ''}
|
||||
${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 = '<i class="check icon"></i>已安装';
|
||||
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 = '<span class="ui grey text">正在获取状态...</span>';
|
||||
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 = `
|
||||
<div class="ui small statistic">
|
||||
<div class="value">${ready ? '<span class="ui green text">Ready</span>' : '<span class="ui orange text">Not Ready</span>'}</div>
|
||||
<div class="label">${phase} · ${readyReplicas}/${replicas}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// 根据状态动态控制按钮显示
|
||||
updateActionButtonsVisibility();
|
||||
} catch (e) {
|
||||
statusEl.innerHTML = '<span class="ui red text">获取状态失败</span>';
|
||||
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 = '<i class="check icon"></i>已安装';
|
||||
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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user