feat: deployed app removal + missing field injection (v0.19.0)
Add "Eltávolítás" to remove deployed (non-orphaned) stacks — reverts them to "Nincs telepítve" while preserving templates for redeploy. Modal offers HDD data and backup data cleanup choices. Auto-inject missing deploy fields (secrets, domains) into existing app.yaml when templates are updated via sync or on controller startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,8 +155,15 @@ func main() {
|
||||
logger.Printf("[WARN] Initial stack scan failed: %v", err)
|
||||
}
|
||||
|
||||
// Inject missing deploy fields for all deployed stacks on startup
|
||||
if names := stackMgr.DeployedStackNames(); len(names) > 0 {
|
||||
stackMgr.InjectMissingFields(names)
|
||||
}
|
||||
|
||||
// --- Initialize catalog syncer ---
|
||||
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks)
|
||||
syncer := catalogsync.New(cfg, logger, stackMgr.ScanStacks, func(updated []string) {
|
||||
stackMgr.InjectMissingFields(updated)
|
||||
})
|
||||
syncer.Start()
|
||||
defer syncer.Stop()
|
||||
|
||||
|
||||
@@ -111,6 +111,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
|
||||
r.getStackHDDData(w, req, extractName(path, "/hdd-data"))
|
||||
|
||||
// GET /api/stacks/{name}/backup-data
|
||||
case hasSuffix(path, "/backup-data") && req.Method == http.MethodGet:
|
||||
r.getStackBackupData(w, req, extractName(path, "/backup-data"))
|
||||
|
||||
// POST /api/stacks/{name}/remove — remove a deployed (non-orphaned) stack
|
||||
case hasSuffix(path, "/remove") && req.Method == http.MethodPost:
|
||||
r.removeStack(w, req, extractName(path, "/remove"))
|
||||
|
||||
// DELETE /api/stacks/{name}
|
||||
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
|
||||
r.deleteStack(w, req, trimSegment(path, "/stacks/"))
|
||||
@@ -344,6 +352,82 @@ func (r *Router) getStackHDDData(w http.ResponseWriter, _ *http.Request, name st
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
func (r *Router) getStackBackupData(w http.ResponseWriter, _ *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
return
|
||||
}
|
||||
|
||||
// Compute the drive path for this stack (HDD or system data path)
|
||||
var drivePath string
|
||||
if r.crossDriveRunner != nil {
|
||||
drivePath = r.crossDriveRunner.GetAppDrivePath(name)
|
||||
}
|
||||
|
||||
resp, err := r.stackMgr.GetStackBackupData(name, drivePath)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp})
|
||||
}
|
||||
|
||||
func (r *Router) removeStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
if name == "" {
|
||||
writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid stack name"})
|
||||
return
|
||||
}
|
||||
limitBody(w, req)
|
||||
r.logger.Printf("[API] Remove requested for stack: %s", name)
|
||||
|
||||
var body struct {
|
||||
RemoveHDDData bool `json:"remove_hdd_data"`
|
||||
RemoveBackups bool `json:"remove_backups"`
|
||||
}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
body.RemoveHDDData = false
|
||||
body.RemoveBackups = false
|
||||
}
|
||||
|
||||
// Compute backup paths to remove if requested
|
||||
var backupPaths []string
|
||||
if body.RemoveBackups && r.crossDriveRunner != nil {
|
||||
drivePath := r.crossDriveRunner.GetAppDrivePath(name)
|
||||
if drivePath != "" {
|
||||
backupPaths = append(backupPaths,
|
||||
backup.AppDBDumpPath(drivePath, name),
|
||||
backup.AppSecondaryRsyncPath(drivePath, name),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := r.stackMgr.RemoveStack(name, body.RemoveHDDData, backupPaths)
|
||||
if err != nil {
|
||||
r.logger.Printf("[API] Remove failed for %s: %v", name, err)
|
||||
status := http.StatusInternalServerError
|
||||
if strings.Contains(err.Error(), "protected") {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
if strings.Contains(err.Error(), "not deployed") || strings.Contains(err.Error(), "still running") {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up cross-drive backup config for this stack
|
||||
if r.sett != nil {
|
||||
if err := r.sett.SetCrossDriveConfig(name, nil); err != nil {
|
||||
r.logger.Printf("[WARN] Failed to clean cross-drive config for %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: resp, Message: "Stack " + name + " removed"})
|
||||
}
|
||||
|
||||
func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
|
||||
limitBody(w, req)
|
||||
r.logger.Printf("[API] Delete requested for stack: %s", name)
|
||||
|
||||
@@ -52,8 +52,8 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
|
||||
r.dbDumper = d
|
||||
}
|
||||
|
||||
// getAppDrivePath returns the drive path for an app.
|
||||
func (r *CrossDriveRunner) getAppDrivePath(stackName string) string {
|
||||
// GetAppDrivePath returns the drive path for an app (HDD path or system data path fallback).
|
||||
func (r *CrossDriveRunner) GetAppDrivePath(stackName string) string {
|
||||
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
|
||||
return hddPath
|
||||
}
|
||||
@@ -334,7 +334,7 @@ func (r *CrossDriveRunner) runRsyncBackup(ctx context.Context, stackName, destBa
|
||||
// copyStackDBDumps copies DB dump files for the given stack from its home drive.
|
||||
// DB dumps are at <drive>/backups/primary/<stack>/db-dumps/<stack>_<dbtype>.sql.
|
||||
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
|
||||
appDrive := r.getAppDrivePath(stackName)
|
||||
appDrive := r.GetAppDrivePath(stackName)
|
||||
dumpDir := AppDBDumpPath(appDrive, stackName)
|
||||
|
||||
entries, err := os.ReadDir(dumpDir)
|
||||
@@ -441,7 +441,7 @@ func (r *CrossDriveRunner) AutoEnableSmallApps() {
|
||||
}
|
||||
|
||||
// Find destination: first active storage path that differs from the app's home drive
|
||||
appDrive := r.getAppDrivePath(stack.Name)
|
||||
appDrive := r.GetAppDrivePath(stack.Name)
|
||||
var destPath string
|
||||
for _, sp := range storagePaths {
|
||||
if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeleteResponse holds the result of a stack deletion.
|
||||
// DeleteResponse holds the result of a stack deletion (orphan delete).
|
||||
type DeleteResponse struct {
|
||||
Deleted string `json:"deleted"`
|
||||
VolumesRemoved []string `json:"volumes_removed"`
|
||||
@@ -19,6 +19,22 @@ type DeleteResponse struct {
|
||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
||||
}
|
||||
|
||||
// RemoveResponse holds the result of removing a deployed (non-orphaned) stack.
|
||||
type RemoveResponse struct {
|
||||
Removed string `json:"removed"`
|
||||
VolumesRemoved []string `json:"volumes_removed"`
|
||||
HDDPathsRemoved []string `json:"hdd_paths_removed"`
|
||||
HDDPathsPreserved []string `json:"hdd_paths_preserved"`
|
||||
BackupPathsRemoved []string `json:"backup_paths_removed,omitempty"`
|
||||
}
|
||||
|
||||
// BackupDataResponse holds information about backup data associated with a stack.
|
||||
type BackupDataResponse struct {
|
||||
Stack string `json:"stack"`
|
||||
BackupPaths []HDDPath `json:"backup_paths"` // reuses HDDPath (path, size, exists)
|
||||
HasBackups bool `json:"has_backups"`
|
||||
}
|
||||
|
||||
// HDDDataResponse holds information about HDD data associated with a stack.
|
||||
type HDDDataResponse struct {
|
||||
Stack string `json:"stack"`
|
||||
@@ -199,6 +215,176 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// RemoveStack removes a deployed (non-orphaned) stack: stops containers, removes
|
||||
// volumes, optionally removes HDD data and backup data, then removes app.yaml
|
||||
// so the stack reverts to "not deployed" state. The template files (docker-compose.yml,
|
||||
// .felhom.yml) are preserved so the user can redeploy.
|
||||
func (m *Manager) RemoveStack(name string, removeHDDData bool, backupPathsToRemove []string) (*RemoveResponse, error) {
|
||||
// Safety: never remove protected stacks
|
||||
if m.cfg.IsProtectedStack(name) {
|
||||
return nil, fmt.Errorf("stack %q is protected and cannot be removed", name)
|
||||
}
|
||||
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
// Must be deployed
|
||||
if !stack.Deployed {
|
||||
return nil, fmt.Errorf("stack %q is not deployed", name)
|
||||
}
|
||||
|
||||
// Must be stopped (not running)
|
||||
if stack.State == StateRunning || stack.State == StateStarting || stack.State == StateRestarting {
|
||||
return nil, fmt.Errorf("stack %q is still running — stop it first before removing", name)
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
hddPath := m.cfg.Paths.HDDPath
|
||||
|
||||
m.logger.Printf("[INFO] Removing deployed stack: %s (removeHDDData=%v, backupPaths=%d)", name, removeHDDData, len(backupPathsToRemove))
|
||||
start := time.Now()
|
||||
|
||||
resp := &RemoveResponse{
|
||||
Removed: name,
|
||||
}
|
||||
|
||||
// Step 1: Parse compose file for HDD bind mounts
|
||||
hddMounts := ParseComposeHDDMounts(stack.ComposePath, hddPath)
|
||||
|
||||
// Step 2: Run docker compose down --volumes (keep images for potential redeploy)
|
||||
env := m.stackEnv(stackDir)
|
||||
output, err := m.composeExecCustomEnv(stackDir, env, "down", "--volumes")
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] docker compose down for %s failed: %v (output: %s)", name, err, truncateStr(output, 200))
|
||||
return resp, fmt.Errorf("docker compose down failed for %s: %w", name, err)
|
||||
}
|
||||
|
||||
// Step 3: Identify removed volumes from compose output
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.Contains(line, "Removing volume") || strings.Contains(line, "Volume") {
|
||||
resp.VolumesRemoved = append(resp.VolumesRemoved, line)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Handle HDD data
|
||||
protected := ProtectedHDDPaths(hddPath)
|
||||
for _, mount := range hddMounts {
|
||||
cleanPath := filepath.Clean(mount)
|
||||
if protected != nil && protected[cleanPath] {
|
||||
m.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
if removeHDDData {
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove HDD data %s: %v", cleanPath, err)
|
||||
} else {
|
||||
m.logger.Printf("[INFO] Removed HDD data: %s (%s)", cleanPath, sizeHuman)
|
||||
resp.HDDPathsRemoved = append(resp.HDDPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
} else {
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
resp.HDDPathsPreserved = append(resp.HDDPathsPreserved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Handle backup data cleanup
|
||||
for _, bkPath := range backupPathsToRemove {
|
||||
cleanPath := filepath.Clean(bkPath)
|
||||
if _, err := os.Stat(cleanPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
sizeHuman := getDirSizeHuman(cleanPath)
|
||||
if err := os.RemoveAll(cleanPath); err != nil {
|
||||
m.logger.Printf("[ERROR] Failed to remove backup data %s: %v", cleanPath, err)
|
||||
} else {
|
||||
m.logger.Printf("[INFO] Removed backup data: %s (%s)", cleanPath, sizeHuman)
|
||||
resp.BackupPathsRemoved = append(resp.BackupPathsRemoved, fmt.Sprintf("%s (%s)", cleanPath, sizeHuman))
|
||||
}
|
||||
}
|
||||
|
||||
// Step 6: Remove app.yaml only (keep template files for redeploy)
|
||||
appYAMLPath := filepath.Join(stackDir, "app.yaml")
|
||||
if err := os.Remove(appYAMLPath); err != nil && !os.IsNotExist(err) {
|
||||
m.logger.Printf("[ERROR] Failed to remove %s: %v", appYAMLPath, err)
|
||||
return resp, fmt.Errorf("failed to remove app.yaml: %w", err)
|
||||
}
|
||||
|
||||
m.logger.Printf("[INFO] Stack %s removed successfully (took %.1fs)", name, time.Since(start).Seconds())
|
||||
|
||||
// Step 7: Update in-memory state and rescan
|
||||
m.mu.Lock()
|
||||
if s, ok := m.stacks[name]; ok {
|
||||
s.Deployed = false
|
||||
s.AppConfig = nil
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if err := m.ScanStacks(); err != nil {
|
||||
m.logger.Printf("[WARN] Rescan after remove failed: %v", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetStackBackupData returns information about backup data for a stack.
|
||||
// drivePath is the app's home drive (HDD or system data path).
|
||||
func (m *Manager) GetStackBackupData(name string, drivePath string) (*BackupDataResponse, error) {
|
||||
_, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("stack %q not found", name)
|
||||
}
|
||||
|
||||
resp := &BackupDataResponse{
|
||||
Stack: name,
|
||||
}
|
||||
|
||||
if drivePath == "" {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Check DB dump directory: <drive>/backups/primary/<stack>/db-dumps
|
||||
dbDumpPath := filepath.Join(drivePath, "backups", "primary", name, "db-dumps")
|
||||
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(dbDumpPath))
|
||||
|
||||
// Check cross-drive rsync directory: <drive>/backups/secondary/<stack>/rsync
|
||||
rsyncPath := filepath.Join(drivePath, "backups", "secondary", name, "rsync")
|
||||
resp.BackupPaths = append(resp.BackupPaths, buildPathInfo(rsyncPath))
|
||||
|
||||
for _, p := range resp.BackupPaths {
|
||||
if p.Exists {
|
||||
resp.HasBackups = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildPathInfo creates an HDDPath with size info for a given path.
|
||||
func buildPathInfo(path string) HDDPath {
|
||||
item := HDDPath{Path: path}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
item.Exists = false
|
||||
return item
|
||||
}
|
||||
item.Exists = true
|
||||
if info.IsDir() {
|
||||
item.SizeBytes = getDirSizeBytes(path)
|
||||
item.SizeHuman = getDirSizeHuman(path)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
|
||||
// that reference the HDD path from volume bind mounts.
|
||||
func ParseComposeHDDMounts(composePath, hddPath string) []string {
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
@@ -424,6 +425,16 @@ func generateValue(spec string) (string, error) {
|
||||
return "", fmt.Errorf("reading random bytes: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
case "base64key":
|
||||
byteLen := 0
|
||||
if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
|
||||
return "", fmt.Errorf("invalid base64key length: %q", parts[1])
|
||||
}
|
||||
b := make([]byte, byteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("reading random bytes: %w", err)
|
||||
}
|
||||
return "base64:" + base64.StdEncoding.EncodeToString(b), nil
|
||||
case "static":
|
||||
return parts[1], nil
|
||||
default:
|
||||
@@ -431,6 +442,77 @@ func generateValue(spec string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// InjectMissingFields checks deployed stacks for new deploy_fields that are not
|
||||
// yet in app.yaml and auto-generates values for secret/domain fields.
|
||||
// Called after sync (for updated stacks) and on startup (for all deployed stacks).
|
||||
func (m *Manager) InjectMissingFields(stackNames []string) {
|
||||
for _, name := range stackNames {
|
||||
stack, ok := m.GetStack(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil || !appCfg.Deployed {
|
||||
continue
|
||||
}
|
||||
|
||||
var injected []string
|
||||
for _, field := range meta.DeployFields {
|
||||
if _, exists := appCfg.Env[field.EnvVar]; exists {
|
||||
continue // already present
|
||||
}
|
||||
|
||||
switch field.Type {
|
||||
case "secret":
|
||||
if field.Generate == "" {
|
||||
m.logger.Printf("[WARN] Stack %s: new secret field %s has no generator — skipping", name, field.EnvVar)
|
||||
continue
|
||||
}
|
||||
value, err := generateValue(field.Generate)
|
||||
if err != nil {
|
||||
m.logger.Printf("[ERROR] Stack %s: failed to generate %s: %v", name, field.EnvVar, err)
|
||||
continue
|
||||
}
|
||||
appCfg.Env[field.EnvVar] = value
|
||||
if field.LockedAfterDeploy {
|
||||
appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
|
||||
}
|
||||
injected = append(injected, field.EnvVar)
|
||||
|
||||
case "domain":
|
||||
appCfg.Env[field.EnvVar] = m.cfg.Customer.Domain
|
||||
if field.LockedAfterDeploy && !containsStr(appCfg.LockedFields, field.EnvVar) {
|
||||
appCfg.LockedFields = append(appCfg.LockedFields, field.EnvVar)
|
||||
}
|
||||
injected = append(injected, field.EnvVar)
|
||||
|
||||
default:
|
||||
m.logger.Printf("[WARN] Stack %s: new field %s (type=%s) requires manual configuration", name, field.EnvVar, field.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if len(injected) > 0 {
|
||||
if err := SaveAppConfig(stackDir, appCfg); err != nil {
|
||||
m.logger.Printf("[ERROR] Stack %s: failed to save app.yaml after injection: %v", name, err)
|
||||
continue
|
||||
}
|
||||
m.logger.Printf("[SYNC] Stack %s: injected missing fields: %s", name, strings.Join(injected, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(slice []string, s string) bool {
|
||||
for _, v := range slice {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func randomAlphanumeric(length int) (string, error) {
|
||||
result := make([]byte, length)
|
||||
for i := range result {
|
||||
|
||||
@@ -108,6 +108,19 @@ func detectComposeCommand() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// DeployedStackNames returns the names of all deployed stacks.
|
||||
func (m *Manager) DeployedStackNames() []string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var names []string
|
||||
for name, stack := range m.stacks {
|
||||
if stack.Deployed {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ScanStacks discovers all compose stacks in the stacks directory.
|
||||
func (m *Manager) ScanStacks() error {
|
||||
m.mu.Lock()
|
||||
|
||||
@@ -18,15 +18,16 @@ import (
|
||||
|
||||
// Syncer handles periodic git sync of the app catalog to the local stacks directory.
|
||||
type Syncer struct {
|
||||
cfg *config.Config
|
||||
logger *log.Logger
|
||||
cacheDir string // local git clone
|
||||
rescanFn func() error
|
||||
mu sync.Mutex
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
cfg *config.Config
|
||||
logger *log.Logger
|
||||
cacheDir string // local git clone
|
||||
rescanFn func() error
|
||||
postSyncHook func(updated []string) // called after sync with names of updated stacks
|
||||
mu sync.Mutex
|
||||
lastSync time.Time
|
||||
lastErr error
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// SyncStatus holds information about the last sync operation.
|
||||
@@ -46,14 +47,16 @@ type SyncResult struct {
|
||||
}
|
||||
|
||||
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
|
||||
func New(cfg *config.Config, logger *log.Logger, rescanFn func() error) *Syncer {
|
||||
// postSyncHook is called with names of updated stacks (may be nil).
|
||||
func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSyncHook func([]string)) *Syncer {
|
||||
cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache")
|
||||
return &Syncer{
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
cacheDir: cacheDir,
|
||||
rescanFn: rescanFn,
|
||||
stopCh: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
logger: logger,
|
||||
cacheDir: cacheDir,
|
||||
rescanFn: rescanFn,
|
||||
postSyncHook: postSyncHook,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +190,11 @@ func (s *Syncer) doSync() SyncResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Inject missing deploy fields for updated stacks
|
||||
if len(updated) > 0 && s.postSyncHook != nil {
|
||||
s.postSyncHook(updated)
|
||||
}
|
||||
|
||||
// Build message
|
||||
parts := []string{}
|
||||
if len(newApps) > 0 {
|
||||
|
||||
@@ -178,6 +178,7 @@
|
||||
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">■</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">▶</button>
|
||||
{{if not .Orphaned}}<button class="btn btn-sm btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
|
||||
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">Törlés</button>{{end}}
|
||||
|
||||
@@ -204,6 +204,121 @@
|
||||
btn.textContent = 'Törlés';
|
||||
}
|
||||
}
|
||||
async function removeStack(name) {
|
||||
var modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
modal.id = 'remove-modal';
|
||||
modal.innerHTML = '<div class="modal-card"><h3>Betöltés...</h3></div>';
|
||||
modal.addEventListener('click', function(e) { if (e.target === modal) closeRemoveModal(); });
|
||||
document.body.appendChild(modal);
|
||||
try {
|
||||
var [hddResp, backupResp] = await Promise.all([
|
||||
fetch('/api/stacks/' + name + '/hdd-data').then(function(r) { return r.json(); }),
|
||||
fetch('/api/stacks/' + name + '/backup-data').then(function(r) { return r.json(); })
|
||||
]);
|
||||
var sections = '';
|
||||
// Section 1: Always removed
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mindig törlődik:</strong>' +
|
||||
'<ul style="margin:.25rem 0;padding-left:1.2rem;color:var(--text-secondary);font-size:.85rem">' +
|
||||
'<li>Docker kötetek (adatbázis, alkalmazás konfiguráció)</li>' +
|
||||
'<li>Telepítési konfiguráció (app.yaml)</li>' +
|
||||
'<li>Másodlagos mentés ütemezése</li>' +
|
||||
'</ul></div>';
|
||||
// Section 2: HDD data
|
||||
var hddCheckbox = '';
|
||||
if (hddResp.ok && hddResp.data && hddResp.data.has_hdd_data) {
|
||||
var hddPaths = '';
|
||||
hddResp.data.hdd_paths.forEach(function(p) {
|
||||
hddPaths += '<div class="modal-hdd-path">' + p.path + ' (' + (p.exists ? p.size_human : 'nem létezik') + ')</div>';
|
||||
});
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Felhasználói adatok a merevlemezen:</strong>' + hddPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-hdd-check"> Felhasználói adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem" id="remove-hdd-keep-warning">' +
|
||||
'Ha újratelepíti az alkalmazást, az adatokat újra importálnia kell, mivel az adatbázis törlődik. A megtartott adatok a továbbiakban NEM lesznek automatikusan mentve.' +
|
||||
'</div></div>';
|
||||
hddCheckbox = '<script>document.getElementById("remove-hdd-check").addEventListener("change",function(){document.getElementById("remove-hdd-keep-warning").style.display=this.checked?"none":"";});<\/script>';
|
||||
}
|
||||
// Section 3: Backup data
|
||||
var backupCheckbox = '';
|
||||
if (backupResp.ok && backupResp.data && backupResp.data.has_backups) {
|
||||
var bkPaths = '';
|
||||
backupResp.data.backup_paths.forEach(function(p) {
|
||||
if (p.exists) bkPaths += '<div class="modal-hdd-path">' + p.path + ' (' + p.size_human + ')</div>';
|
||||
});
|
||||
if (bkPaths) {
|
||||
sections += '<div class="modal-section">' +
|
||||
'<strong>Mentési adatok:</strong>' + bkPaths +
|
||||
'<label class="modal-checkbox"><input type="checkbox" id="remove-backup-check"> Mentési adatok törlése</label>' +
|
||||
'<div class="alert alert-info" style="margin-top:.5rem;font-size:.8rem">' +
|
||||
'Az éjszakai restic pillanatképek nem törölhetők egyenként — a megőrzési szabályok szerint automatikusan elavulnak.' +
|
||||
'</div></div>';
|
||||
}
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Alkalmazás eltávolítása: ' + name + '</h3>' +
|
||||
'<p style="color:var(--text-secondary);font-size:.9rem;margin-bottom:.75rem">Az alkalmazás visszaáll "Nincs telepítve" állapotba. A sablon megmarad, újratelepíthető.</p>' +
|
||||
'<div class="alert alert-warning" style="margin-bottom:.75rem">Ez a művelet nem visszavonható!</div>' +
|
||||
sections +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn btn-outline" onclick="closeRemoveModal()">Mégsem</button>' +
|
||||
'<button class="btn btn-danger" id="confirm-remove-btn" onclick="confirmRemoveStack(\'' + name + '\')">Eltávolítás</button>' +
|
||||
'</div>' + hddCheckbox;
|
||||
} catch (err) {
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Hiba</h3><p style="color:var(--text-secondary)">Nem sikerült lekérni az adatokat: ' + err.message + '</p>' +
|
||||
'<div class="modal-actions"><button class="btn btn-outline" onclick="closeRemoveModal()">Bezárás</button></div>';
|
||||
}
|
||||
}
|
||||
function closeRemoveModal() {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
if (modal) modal.remove();
|
||||
}
|
||||
async function confirmRemoveStack(name) {
|
||||
var btn = document.getElementById('confirm-remove-btn');
|
||||
var hddCheck = document.getElementById('remove-hdd-check');
|
||||
var backupCheck = document.getElementById('remove-backup-check');
|
||||
var removeHDD = hddCheck ? hddCheck.checked : false;
|
||||
var removeBackups = backupCheck ? backupCheck.checked : false;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Eltávolítás folyamatban...';
|
||||
try {
|
||||
var resp = await fetch('/api/stacks/' + name + '/remove', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({remove_hdd_data: removeHDD, remove_backups: removeBackups})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
var modal = document.getElementById('remove-modal');
|
||||
var removedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_removed && data.data.hdd_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt adatok: ' + data.data.hdd_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
if (data.data && data.data.backup_paths_removed && data.data.backup_paths_removed.length > 0) {
|
||||
removedInfo += '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Törölt mentések: ' + data.data.backup_paths_removed.join(', ') + '</p>';
|
||||
}
|
||||
var preservedInfo = '';
|
||||
if (data.data && data.data.hdd_paths_preserved && data.data.hdd_paths_preserved.length > 0) {
|
||||
preservedInfo = '<p style="color:var(--text-secondary);font-size:.85rem;margin-top:.5rem">Megőrzött adatok: ' + data.data.hdd_paths_preserved.join(', ') + '</p>';
|
||||
}
|
||||
modal.querySelector('.modal-card').innerHTML =
|
||||
'<h3>Sikeresen eltávolítva!</h3>' +
|
||||
'<p style="color:var(--text-secondary)">Az alkalmazás (' + name + ') eltávolítva. Újratelepíthető a Telepítés gombbal.</p>' +
|
||||
removedInfo + preservedInfo +
|
||||
'<div class="modal-actions"><button class="btn btn-primary" onclick="window.location.href=\'/stacks\'">Bezárás</button></div>';
|
||||
} else {
|
||||
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Hálózati hiba: ' + err.message);
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Eltávolítás';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
|
||||
{{else}}
|
||||
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button>
|
||||
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">Eltávolítás</button>{{end}}
|
||||
{{end}}
|
||||
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a>
|
||||
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}
|
||||
|
||||
Reference in New Issue
Block a user