feat(api): enhance app secrets management and deployment process
- Add tests for retrieving and redacting per-app secrets in GetSecrets handler. - Implement logic to restart workloads when ConfigMaps are updated during app deployment. - Ensure complete cleanup of app configuration directories upon deletion, including all related files. - Introduce a filter to skip non-Longhorn PVCs during backup operations. - Refactor legacy helper functions in context.go to streamline instance path management. - Update AppDetailPanel component to include action buttons for app lifecycle management and backup operations.
This commit is contained in:
@@ -449,9 +449,7 @@ func (api *API) GetSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
secretsPath := inst.SecretsPath
|
||||
|
||||
secretsData, err := os.ReadFile(secretsPath)
|
||||
secretsData, err := os.ReadFile(inst.SecretsPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{})
|
||||
@@ -466,13 +464,54 @@ func (api *API) GetSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse secrets: %v", err))
|
||||
return
|
||||
}
|
||||
if secretsMap == nil {
|
||||
secretsMap = map[string]any{}
|
||||
}
|
||||
|
||||
// Load per-app secrets and include them under the "apps" key
|
||||
appsConfigDir := tools.GetAppsConfigDirPath(api.dataDir, name)
|
||||
if appEntries, err := os.ReadDir(appsConfigDir); err == nil {
|
||||
appSecrets := map[string]any{}
|
||||
for _, entry := range appEntries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
appName := entry.Name()
|
||||
data, err := os.ReadFile(tools.GetAppSecretsPath(api.dataDir, name, appName))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var appSecretsMap map[string]any
|
||||
if err := yaml.Unmarshal(data, &appSecretsMap); err != nil || len(appSecretsMap) == 0 {
|
||||
continue
|
||||
}
|
||||
appSecrets[appName] = appSecretsMap
|
||||
}
|
||||
if len(appSecrets) > 0 {
|
||||
secretsMap["apps"] = appSecrets
|
||||
}
|
||||
}
|
||||
|
||||
// Check if client wants raw secrets (dangerous!)
|
||||
showRaw := r.URL.Query().Get("raw") == "true"
|
||||
|
||||
if !showRaw {
|
||||
// Redact secrets
|
||||
for key := range secretsMap {
|
||||
// Redact top-level secrets
|
||||
for key, val := range secretsMap {
|
||||
if key == "apps" {
|
||||
// Redact per-app secrets
|
||||
if appsMap, ok := val.(map[string]any); ok {
|
||||
for appName, appData := range appsMap {
|
||||
if appDataMap, ok := appData.(map[string]any); ok {
|
||||
for k := range appDataMap {
|
||||
appDataMap[k] = "********"
|
||||
}
|
||||
appsMap[appName] = appDataMap
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
secretsMap[key] = "********"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,34 +234,25 @@ func (api *API) AppsGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
instanceName := GetInstanceName(r)
|
||||
appName := GetAppName(r)
|
||||
|
||||
inst, err := api.instance.GetInstance(instanceName)
|
||||
if err != nil {
|
||||
if err := api.instance.ValidateInstance(instanceName); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Instance not found")
|
||||
return
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(inst.ConfigPath)
|
||||
configPath := tools.GetAppConfigPath(api.dataDir, instanceName, appName)
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read instance config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
var instanceConfig map[string]any
|
||||
if err := yaml.Unmarshal(configData, &instanceConfig); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse instance config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Extract app config
|
||||
var appConfig map[string]any
|
||||
if appsSection, ok := instanceConfig["apps"].(map[string]any); ok {
|
||||
if appConfigData, ok := appsSection[appName].(map[string]any); ok {
|
||||
appConfig = appConfigData
|
||||
if os.IsNotExist(err) {
|
||||
respondError(w, http.StatusNotFound, fmt.Sprintf("Config not found for app: %s", appName))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read app config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if appConfig == nil {
|
||||
respondError(w, http.StatusNotFound, fmt.Sprintf("Config not found for app: %s", appName))
|
||||
var appConfig map[string]any
|
||||
if err := yaml.Unmarshal(configData, &appConfig); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse app config: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/storage"
|
||||
"github.com/wild-cloud/wild-central/daemon/internal/tools"
|
||||
)
|
||||
|
||||
func setupTestAPI(t *testing.T) (*API, string) {
|
||||
@@ -654,3 +656,96 @@ func TestUpdateYAMLFile_PreservesComplexTypes(t *testing.T) {
|
||||
t.Errorf("Null value not preserved: %v", result["nullValue"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecrets_IncludesPerAppSecrets(t *testing.T) {
|
||||
api, tmpDir := setupTestAPI(t)
|
||||
instanceName := "test-instance"
|
||||
createTestInstance(t, api, instanceName)
|
||||
|
||||
// Write a per-app secrets file
|
||||
appSecretsPath := tools.GetAppSecretsPath(tmpDir, instanceName, "vllm")
|
||||
if err := os.MkdirAll(filepath.Dir(appSecretsPath), 0755); err != nil {
|
||||
t.Fatalf("Failed to create app secrets dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(appSecretsPath, []byte("apiKey: mykey123\n"), 0600); err != nil {
|
||||
t.Fatalf("Failed to write app secrets: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/instances/"+instanceName+"/secrets?raw=true", nil)
|
||||
w := httptest.NewRecorder()
|
||||
req = mux.SetURLVars(req, map[string]string{"name": instanceName})
|
||||
|
||||
api.GetSecrets(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
appsRaw, ok := result["apps"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'apps' key in response")
|
||||
}
|
||||
appsMap, ok := appsRaw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected 'apps' to be a map, got %T", appsRaw)
|
||||
}
|
||||
vllmRaw, ok := appsMap["vllm"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'vllm' in apps secrets")
|
||||
}
|
||||
vllmMap, ok := vllmRaw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("Expected vllm secrets to be a map, got %T", vllmRaw)
|
||||
}
|
||||
if vllmMap["apiKey"] != "mykey123" {
|
||||
t.Errorf("Expected apiKey='mykey123', got %v", vllmMap["apiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSecrets_RedactsPerAppSecrets(t *testing.T) {
|
||||
api, tmpDir := setupTestAPI(t)
|
||||
instanceName := "test-instance"
|
||||
createTestInstance(t, api, instanceName)
|
||||
|
||||
appSecretsPath := tools.GetAppSecretsPath(tmpDir, instanceName, "vllm")
|
||||
if err := os.MkdirAll(filepath.Dir(appSecretsPath), 0755); err != nil {
|
||||
t.Fatalf("Failed to create app secrets dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(appSecretsPath, []byte("apiKey: mykey123\n"), 0600); err != nil {
|
||||
t.Fatalf("Failed to write app secrets: %v", err)
|
||||
}
|
||||
|
||||
// Request without ?raw=true — should be redacted
|
||||
req := httptest.NewRequest("GET", "/api/v1/instances/"+instanceName+"/secrets", nil)
|
||||
w := httptest.NewRecorder()
|
||||
req = mux.SetURLVars(req, map[string]string{"name": instanceName})
|
||||
|
||||
api.GetSecrets(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("Failed to parse response: %v", err)
|
||||
}
|
||||
|
||||
appsRaw, ok := result["apps"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'apps' key in response")
|
||||
}
|
||||
appsMap := appsRaw.(map[string]any)
|
||||
vllmMap := appsMap["vllm"].(map[string]any)
|
||||
if vllmMap["apiKey"] == "mykey123" {
|
||||
t.Error("apiKey should be redacted but got raw value")
|
||||
}
|
||||
if vllmMap["apiKey"] != "********" {
|
||||
t.Errorf("Expected redacted value '********', got %v", vllmMap["apiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1015,6 +1015,12 @@ func (m *Manager) Deploy(inst *instance.Instance, appName string, opID string, b
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy phase %s: %w\nOutput: %s", phase.Path, err, string(output))
|
||||
}
|
||||
if hasConfigMapChanges(output) {
|
||||
slog.Info("ConfigMap updated, restarting workloads", "component", "apps", "namespace", namespace)
|
||||
if err := m.restartNamespaceWorkloads(kubeconfigPath, namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if phase.WaitFor != nil {
|
||||
if err := m.waitForRollout(kubeconfigPath, namespace, phase.WaitFor); err != nil {
|
||||
@@ -1029,6 +1035,12 @@ func (m *Manager) Deploy(inst *instance.Instance, appName string, opID string, b
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deploy app: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
if hasConfigMapChanges(output) {
|
||||
slog.Info("ConfigMap updated, restarting workloads", "component", "apps", "namespace", namespace)
|
||||
if err := m.restartNamespaceWorkloads(kubeconfigPath, namespace); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-deploy: restart deployments
|
||||
@@ -1074,25 +1086,24 @@ func (m *Manager) waitForRollout(kubeconfigPath, namespace string, wait *Rollout
|
||||
// Restart performs a rolling restart of all deployments and statefulsets in an app's namespace
|
||||
func (m *Manager) Restart(inst *instance.Instance, appName string) error {
|
||||
slog.Info("restarting app", "component", "apps", "instance", inst.Name, "app", appName)
|
||||
|
||||
kubeconfigPath := inst.KubeconfigPath()
|
||||
namespace := m.ResolveNamespace(inst, appName)
|
||||
return m.restartNamespaceWorkloads(kubeconfigPath, namespace)
|
||||
}
|
||||
|
||||
// Restart deployments
|
||||
// restartNamespaceWorkloads restarts all deployments and statefulsets in the namespace.
|
||||
func (m *Manager) restartNamespaceWorkloads(kubeconfigPath, namespace string) error {
|
||||
deployCmd := exec.Command("kubectl", "rollout", "restart", "deployment", "-n", namespace)
|
||||
tools.WithKubeconfig(deployCmd, kubeconfigPath)
|
||||
if output, err := deployCmd.CombinedOutput(); err != nil {
|
||||
// Ignore "no resources found" errors - namespace may only have statefulsets
|
||||
if !strings.Contains(string(output), "no resources found") {
|
||||
return fmt.Errorf("failed to restart deployments: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
}
|
||||
|
||||
// Restart statefulsets
|
||||
stsCmd := exec.Command("kubectl", "rollout", "restart", "statefulset", "-n", namespace)
|
||||
tools.WithKubeconfig(stsCmd, kubeconfigPath)
|
||||
if output, err := stsCmd.CombinedOutput(); err != nil {
|
||||
// Ignore "no resources found" errors - namespace may only have deployments
|
||||
if !strings.Contains(string(output), "no resources found") {
|
||||
return fmt.Errorf("failed to restart statefulsets: %w\nOutput: %s", err, string(output))
|
||||
}
|
||||
@@ -1101,6 +1112,19 @@ func (m *Manager) Restart(inst *instance.Instance, appName string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasConfigMapChanges reports whether kubectl apply output indicates any ConfigMaps were updated.
|
||||
// On first deploy all resources are "created", not "configured", so this correctly returns false
|
||||
// and avoids a redundant restart before any pods exist.
|
||||
func hasConfigMapChanges(output []byte) bool {
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "configmap/") && strings.HasSuffix(line, " configured") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// protectedNamespaces that must never be deleted
|
||||
var protectedNamespaces = map[string]bool{
|
||||
"kube-system": true, "kube-public": true,
|
||||
@@ -1121,14 +1145,14 @@ func (m *Manager) namespaceSharedByOtherApp(inst *instance.Instance, appName, na
|
||||
return false
|
||||
}
|
||||
|
||||
// Delete removes an app from the cluster and configuration
|
||||
// Delete removes an app from the cluster and configuration.
|
||||
// Local filesystem cleanup always runs, even if k8s deletion fails.
|
||||
func (m *Manager) Delete(inst *instance.Instance, appName string) error {
|
||||
slog.Info("deleting app", "component", "apps", "instance", inst.Name, "app", appName)
|
||||
|
||||
kubeconfigPath := inst.KubeconfigPath()
|
||||
appDir := m.getAppPackageDir(inst.Name, appName)
|
||||
appConfigFile := m.getAppConfigFile(inst.Name, appName)
|
||||
secretsFile := m.getAppSecretsFile(inst.Name, appName)
|
||||
appConfigDir := tools.GetAppConfigDirPath(m.dataDir, inst.Name, appName)
|
||||
|
||||
if !storage.FileExists(appDir) {
|
||||
return fmt.Errorf("app %s not found in instance", appName)
|
||||
@@ -1136,46 +1160,42 @@ func (m *Manager) Delete(inst *instance.Instance, appName string) error {
|
||||
|
||||
namespace := m.ResolveNamespace(inst, appName)
|
||||
|
||||
// For protected or shared namespaces, only delete this app's resources (not the namespace)
|
||||
// Attempt k8s cleanup; track error but don't return early so local files are always cleaned up.
|
||||
var k8sErr error
|
||||
if protectedNamespaces[namespace] || m.namespaceSharedByOtherApp(inst, appName, namespace) {
|
||||
deleteCmd := exec.Command("kubectl", "delete", "-k", appDir, "--ignore-not-found")
|
||||
tools.WithKubeconfig(deleteCmd, kubeconfigPath)
|
||||
output, err := deleteCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete app resources: %w\nOutput: %s", err, string(output))
|
||||
if output, err := deleteCmd.CombinedOutput(); err != nil {
|
||||
k8sErr = fmt.Errorf("failed to delete app resources: %w\nOutput: %s", err, string(output))
|
||||
slog.Error("k8s resource deletion failed", "component", "apps", "instance", inst.Name, "app", appName, "error", k8sErr)
|
||||
}
|
||||
} else {
|
||||
// App owns this namespace exclusively - delete the whole namespace
|
||||
deleteNsCmd := exec.Command("kubectl", "delete", "namespace", namespace, "--ignore-not-found")
|
||||
tools.WithKubeconfig(deleteNsCmd, kubeconfigPath)
|
||||
output, err := deleteNsCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output))
|
||||
if output, err := deleteNsCmd.CombinedOutput(); err != nil {
|
||||
k8sErr = fmt.Errorf("failed to delete namespace: %w\nOutput: %s", err, string(output))
|
||||
slog.Error("namespace deletion failed", "component", "apps", "instance", inst.Name, "app", appName, "error", k8sErr)
|
||||
} else {
|
||||
// Wait for namespace deletion to complete (timeout after 60s)
|
||||
waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", namespace, "--timeout=60s")
|
||||
tools.WithKubeconfig(waitCmd, kubeconfigPath)
|
||||
_, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist
|
||||
}
|
||||
|
||||
// Wait for namespace deletion to complete (timeout after 60s)
|
||||
waitCmd := exec.Command("kubectl", "wait", "--for=delete", "namespace", namespace, "--timeout=60s")
|
||||
tools.WithKubeconfig(waitCmd, kubeconfigPath)
|
||||
_, _ = waitCmd.CombinedOutput() // Ignore errors - namespace might not exist
|
||||
}
|
||||
|
||||
// Delete local app package directory
|
||||
// Always clean up local files regardless of k8s outcome.
|
||||
if err := os.RemoveAll(appDir); err != nil {
|
||||
return fmt.Errorf("failed to delete local app directory: %w", err)
|
||||
}
|
||||
|
||||
// Remove app config
|
||||
if err := os.Remove(appConfigFile); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove app config: %w", err)
|
||||
}
|
||||
|
||||
// Remove app secrets
|
||||
if err := os.Remove(secretsFile); err != nil && !os.IsNotExist(err) {
|
||||
slog.Warn("failed to remove app secrets", "component", "apps", "app", appName, "error", err)
|
||||
// Remove entire app config directory (config.yaml, secrets.yaml, schedule.yaml, etc.)
|
||||
if err := os.RemoveAll(appConfigDir); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to remove app config directory: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("app deleted", "component", "apps", "instance", inst.Name, "app", appName)
|
||||
return nil
|
||||
return k8sErr
|
||||
}
|
||||
|
||||
// GetStatus returns the status of a deployed app
|
||||
|
||||
@@ -1899,3 +1899,69 @@ func TestEnsureDefaultSecretsSucceedsWithEmptyFile(t *testing.T) {
|
||||
t.Errorf("expected secrets file to contain 'dbPassword', got: %s", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteCleansUpConfigDirectory ensures that Delete removes the entire
|
||||
// config/apps/<app>/ directory, including schedule.yaml. Previously only
|
||||
// config.yaml and secrets.yaml were removed, leaving schedule.yaml orphaned
|
||||
// and causing deleted apps to appear in backup schedule lists.
|
||||
func TestDeleteCleansUpConfigDirectory(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "delete-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dataDir := filepath.Join(tmpDir, "data")
|
||||
appsDir := filepath.Join(tmpDir, "apps")
|
||||
instanceName := "test-instance"
|
||||
appName := "deleteapp"
|
||||
|
||||
instancePath := filepath.Join(dataDir, "instances", instanceName)
|
||||
|
||||
// Create the app data directory (what Delete checks for existence)
|
||||
appDataDir := filepath.Join(instancePath, "data", "apps", appName)
|
||||
if err := storage.EnsureDir(appDataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a minimal manifest so Delete can proceed
|
||||
manifestYAML := "name: deleteapp\ndescription: test\nversion: 1.0.0\n"
|
||||
if err := os.WriteFile(filepath.Join(appDataDir, "manifest.yaml"), []byte(manifestYAML), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create the config directory with config.yaml, secrets.yaml, AND schedule.yaml
|
||||
appConfigDir := filepath.Join(instancePath, "config", "apps", appName)
|
||||
if err := storage.EnsureDir(appConfigDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fname := range []string{"config.yaml", "secrets.yaml", "schedule.yaml"} {
|
||||
if err := os.WriteFile(filepath.Join(appConfigDir, fname), []byte(""), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create instance config and kubeconfig so Delete can resolve the namespace
|
||||
if err := storage.EnsureDir(filepath.Join(instancePath, "config"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(instancePath, "config", "instance.yaml"), []byte("cloud:\n domain: example.com\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
mgr := NewManager(dataDir, appsDir)
|
||||
inst := instance.NewInstance(instanceName, dataDir)
|
||||
|
||||
// Delete will attempt kubectl to remove k8s resources, which will fail in
|
||||
// test. We only care that the local filesystem cleanup happens correctly, so
|
||||
// we verify the config directory is gone regardless of the kubectl error.
|
||||
_ = mgr.Delete(inst, appName)
|
||||
|
||||
// The entire config directory (including schedule.yaml) must be removed
|
||||
if storage.FileExists(appConfigDir) {
|
||||
t.Errorf("config/apps/%s/ should be removed after Delete, but it still exists", appName)
|
||||
}
|
||||
if storage.FileExists(filepath.Join(appConfigDir, "schedule.yaml")) {
|
||||
t.Errorf("schedule.yaml should be removed after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -953,3 +953,49 @@ func TestPodListStatus_PodNotRunning(t *testing.T) {
|
||||
t.Errorf("expected 'unhealthy', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hasConfigMapChanges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestHasConfigMapChanges_Configured(t *testing.T) {
|
||||
output := []byte("namespace/myapp unchanged\nconfigmap/myapp-config configured\ndeployment.apps/myapp unchanged\n")
|
||||
if !hasConfigMapChanges(output) {
|
||||
t.Error("expected true when a ConfigMap is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigMapChanges_Unchanged(t *testing.T) {
|
||||
output := []byte("namespace/myapp unchanged\nconfigmap/myapp-config unchanged\ndeployment.apps/myapp configured\n")
|
||||
if hasConfigMapChanges(output) {
|
||||
t.Error("expected false when ConfigMap is unchanged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigMapChanges_Created(t *testing.T) {
|
||||
// First deploy: all resources are "created", no pods exist yet — no restart needed
|
||||
output := []byte("namespace/myapp created\nconfigmap/myapp-config created\ndeployment.apps/myapp created\n")
|
||||
if hasConfigMapChanges(output) {
|
||||
t.Error("expected false when ConfigMap is created (first deploy)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigMapChanges_NoConfigMap(t *testing.T) {
|
||||
output := []byte("deployment.apps/myapp configured\nservice/myapp unchanged\n")
|
||||
if hasConfigMapChanges(output) {
|
||||
t.Error("expected false when no ConfigMap in output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigMapChanges_Empty(t *testing.T) {
|
||||
if hasConfigMapChanges([]byte{}) {
|
||||
t.Error("expected false for empty output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasConfigMapChanges_MultipleConfigMaps_AnyConfigured(t *testing.T) {
|
||||
output := []byte("configmap/app-config unchanged\nconfigmap/app-env configured\n")
|
||||
if !hasConfigMapChanges(output) {
|
||||
t.Error("expected true when any ConfigMap is configured")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,13 @@ func (l *LonghornNativeStrategy) Backup(plan *btypes.RecoveryPlan, dest btypes.B
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip non-Longhorn PVCs (e.g. NFS provisioner volumes)
|
||||
storageClass := l.getPVCStorageClass(kubeconfigPath, sourceNamespace, pvcName)
|
||||
if !strings.HasPrefix(storageClass, "longhorn") {
|
||||
slog.Info("skipping non-Longhorn PVC", "component", "longhorn", "pvc", pvcName, "storageClass", storageClass)
|
||||
continue
|
||||
}
|
||||
|
||||
volumeName, err := l.getVolumeNameFromPVC(kubeconfigPath, sourceNamespace, pvcName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get volume name for PVC %s: %w", pvcName, err)
|
||||
@@ -520,6 +527,17 @@ func (l *LonghornNativeStrategy) getPVCAccessMode(kubeconfigPath, namespace, pvc
|
||||
return "ReadWriteOnce"
|
||||
}
|
||||
|
||||
func (l *LonghornNativeStrategy) getPVCStorageClass(kubeconfigPath, namespace, pvcName string) string {
|
||||
cmd := exec.Command("kubectl", "get", "pvc", "-n", namespace, pvcName,
|
||||
"-o", "jsonpath={.spec.storageClassName}")
|
||||
tools.WithKubeconfig(cmd, kubeconfigPath)
|
||||
|
||||
if output, err := cmd.Output(); err == nil && len(output) > 0 {
|
||||
return string(output)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (l *LonghornNativeStrategy) getVolumeNameFromPVC(kubeconfigPath, namespace, pvcName string) (string, error) {
|
||||
cmd := exec.Command("kubectl", "get", "pvc", "-n", namespace, pvcName,
|
||||
"-o", "jsonpath={.spec.volumeName}")
|
||||
|
||||
@@ -93,3 +93,28 @@ decidim-data-restore-20260609t151938z nfs://192.168.8.222:/storage/nfs/test-clou
|
||||
assert.Equal(t, "writefreely-data-restore-202606-7b566524", found,
|
||||
"should find the volume matching the backup URL even when Longhorn renamed it")
|
||||
}
|
||||
|
||||
// TestGetPVCStorageClassFilter verifies that the storage class check correctly
|
||||
// distinguishes Longhorn from non-Longhorn storage classes.
|
||||
func TestGetPVCStorageClassFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
storageClass string
|
||||
shouldBackup bool
|
||||
}{
|
||||
{"longhorn", true},
|
||||
{"longhorn-1replica", true},
|
||||
{"longhorn-static", true},
|
||||
{"nfs", false},
|
||||
{"hostpath", false},
|
||||
{"local-path", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.storageClass, func(t *testing.T) {
|
||||
result := strings.HasPrefix(tt.storageClass, "longhorn")
|
||||
assert.Equal(t, tt.shouldBackup, result,
|
||||
"storageClass=%q should backup=%v", tt.storageClass, tt.shouldBackup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,25 +197,11 @@ func GetInstanceDiscoveryPath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "cache")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remaining legacy helpers (unchanged paths, kept for compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GetInstancePath returns the path to an instance directory
|
||||
func GetInstancePath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName)
|
||||
}
|
||||
|
||||
// GetInstanceTalosPath returns the path to an instance's talos directory (legacy layout)
|
||||
func GetInstanceTalosPath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "talos")
|
||||
}
|
||||
|
||||
// GetInstanceAssetsPath returns the path to an instance's assets directory
|
||||
func GetInstanceAssetsPath(dataDir, instanceName string) string {
|
||||
return filepath.Join(dataDir, "instances", instanceName, "assets")
|
||||
}
|
||||
|
||||
// GetInstancesPath returns the path to the instances directory
|
||||
func GetInstancesPath(dataDir string) string {
|
||||
return filepath.Join(dataDir, "instances")
|
||||
|
||||
@@ -345,22 +345,6 @@ func TestGetInstanceDiscoveryPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceTalosPath(t *testing.T) {
|
||||
got := GetInstanceTalosPath("/data", "test-cloud")
|
||||
want := "/data/instances/test-cloud/talos"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInstanceAssetsPath(t *testing.T) {
|
||||
got := GetInstanceAssetsPath("/data", "test-cloud")
|
||||
want := "/data/instances/test-cloud/assets"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsAreUnderInstance(t *testing.T) {
|
||||
dataDir := "/data"
|
||||
instanceName := "test-cloud"
|
||||
@@ -387,8 +371,6 @@ func TestPathsAreUnderInstance(t *testing.T) {
|
||||
"NodeSetupCache": GetNodeSetupCachePath(dataDir, instanceName),
|
||||
"DiscoveryCache": GetDiscoveryCachePath(dataDir, instanceName),
|
||||
"InstanceDiscovery": GetInstanceDiscoveryPath(dataDir, instanceName),
|
||||
"InstanceTalos": GetInstanceTalosPath(dataDir, instanceName),
|
||||
"InstanceAssets": GetInstanceAssetsPath(dataDir, instanceName),
|
||||
}
|
||||
|
||||
for name, p := range paths {
|
||||
|
||||
@@ -297,6 +297,254 @@ export function AppDetailPanel({
|
||||
{getStatusBadge(appDetails.status)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Lifecycle actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appDetails.status === 'added' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configure
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onDeploy(appName)} disabled={isDeploying}>
|
||||
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
|
||||
Deploy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Reconfigure
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onDeploy(appName)} disabled={isDeploying}>
|
||||
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||
Redeploy
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onRestart(appName)} disabled={isRestarting}>
|
||||
{isRestarting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updateAvailable && sourceUpdate?.upgradeBlocked && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-700 dark:text-red-400"
|
||||
disabled
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Upgrade blocked
|
||||
</Button>
|
||||
{sourceUpdate.upgradeNotes && (
|
||||
<span className="text-xs text-muted-foreground max-w-xs truncate" title={sourceUpdate.upgradeNotes}>
|
||||
{sourceUpdate.upgradeNotes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateAvailable && availableVersion && !sourceUpdate?.upgradeBlocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-500 text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/20"
|
||||
onClick={() => onUpdate(appName)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||
Update to {availableVersion}
|
||||
{(sourceUpdate?.upgradeSteps ?? 0) > 1 && ` (${sourceUpdate?.upgradeSteps} steps)`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup status */}
|
||||
{category !== 'services' && appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
|
||||
<div className="flex items-center gap-3 border-l pl-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{appBackupHealth?.lastBackup
|
||||
? `Backed up ${formatRelativeTime(appBackupHealth.lastBackup)}`
|
||||
: 'No backup'}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => createBackup()} disabled={isCreatingBackup}>
|
||||
{isCreatingBackup ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Archive className="h-4 w-4 mr-2" />}
|
||||
Back Up Now
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/instances/${instanceName}/backups/${appName}`)}>
|
||||
View backups
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scripts */}
|
||||
{appDetails.manifest?.scripts && appDetails.manifest.scripts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-l pl-4">
|
||||
{appDetails.manifest.scripts.map((script) => (
|
||||
<Button
|
||||
key={script.name}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={script.description}
|
||||
onClick={() => {
|
||||
if (script.params && script.params.length > 0) {
|
||||
setPendingScript({ name: script.name, params: {} });
|
||||
} else {
|
||||
runScript(script.name);
|
||||
}
|
||||
}}
|
||||
disabled={runningScripts.has(script.name)}
|
||||
>
|
||||
{runningScripts.has(script.name) ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
|
||||
{script.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script param form */}
|
||||
{pendingScript && (() => {
|
||||
const script = appDetails.manifest?.scripts?.find(s => s.name === pendingScript.name);
|
||||
if (!script) return null;
|
||||
return (
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{script.name}
|
||||
</CardTitle>
|
||||
{script.description && <CardDescription>{script.description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{script.params?.map((param) => (
|
||||
<div key={param.name}>
|
||||
<Label htmlFor={`param-${param.name}`} className="text-xs">
|
||||
{param.name}{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
{param.description && <p className="text-xs text-muted-foreground mb-1">{param.description}</p>}
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
className="mt-1"
|
||||
value={pendingScript.params[param.name] ?? ''}
|
||||
onChange={(e) => setPendingScript(prev => prev ? {
|
||||
...prev,
|
||||
params: { ...prev.params, [param.name]: e.target.value }
|
||||
} : null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ps = pendingScript;
|
||||
setPendingScript(null);
|
||||
runScript(ps.name, ps.params);
|
||||
}}
|
||||
disabled={script.params?.some(p => p.required && !pendingScript.params[p.name])}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPendingScript(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="flex gap-2 border-l pl-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(appName)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drift summary */}
|
||||
{drift && (drift.source || drift.compilation || drift.deploy) && (
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
{drift.source?.drifted && (
|
||||
<p className="text-sm">
|
||||
{drift.source.currentVersion && drift.source.availableVersion
|
||||
? `Update available: ${drift.source.currentVersion} \u2192 ${drift.source.availableVersion}`
|
||||
: drift.source.reason}
|
||||
</p>
|
||||
)}
|
||||
{drift.compilation?.drifted && (
|
||||
<p className="text-sm">{drift.compilation.reason}</p>
|
||||
)}
|
||||
{drift.deploy?.drifted && (
|
||||
<p className="text-sm">{drift.deploy.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Script output */}
|
||||
{scriptOutput && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{scriptOutput.name}
|
||||
{scriptOutput.status === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{scriptOutput.status === 'completed' && (
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
)}
|
||||
{scriptOutput.status === 'failed' && (
|
||||
<AlertCircle className="h-3 w-3 text-red-600" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{scriptOutput.status !== 'running' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setScriptOutput(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre
|
||||
ref={scriptOutputRef}
|
||||
className="bg-muted rounded-md p-3 text-xs font-mono overflow-auto max-h-64 whitespace-pre-wrap"
|
||||
>
|
||||
{scriptOutput.lines.length > 0
|
||||
? scriptOutput.lines.join('\n')
|
||||
: scriptOutput.status === 'running'
|
||||
? 'Waiting for output...'
|
||||
: 'No output'}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="overview" value={activeTab} onValueChange={setActiveTab} className="md:flex-1 md:min-h-0">
|
||||
<TabsList className="grid w-full grid-cols-6 mb-6 shrink-0">
|
||||
<TabsTrigger value="overview">
|
||||
@@ -327,256 +575,8 @@ export function AppDetailPanel({
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="flex flex-col gap-6 md:min-h-0">
|
||||
{/* Action Buttons */}
|
||||
<div className="shrink-0 flex flex-wrap items-center gap-4">
|
||||
{/* Lifecycle actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appDetails.status === 'added' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configure
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => onDeploy(appName)} disabled={isDeploying}>
|
||||
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />}
|
||||
Deploy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={() => onConfigure(appName)}>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Reconfigure
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onDeploy(appName)} disabled={isDeploying}>
|
||||
{isDeploying ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||
Redeploy
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onRestart(appName)} disabled={isRestarting}>
|
||||
{isRestarting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{updateAvailable && sourceUpdate?.upgradeBlocked && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500 text-red-700 dark:text-red-400"
|
||||
disabled
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-2" />
|
||||
Upgrade blocked
|
||||
</Button>
|
||||
{sourceUpdate.upgradeNotes && (
|
||||
<span className="text-xs text-muted-foreground max-w-xs truncate" title={sourceUpdate.upgradeNotes}>
|
||||
{sourceUpdate.upgradeNotes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateAvailable && !sourceUpdate?.upgradeBlocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-500 text-amber-700 hover:bg-amber-50 dark:text-amber-400 dark:hover:bg-amber-950/20"
|
||||
onClick={() => onUpdate(appName)}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <RefreshCw className="h-4 w-4 mr-2" />}
|
||||
Update to {availableVersion}
|
||||
{(sourceUpdate?.upgradeSteps ?? 0) > 1 && ` (${sourceUpdate?.upgradeSteps} steps)`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Backup status */}
|
||||
{category !== 'services' && appDetails.status !== 'added' && appDetails.status !== 'not-added' && (
|
||||
<div className="flex items-center gap-3 border-l pl-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{appBackupHealth?.lastBackup
|
||||
? `Backed up ${formatRelativeTime(appBackupHealth.lastBackup)}`
|
||||
: 'No backup'}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => createBackup()} disabled={isCreatingBackup}>
|
||||
{isCreatingBackup ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Archive className="h-4 w-4 mr-2" />}
|
||||
Back Up Now
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/instances/${instanceName}/backups/${appName}`)}>
|
||||
View backups
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scripts */}
|
||||
{appDetails.manifest?.scripts && appDetails.manifest.scripts.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-l pl-4">
|
||||
{appDetails.manifest.scripts.map((script) => (
|
||||
<Button
|
||||
key={script.name}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
title={script.description}
|
||||
onClick={() => {
|
||||
if (script.params && script.params.length > 0) {
|
||||
setPendingScript({ name: script.name, params: {} });
|
||||
} else {
|
||||
runScript(script.name);
|
||||
}
|
||||
}}
|
||||
disabled={runningScripts.has(script.name)}
|
||||
>
|
||||
{runningScripts.has(script.name) ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Terminal className="h-4 w-4 mr-2" />}
|
||||
{script.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script param form */}
|
||||
{pendingScript && (() => {
|
||||
const script = appDetails.manifest?.scripts?.find(s => s.name === pendingScript.name);
|
||||
if (!script) return null;
|
||||
return (
|
||||
<Card className="shrink-0 border-l-4 border-l-blue-500">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{script.name}
|
||||
</CardTitle>
|
||||
{script.description && <CardDescription>{script.description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{script.params?.map((param) => (
|
||||
<div key={param.name}>
|
||||
<Label htmlFor={`param-${param.name}`} className="text-xs">
|
||||
{param.name}{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</Label>
|
||||
{param.description && <p className="text-xs text-muted-foreground mb-1">{param.description}</p>}
|
||||
<Input
|
||||
id={`param-${param.name}`}
|
||||
className="mt-1"
|
||||
value={pendingScript.params[param.name] ?? ''}
|
||||
onChange={(e) => setPendingScript(prev => prev ? {
|
||||
...prev,
|
||||
params: { ...prev.params, [param.name]: e.target.value }
|
||||
} : null)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const ps = pendingScript;
|
||||
setPendingScript(null);
|
||||
runScript(ps.name, ps.params);
|
||||
}}
|
||||
disabled={script.params?.some(p => p.required && !pendingScript.params[p.name])}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setPendingScript(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="flex gap-2 border-l pl-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(appName)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Trash2 className="h-4 w-4 mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drift summary */}
|
||||
{drift && (drift.source || drift.compilation || drift.deploy) && (
|
||||
<Card className="shrink-0 border-l-4 border-l-amber-500">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-amber-500 mt-0.5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
{drift.source?.drifted && (
|
||||
<p className="text-sm">
|
||||
{drift.source.currentVersion && drift.source.availableVersion
|
||||
? `Update available: ${drift.source.currentVersion} \u2192 ${drift.source.availableVersion}`
|
||||
: drift.source.reason}
|
||||
</p>
|
||||
)}
|
||||
{drift.compilation?.drifted && (
|
||||
<p className="text-sm">{drift.compilation.reason}</p>
|
||||
)}
|
||||
{drift.deploy?.drifted && (
|
||||
<p className="text-sm">{drift.deploy.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Script output */}
|
||||
{scriptOutput && (
|
||||
<Card className="shrink-0">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
{scriptOutput.name}
|
||||
{scriptOutput.status === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
)}
|
||||
{scriptOutput.status === 'completed' && (
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
)}
|
||||
{scriptOutput.status === 'failed' && (
|
||||
<AlertCircle className="h-3 w-3 text-red-600" />
|
||||
)}
|
||||
</CardTitle>
|
||||
{scriptOutput.status !== 'running' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setScriptOutput(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre
|
||||
ref={scriptOutputRef}
|
||||
className="bg-muted rounded-md p-3 text-xs font-mono overflow-auto max-h-64 whitespace-pre-wrap"
|
||||
>
|
||||
{scriptOutput.lines.length > 0
|
||||
? scriptOutput.lines.join('\n')
|
||||
: scriptOutput.status === 'running'
|
||||
? 'Waiting for output...'
|
||||
: 'No output'}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* App Information */}
|
||||
<Card className="shrink-0">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Application Information</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user