controller v0.68.0: storage lifecycle on intermediary model (H2/H3/M1/M3 + boot-id)
H2 decommission UI button (migrate / anyway); H3 one-click re-enroll of a
decommissioned drive; M1 default reassignment (auto-promote + block-if-none);
M3 migrate re-asserts 2775 setgid on userdata dirs; deterministic guest-reboot
recreate via agent boot_id (replaces the timed sample). Fixes the {path}/{where}
H1 JS bug. Non-hollow tests + companions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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) —
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<name>; 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)
|
||||
|
||||
@@ -247,6 +247,7 @@ function pollUntilBack() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-path-actions">
|
||||
<button class="btn btn-xs btn-primary" onclick="storageReEnroll('{{.Path}}','{{.Label}}')">Visszacsatlakoztatás</button>
|
||||
<form method="POST" action="/settings/storage/remove" style="display:inline"
|
||||
onsubmit="return confirm('Biztosan eltávolítja a(z) {{.Label}} ({{.Path}}) meghajtót a rendszerből?\n\nA meghajtó adatai NEM törlődnek.')">
|
||||
{{$.CSRFField}}
|
||||
@@ -343,6 +344,7 @@ function pollUntilBack() {
|
||||
<button class="btn btn-xs btn-outline" onclick="storageMigrateAll('{{.Path}}','{{.Label}}')">Összes adat áthelyezése</button>
|
||||
</span>
|
||||
{{end}}
|
||||
<button class="btn btn-xs btn-danger-outline" onclick="storageDecommission('{{.Path}}','{{.Label}}',{{.AppCount}})">Leszerelés</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user