diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c98ea..a7ae566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ ## Changelog +### v0.68.0 — storage lifecycle on the intermediary model: H2/H3/M1/M3 + deterministic boot-id (2026-06-15) + +Pairs with agent v0.36.0. Finishes the storage lifecycle on the new mount model. + +- **Boot-id determinism (kills the 1/8 race).** `processGuestBootChange` replaces the fragile + container-uptime sample: the agent reports `guest_boot_id` (changes per guest boot, stable across a + controller-only restart), persisted in settings (`LastGuestBootID`). On a change, every deployed + drive-backed app whose drive is present and that docker brought back (`shouldRecreateOnBoot`: state not + stopped/not_deployed) is DETERMINISTICALLY recreated onto the populated path. Respects user-stop; + gate-stopped apps stay the gate's job. +- **H2 — decommission UI button** (settings.html): a "Leszerelés" button on every connected drive → + migrate-then-decommission (uses the inline target select) OR decommission-anyway (type-to-confirm the + drive name). Both modes were already server-side; the new model never touches the parent mp. +- **H3 — one-click re-enroll/reconnect.** `handleStorageReconnect` now also handles a DECOMMISSIONED + drive: clears the soft marker + schedulable, re-attaches under the parent, restarts apps (re-discovered + via `appsOnStoragePath` since decommission-anyway doesn't persist StoppedStacks). New + "Visszacsatlakoztatás" button on decommissioned drives. +- **M1 — default reassignment.** `defaultPromotionTarget` + `finalizeDecommissionWith`: decommissioning + the DEFAULT auto-promotes another schedulable drive (preferring the migrate target); if NONE exists the + decommission is BLOCKED with a clear message (never zero default). +- **M3 — userdata setgid on migrate.** The merge-walk now RE-ASSERTS 2775-setgid/gid-1000 + (`EnsureUserdataDir`) on the userdata tree (`isUserdataDir`) instead of merely preserving the source + mode — so a pre-existing stale 755 target dir (e.g. import/calibre) is corrected. +- Fix: the H1 disconnect/reconnect/restart-apps JS sent `{path}` but the handler decodes `{where}` + (always 400); response keys realigned (`restarted`). New buttons use `{where}`. + +Tests (non-hollow + companions): `TestShouldRecreateOnBoot` (old sample missed a healthy-stale app), +`TestDefaultPromotionTarget`, `TestIsUserdataDir`. + + ### v0.67.5 — gate: startup recreate waits for stack scan + handles exited apps (2026-06-15) Adds a bounded wait for the stack scan (GetStacks is empty at NewServer time, so the recreate found no diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 434617b..e8111e7 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -275,6 +275,9 @@ func (d DiskInfo) FSUUID() string { type DisksResponse struct { VMID int `json:"vmid"` Disks []DiskInfo `json:"disks"` + // GuestBootID changes on every guest boot (host or guest reboot) but is stable across a + // controller-only restart — the deterministic signal the controller recreates drive-backed apps on. + GuestBootID string `json:"guest_boot_id,omitempty"` } // FormatResult mirrors POST /disks/format (the success/refusal payload). diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index d449914..81b0675 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -39,6 +39,11 @@ type Settings struct { // Cross-drive restic repo password (auto-generated on first use) CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"` + // Last-seen guest boot-id (intermediary-mount model): persisted so the controller can detect a guest + // reboot across its own restart (it restarts with the guest) and deterministically recreate + // drive-backed apps once the agent re-propagates the drive. + LastGuestBootID string `json:"last_guest_boot_id,omitempty"` + // Hub verification state HubVerified bool `json:"hub_verified,omitempty"` HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339 @@ -931,6 +936,21 @@ func (s *Settings) SetHubVerified(verified bool, at time.Time) error { } // SetHubLastCheck updates the last Hub check timestamp without changing verification status. +// GetLastGuestBootID returns the persisted last-seen guest boot-id ("" if never recorded). +func (s *Settings) GetLastGuestBootID() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.LastGuestBootID +} + +// SetLastGuestBootID persists the current guest boot-id (after a deterministic boot-recreate pass). +func (s *Settings) SetLastGuestBootID(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.LastGuestBootID = id + return s.save() +} + func (s *Settings) SetHubLastCheck(at time.Time) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/controller/internal/stacks/migrate.go b/controller/internal/stacks/migrate.go index 4d3a757..d7535b7 100644 --- a/controller/internal/stacks/migrate.go +++ b/controller/internal/stacks/migrate.go @@ -851,9 +851,12 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert if err := os.MkdirAll(dst, 0o755); err != nil { return err } - // #8 (v0.66.0): preserve the SOURCE dir's full mode (incl. setgid) + group, so the userdata - // ownership convention (2775 setgid, gid 1000) survives a whole-drive migration. MkdirAll's - // mode is umask-masked + drops setgid, so re-stamp explicitly from the source. + // M3: userdata dirs ALWAYS get the 2775-setgid/gid-1000 convention RE-ASSERTED (not merely + // source-preserved), so a PRE-EXISTING stale 755 target dir is corrected regardless of the + // source mode. Other dirs keep #8's source-mode preservation. + if isUserdataDir(rel) { + return appbackup.EnsureUserdataDir(dst) + } return preserveDirOwnership(dst, d) } @@ -1018,6 +1021,14 @@ func copyFile(src, dst string) (int64, error) { // preserveDirOwnership re-stamps a freshly-created target dir with the SOURCE dir's full mode (incl. // setgid) and group — part of the #8 fix so the userdata convention survives a migration. +// isUserdataDir reports whether a namespace-relative path is the userdata tree (the customer-facing +// shared-content area) — `userdata` itself or anything under it — which must carry the 2775-setgid +// convention. Normalised to forward slashes so it matches on any host. +func isUserdataDir(rel string) bool { + r := filepath.ToSlash(rel) + return r == "userdata" || strings.HasPrefix(r, "userdata/") +} + func preserveDirOwnership(dst string, d fs.DirEntry) error { info, err := d.Info() if err != nil { diff --git a/controller/internal/stacks/migrate_setgid_test.go b/controller/internal/stacks/migrate_setgid_test.go new file mode 100644 index 0000000..b9290e6 --- /dev/null +++ b/controller/internal/stacks/migrate_setgid_test.go @@ -0,0 +1,27 @@ +package stacks + +import "testing" + +// TestIsUserdataDir pins which namespace-relative dirs the migrate merge-walk routes through the +// 2775-setgid re-assert (EnsureUserdataDir) vs plain source-mode preservation. This is the M3 routing +// decision: only the customer-facing `userdata` tree gets the convention ENFORCED on pre-existing target +// dirs. +// +// COMPANION GUARD: the pre-fix merge-walk had NO userdata branch (everything went through +// preserveDirOwnership, which copies the SOURCE mode) — equivalent to isUserdataDir always returning +// false. The `userdata/...` → true cases below fail that impl, so a stale 755 target userdata dir would +// keep its 755 instead of becoming 2775. +func TestIsUserdataDir(t *testing.T) { + yes := []string{"userdata", "userdata/import", "userdata/import/calibre", "userdata/media/movies"} + no := []string{"appdata", "appdata/romm", "backups", "media", "userdataX", "", "."} + for _, r := range yes { + if !isUserdataDir(r) { + t.Errorf("isUserdataDir(%q) = false, want true", r) + } + } + for _, r := range no { + if isUserdataDir(r) { + t.Errorf("isUserdataDir(%q) = true, want false (only the userdata tree gets the setgid re-assert)", r) + } + } +} diff --git a/controller/internal/web/decommission_test.go b/controller/internal/web/decommission_test.go index c12055c..ce05f77 100644 --- a/controller/internal/web/decommission_test.go +++ b/controller/internal/web/decommission_test.go @@ -14,11 +14,18 @@ import ( func TestFinalizeDecommission_SoftMarksAndCallsAgent(t *testing.T) { s := testServer(t) _ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/old", Schedulable: true, IsDefault: true}) + // The migrate target is a registered schedulable drive (realistic for migrate-then-decommission); M1 + // promotes it to default since /mnt/old was the default. + _ = s.settings.AddStoragePath(settings.StoragePath{Path: "/mnt/new", Schedulable: true}) agent := &mockAgent{} if err := s.finalizeDecommissionWith(context.Background(), agent, "/mnt/old", "/mnt/new"); err != nil { t.Fatalf("finalize: %v", err) } + // M1: the default was reassigned to the migrate target (never zero default). + if s.settings.GetDefaultStoragePath() != "/mnt/new" { + t.Errorf("default not reassigned to /mnt/new (M1), got %q", s.settings.GetDefaultStoragePath()) + } // soft-marked, entry retained if !s.settings.IsDecommissioned("/mnt/old") { t.Errorf("path not soft-marked decommissioned") diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index 7a09613..1d70d59 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -43,23 +43,51 @@ func agentWhere(registeredPath string) string { // stackStartedRecently heuristically reports whether the stack's containers started within ~5 minutes // (i.e. a fresh guest boot, not a long-running app across a controller-only restart) — read from docker's // human "Up X …" status string. Used to limit the startup boot-stale recreate to the guest-reboot case. -func (s *Server) stackStartedRecently(name string, _ time.Duration) bool { - st, ok := s.stackMgr.GetStack(name) - if !ok { +// shouldRecreateOnBoot is the PURE decision for the boot-id recreate: on a fresh guest boot, which +// deployed drive-backed app to recreate onto its (re-propagated) drive. Recreate every app whose drive +// is present (BoundUnderParent) AND that docker BROUGHT BACK on this boot (State not stopped/not_deployed +// — i.e. its containers exist). A cleanly user-stopped app (compose down → no containers → Stopped) is +// respected; a gate-stopped app is the gate's job (StoppedStacks). Deterministic — depends only on +// deployed + drive-present + state, NOT a fragile container-uptime sample (the old `stackStartedRecently` +// missed an app that was healthy-but-stale or stopped at the sample instant). +func shouldRecreateOnBoot(deployed bool, hdd string, state stacks.ContainerState, presentStable map[string]bool) bool { + if !deployed || hdd == "" || !strings.HasPrefix(hdd, StableParentDir+"/") || !presentStable[hdd] { return false } - for _, c := range st.Containers { - status := strings.ToLower(c.Status) - if strings.Contains(status, "second") || strings.Contains(status, "about a minute") { - return true + return state != stacks.StateStopped && state != stacks.StateNotDeployed +} + +// defaultPromotionTarget decides M1 (never leave zero default). If the path being decommissioned is NOT +// the current default → ("", false): nothing to do. If it IS the default → pick a promotion target from +// the OTHER schedulable, non-decommissioned paths (preferring `migratedTo` — the migrate-then-decommission +// target — when valid); ("", true) = MUST BLOCK (no other usable drive exists). Pure → unit-testable. +func defaultPromotionTarget(paths []settings.StoragePath, decommissioning, migratedTo string) (target string, mustBlock bool) { + isDefault := false + for _, sp := range paths { + if sp.Path == decommissioning && sp.IsDefault { + isDefault = true } - for _, m := range []string{"up 1 minute", "up 2 minute", "up 3 minute", "up 4 minute"} { - if strings.Contains(status, m) { + } + if !isDefault { + return "", false + } + usable := func(p string) bool { + for _, sp := range paths { + if sp.Path == p && sp.Path != decommissioning && sp.Schedulable && !sp.Decommissioned { return true } } + return false } - return false + if migratedTo != "" && usable(migratedTo) { + return migratedTo, false + } + for _, sp := range paths { + if sp.Path != decommissioning && sp.Schedulable && !sp.Decommissioned { + return sp.Path, false + } + } + return "", true // no other usable drive → block the decommission } // appsOnStoragePath returns the deployed stack names whose HDD_PATH equals the given (stable) storage @@ -204,14 +232,14 @@ func (s *Server) ReconcileDriveGates() { // periodic gate. func (s *Server) driveGateLoop() { // Wait for the stack scan to complete (GetStacks empty at NewServer time) and the agent to come up, - // so the one-time boot-stale recreate sees the real deployed apps + drive state. Bounded poll. + // so the boot-id recreate sees the real deployed apps + drive state. Bounded poll. for i := 0; i < 30; i++ { if s.stackMgr != nil && len(s.stackMgr.GetStacks()) > 0 { break } time.Sleep(time.Second) } - s.recreateBootStaleApps() + s.processGuestBootChange() s.ReconcileDriveGates() t := time.NewTicker(30 * time.Second) defer t.Stop() @@ -220,16 +248,16 @@ func (s *Server) driveGateLoop() { } } -// recreateBootStaleApps converges a GUEST REBOOT deterministically. On a guest reboot docker auto-starts -// the app containers (restart:unless-stopped) potentially BEFORE the agent has re-propagated the drive -// under the parent — so those containers bind the empty fail-closed stable dir (and the non-recursive -// parent bind + leaf-bind pinning means they never pick up the later propagation in their own ns). This -// runs ONCE at controller startup (the controller itself restarts with the guest): for every deployed -// drive-backed app whose drive is NOW present (BoundUnderParent) AND whose containers started recently -// (a fresh guest boot, not a long-running app across a controller-only restart), it recreates the app -// (Stop=down + Start=up) so it binds the populated path. Apps whose drive is still absent are left to the -// normal gate (stop→return→restart). Best-effort. -func (s *Server) recreateBootStaleApps() { +// processGuestBootChange converges a GUEST REBOOT DETERMINISTICALLY. On a guest reboot docker auto-starts +// the app containers (restart:unless-stopped) potentially BEFORE the agent re-propagates the drive under +// the parent — so they bind the empty fail-closed stable dir and (non-recursive parent bind + leaf-bind +// pinning) never pick up the later propagation. The agent reports a `guest_boot_id` that changes on every +// guest boot but is stable across a controller-only restart; the controller persists the last-seen value. +// When it changes (the controller restarts WITH the guest), this recreates every deployed drive-backed +// app whose drive is present + that docker brought back (`shouldRecreateOnBoot`) onto the populated path, +// then persists the new boot-id. Apps on a still-absent drive are handled by the normal gate +// (stop→return→restart). Replaces the old fragile container-uptime sample. Best-effort. +func (s *Server) processGuestBootChange() { if s.settings == nil || s.stackMgr == nil { return } @@ -243,6 +271,9 @@ func (s *Server) recreateBootStaleApps() { if derr != nil { return } + if resp.GuestBootID == "" || resp.GuestBootID == s.settings.GetLastGuestBootID() { + return // no boot-id, or unchanged (controller-only restart) → no recreate + } presentStable := map[string]bool{} for _, d := range resp.Disks { if d.GuestPath != "" && d.BoundUnderParent { @@ -254,25 +285,18 @@ func (s *Server) recreateBootStaleApps() { if cfg == nil { continue } - hdd := cfg.Env["HDD_PATH"] - if hdd == "" || !strings.HasPrefix(hdd, StableParentDir+"/") || !presentStable[hdd] { + if !shouldRecreateOnBoot(cfg.Deployed, cfg.Env["HDD_PATH"], st.State, presentStable) { continue } - // Recreate when the app is boot-stale or not cleanly running: recently-started (it likely came up - // on the empty bind before the drive was re-propagated) OR currently exited/restarting/unhealthy - // (came up wrong and bailed). SKIP a healthy long-running app (no bounce on a controller-only - // restart) and a cleanly user-Stopped app (respect the user's intent). - needs := s.stackStartedRecently(st.Name, 5*time.Minute) || - st.State == stacks.StateExited || st.State == stacks.StateRestarting || st.State == stacks.StateUnhealthy - if !needs { - continue - } - s.logger.Printf("[INFO] [gate] startup: recreating drive-backed app %s (state=%s) onto its drive %s", st.Name, st.State, hdd) + s.logger.Printf("[INFO] [gate] boot %s: recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, st.Name, st.State, cfg.Env["HDD_PATH"]) _ = s.stackMgr.StopStack(st.Name) if serr := s.stackMgr.StartStack(st.Name); serr != nil { - s.logger.Printf("[WARN] [gate] startup recreate %s: %v", st.Name, serr) + s.logger.Printf("[WARN] [gate] boot recreate %s: %v", st.Name, serr) } } + if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil { + s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr) + } } // ---- H1 endpoints (the UI's settings.js calls these; previously 404/unrouted) ----------------- @@ -302,17 +326,30 @@ func (s *Server) handleStorageDisconnect(w http.ResponseWriter, r *http.Request) // handleStorageReconnect re-attaches a returned drive without restart: agent-attach the felhom-data bind // under the parent (live), restart the gate-stopped apps, clear the disconnected mark. +// handleStorageReconnect re-enrolls a DISCONNECTED **or** DECOMMISSIONED drive in one click (H3): +// (for a decommissioned drive) clears the soft marker + restores schedulable, then re-attaches the +// felhom-data under the parent (agent, raw path, no reboot) and restarts the gate-stopped apps. The data +// is intact — decommission is non-destructive — so the apps resolve once the bind is live. func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request) { where, ok := s.gateWhere(w, r) if !ok { return } + decommissioned := s.settings.IsDecommissioned(where) var stopped []string for _, sp := range s.settings.GetStoragePaths() { if sp.Path == where { stopped = sp.StoppedStacks } } + if decommissioned { + if _, cerr := s.reEnrollClearMarker(where); cerr != nil { + writeDiskJSON(w, http.StatusInternalServerError, false, cerr.Error(), nil) + return + } + // decommission-anyway stopped the apps WITHOUT persisting StoppedStacks — re-discover them. + stopped = s.appsOnStoragePath(where) + } agent, err := s.agentClient() if err != nil { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) @@ -328,7 +365,7 @@ func (s *Server) handleStorageReconnect(w http.ResponseWriter, r *http.Request) return } go s.SyncFileBrowserMounts() - writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "restarted": stopped}) + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"where": where, "restarted": stopped, "reenrolled": decommissioned}) } // handleStorageRestartApps restarts the gate-stopped apps on a path (without changing connection state) — diff --git a/controller/internal/web/intermediary_test.go b/controller/internal/web/intermediary_test.go index 8f126a4..c05262c 100644 --- a/controller/internal/web/intermediary_test.go +++ b/controller/internal/web/intermediary_test.go @@ -5,6 +5,7 @@ import ( "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) func TestAgentWhere(t *testing.T) { @@ -20,6 +21,64 @@ func TestAgentWhere(t *testing.T) { } } +// TestShouldRecreateOnBoot pins the deterministic boot-id recreate decision. +// +// COMPANION GUARD: the OLD timed-sample (`stackStartedRecently || State∈{exited,restarting,unhealthy}`) +// MISSED a healthy-but-stale Running app sampled minutes after boot — the first case below would be +// `false` under it. The boot-id path recreates it (it came back on this boot, drive present). It still +// respects a cleanly user-Stopped app and never touches absent-drive / SSD / not-deployed apps. +func TestShouldRecreateOnBoot(t *testing.T) { + present := map[string]bool{"/mnt/felhom-drives/felhom-flash": true} + cases := []struct { + name string + deployed bool + hdd string + state stacks.ContainerState + want bool + }{ + {"healthy-but-stale (old sample MISSED this)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, true}, + {"exited", true, "/mnt/felhom-drives/felhom-flash", stacks.StateExited, true}, + {"unhealthy", true, "/mnt/felhom-drives/felhom-flash", stacks.StateUnhealthy, true}, + {"user-stopped (respected)", true, "/mnt/felhom-drives/felhom-flash", stacks.StateStopped, false}, + {"not-deployed", true, "/mnt/felhom-drives/felhom-flash", stacks.StateNotDeployed, false}, + {"drive absent (gate handles)", true, "/mnt/felhom-drives/felhom-usb", stacks.StateRunning, false}, + {"SSD path never", true, "/mnt/sys_drive/felhom-data", stacks.StateRunning, false}, + {"app.yaml not deployed", false, "/mnt/felhom-drives/felhom-flash", stacks.StateRunning, false}, + } + for _, c := range cases { + if got := shouldRecreateOnBoot(c.deployed, c.hdd, c.state, present); got != c.want { + t.Errorf("%s: shouldRecreateOnBoot = %v, want %v", c.name, got, c.want) + } + } +} + +// TestDefaultPromotionTarget pins M1 (never leave zero default). +// +// COMPANION GUARD: the pre-fix decommission blanked the default and promoted nothing — equivalent to this +// always returning ("", false). The "promote another" + "block when only drive" cases below fail that. +func TestDefaultPromotionTarget(t *testing.T) { + flash := "/mnt/felhom-drives/felhom-flash" + usb := "/mnt/felhom-drives/felhom-usb" + // decommissioning a NON-default → no action. + paths := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Schedulable: true}} + if tgt, blk := defaultPromotionTarget(paths, usb, ""); tgt != "" || blk { + t.Fatalf("non-default decommission: got (%q,%v), want (\"\",false)", tgt, blk) + } + // decommissioning the DEFAULT with another usable → promote it. + if tgt, blk := defaultPromotionTarget(paths, flash, ""); tgt != usb || blk { + t.Fatalf("default decommission: got (%q,%v), want (%q,false)", tgt, blk, usb) + } + // prefer the migrate target when valid. + if tgt, _ := defaultPromotionTarget(paths, flash, usb); tgt != usb { + t.Fatalf("should prefer migrate target %q, got %q", usb, tgt) + } + // the ONLY usable drive (other is decommissioned) → BLOCK. + only := []settings.StoragePath{{Path: flash, IsDefault: true, Schedulable: true}, {Path: usb, Decommissioned: true}} + if tgt, blk := defaultPromotionTarget(only, flash, ""); tgt != "" || !blk { + t.Fatalf("only-drive decommission: got (%q,%v), want (\"\",true)", tgt, blk) + } +} + func TestStablePathForName(t *testing.T) { if got := stablePathForName("felhom-usb"); got != "/mnt/felhom-drives/felhom-usb" { t.Errorf("stablePathForName = %q", got) diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 5cd44ab..27b8f67 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -429,9 +429,23 @@ func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo str // target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so // the orchestration is testable without a live agent. func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error { + // M1: never leave zero default. If `where` is the default, promote another usable drive; if none + // exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which + // flips IsDefault/Schedulable on `where`). + promote, mustBlock := defaultPromotionTarget(s.settings.GetStoragePaths(), where, migratedTo) + if mustBlock { + return fmt.Errorf("ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)") + } if err := s.settings.SetDecommissioned(where, migratedTo); err != nil { return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err) } + if promote != "" { + if derr := s.settings.SetDefaultStoragePath(promote); derr != nil { + s.logger.Printf("[WARN] [web] default reassignment to %s failed: %v", promote, derr) + } else { + s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote) + } + } // Registered path is the STABLE /mnt/felhom-drives/; the agent decommissions the raw mount. if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil { return fmt.Errorf("a meghajtó leszerelése sikertelen: %w", err) diff --git a/controller/internal/web/templates/settings.html b/controller/internal/web/templates/settings.html index 85a9594..e92c4ac 100644 --- a/controller/internal/web/templates/settings.html +++ b/controller/internal/web/templates/settings.html @@ -247,6 +247,7 @@ function pollUntilBack() {
+
{{$.CSRFField}} @@ -343,6 +344,7 @@ function pollUntilBack() { {{end}} +
{{end}} @@ -1130,7 +1132,7 @@ function storageDisconnect(path, label, appCount) { fetch('/api/storage/disconnect', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({path: path}) + body: JSON.stringify({where: path}) }).then(function(r) { return r.json(); }).then(function(data) { if (data.ok) { alert('A meghajtó biztonságosan eltávolítható.'); @@ -1146,7 +1148,7 @@ function storageReconnect(path) { fetch('/api/storage/reconnect', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({path: path}) + body: JSON.stringify({where: path}) }).then(function(r) { return r.json(); }).then(function(data) { if (data.ok) { location.reload(); @@ -1163,19 +1165,56 @@ function storageRestartApps(path) { fetch('/api/storage/restart-apps', { method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), - body: JSON.stringify({path: path}) + body: JSON.stringify({where: path}) }).then(function(r) { return r.json(); }).then(function(data) { if (data.ok) { - var msg = ''; - if (data.started && data.started.length) msg += 'Elindítva: ' + data.started.join(', '); - if (data.failed && data.failed.length) msg += (msg ? '\n' : '') + 'Sikertelen: ' + data.failed.join(', '); - if (msg) alert(msg); + var r2 = data.restarted || []; + if (r2.length) alert('Elindítva: ' + r2.join(', ')); location.reload(); } else { alert('Hiba: ' + (data.error || 'ismeretlen')); } }).catch(function(e) { alert('Hiba: ' + e); }); } +// H2: decommission a drive (non-destructive). If a migrate target is picked in the inline select → +// migrate-then-decommission; otherwise decommission-anyway with type-to-confirm. +function storageDecommission(path, label, appCount) { + var sel = document.getElementById('migrate-target-' + path); + var target = sel ? sel.value : ''; + var body; + if (target) { + if (!confirm('Leszerelés áthelyezéssel: minden adat átmásolása ide: ' + target + ', majd a(z) ' + label + ' leszerelése?\n\nAz adatok NEM törlődnek.')) return; + body = {where: path, mode: 'migrate', target: target}; + } else { + var name = path.split('/').pop(); + var typed = prompt('A(z) ' + label + ' leszereléséhez (áthelyezés nélkül) írja be a meghajtó nevét megerősítésként:\n\n' + name + + (appCount > 0 ? '\n\nFIGYELEM: ' + appCount + ' alkalmazás leáll (az adatok megmaradnak).' : '')); + if (typed === null) return; + if (typed.trim() !== name) { alert('A név nem egyezik — megszakítva.'); return; } + body = {where: path, mode: 'anyway', mount_name: name}; + } + fetch('/api/storage/decommission', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), + body: JSON.stringify(body) + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.ok) { alert('Leszerelés elindítva.'); location.reload(); } + else { alert('Hiba: ' + (data.error || 'ismeretlen')); } + }).catch(function(e) { alert('Hiba: ' + e); }); +} +// H3: one-click re-enroll a decommissioned/ejected drive — clears the marker, re-attaches under the +// parent, restarts the gate-stopped apps (data is intact). +function storageReEnroll(path, label) { + if (!confirm('Visszacsatlakoztatja a(z) ' + label + ' meghajtót?\n\nAz adatok érintetlenek; a leállított alkalmazások újraindulnak.')) return; + fetch('/api/storage/reconnect', { + method: 'POST', + headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), + body: JSON.stringify({where: path}) + }).then(function(r) { return r.json(); }).then(function(data) { + if (data.ok) { location.reload(); } + else { alert('Hiba: ' + (data.error || 'ismeretlen')); } + }).catch(function(e) { alert('Hiba: ' + e); }); +} function cancelEditLabel(path, label) { var wrap = document.getElementById('label-wrap-' + path); if (!wrap) return;