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:
2026-02-20 11:01:21 +01:00
parent 99bf3ca7a8
commit 8130c344cc
10 changed files with 518 additions and 21 deletions
+8 -1
View File
@@ -155,8 +155,15 @@ func main() {
logger.Printf("[WARN] Initial stack scan failed: %v", err) 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 --- // --- 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() syncer.Start()
defer syncer.Stop() defer syncer.Stop()
+84
View File
@@ -111,6 +111,14 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet: case hasSuffix(path, "/hdd-data") && req.Method == http.MethodGet:
r.getStackHDDData(w, req, extractName(path, "/hdd-data")) 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} // DELETE /api/stacks/{name}
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"): case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodDelete && !hasSubpath(path, "/stacks/"):
r.deleteStack(w, req, trimSegment(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}) 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) { func (r *Router) deleteStack(w http.ResponseWriter, req *http.Request, name string) {
limitBody(w, req) limitBody(w, req)
r.logger.Printf("[API] Delete requested for stack: %s", name) r.logger.Printf("[API] Delete requested for stack: %s", name)
+4 -4
View File
@@ -52,8 +52,8 @@ func (r *CrossDriveRunner) SetDBDumper(d DBDumper) {
r.dbDumper = d r.dbDumper = d
} }
// getAppDrivePath returns the drive path for an app. // GetAppDrivePath returns the drive path for an app (HDD path or system data path fallback).
func (r *CrossDriveRunner) getAppDrivePath(stackName string) string { func (r *CrossDriveRunner) GetAppDrivePath(stackName string) string {
if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" { if hddPath := r.stackProvider.GetStackHDDPath(stackName); hddPath != "" {
return 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. // 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. // DB dumps are at <drive>/backups/primary/<stack>/db-dumps/<stack>_<dbtype>.sql.
func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error { func (r *CrossDriveRunner) copyStackDBDumps(stackName, destDir string) error {
appDrive := r.getAppDrivePath(stackName) appDrive := r.GetAppDrivePath(stackName)
dumpDir := AppDBDumpPath(appDrive, stackName) dumpDir := AppDBDumpPath(appDrive, stackName)
entries, err := os.ReadDir(dumpDir) 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 // 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 var destPath string
for _, sp := range storagePaths { for _, sp := range storagePaths {
if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned { if sp.Path != appDrive && !sp.Disconnected && !sp.Decommissioned {
+187 -1
View File
@@ -11,7 +11,7 @@ import (
"time" "time"
) )
// DeleteResponse holds the result of a stack deletion. // DeleteResponse holds the result of a stack deletion (orphan delete).
type DeleteResponse struct { type DeleteResponse struct {
Deleted string `json:"deleted"` Deleted string `json:"deleted"`
VolumesRemoved []string `json:"volumes_removed"` VolumesRemoved []string `json:"volumes_removed"`
@@ -19,6 +19,22 @@ type DeleteResponse struct {
HDDPathsPreserved []string `json:"hdd_paths_preserved"` 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. // HDDDataResponse holds information about HDD data associated with a stack.
type HDDDataResponse struct { type HDDDataResponse struct {
Stack string `json:"stack"` Stack string `json:"stack"`
@@ -199,6 +215,176 @@ func (m *Manager) GetStackHDDData(name string) (*HDDDataResponse, error) {
return resp, nil 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 // ParseComposeHDDMounts reads a docker-compose.yml and extracts host paths
// that reference the HDD path from volume bind mounts. // that reference the HDD path from volume bind mounts.
func ParseComposeHDDMounts(composePath, hddPath string) []string { func ParseComposeHDDMounts(composePath, hddPath string) []string {
+82
View File
@@ -2,6 +2,7 @@ package stacks
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"math/big" "math/big"
@@ -424,6 +425,16 @@ func generateValue(spec string) (string, error) {
return "", fmt.Errorf("reading random bytes: %w", err) return "", fmt.Errorf("reading random bytes: %w", err)
} }
return hex.EncodeToString(b), nil 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": case "static":
return parts[1], nil return parts[1], nil
default: 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) { func randomAlphanumeric(length int) (string, error) {
result := make([]byte, length) result := make([]byte, length)
for i := range result { for i := range result {
+13
View File
@@ -108,6 +108,19 @@ func detectComposeCommand() string {
return "" 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. // ScanStacks discovers all compose stacks in the stacks directory.
func (m *Manager) ScanStacks() error { func (m *Manager) ScanStacks() error {
m.mu.Lock() m.mu.Lock()
+9 -1
View File
@@ -22,6 +22,7 @@ type Syncer struct {
logger *log.Logger logger *log.Logger
cacheDir string // local git clone cacheDir string // local git clone
rescanFn func() error rescanFn func() error
postSyncHook func(updated []string) // called after sync with names of updated stacks
mu sync.Mutex mu sync.Mutex
lastSync time.Time lastSync time.Time
lastErr error lastErr error
@@ -46,13 +47,15 @@ type SyncResult struct {
} }
// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks(). // 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") cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache")
return &Syncer{ return &Syncer{
cfg: cfg, cfg: cfg,
logger: logger, logger: logger,
cacheDir: cacheDir, cacheDir: cacheDir,
rescanFn: rescanFn, rescanFn: rescanFn,
postSyncHook: postSyncHook,
stopCh: make(chan struct{}), 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 // Build message
parts := []string{} parts := []string{}
if len(newApps) > 0 { if len(newApps) > 0 {
@@ -178,6 +178,7 @@
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')"></button> <button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')"></button>
{{else}} {{else}}
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Name}}', 'start')"></button> <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}} {{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">Napló</a> <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}} {{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'; 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> </script>
</body> </body>
</html> </html>
@@ -76,6 +76,7 @@
<button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button> <button class="btn btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')">Leállítás</button>
{{else}} {{else}}
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">Indítás</button> <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}} {{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">Naplók</a> <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}} {{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>{{end}}