diff --git a/CHANGELOG.md b/CHANGELOG.md index 9425450..b3b960a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ ## Changelog +### v0.141.0 — N100 polish: initialize-to-usable (F6) + Vissza back-routes (F7) (2026-07-17) + +Closes two `VALIDATION-n100-baremetal-2026-07-16.md` findings. Green: +`go build ./... && go vet ./... && go test ./...` all pass; template/emoji/native-confirm gates pass. + +- **F6 (MEDIUM) — drive "initialize" now ends in a USABLE (mounted+registered) drive, disconnect-safe.** + Root cause (fork verdict, source-grounded): the format→mount→register orchestration + (`internal/web/storage_handlers.go` `runStorageInit`) ran on the REQUEST context; a closed tab / + lost connection cancelled it after `FormatDisk` (the agent's mkfs continues detached, returns + `errFormatClientGone`), so the mount+register leg was aborted — device formatted but + unmounted/unregistered (the N100-observed state). The chain must reach `SyncFileBrowserMounts` + (controller-only), so it stays controller-side — **no agent change**. Fix: `POST /api/storage/init` + starts a DETACHED single-flight job (`internal/web/storage_init_job.go`, the `netAddState` shape) on + `context.Background()`; `runStorageInit` gains a nil-safe phase callback (formatting → mounting → + registering). The wizard polls the new `GET /api/storage/init/status` and renders the 3-step + progress (`storage_init.html`); the confirm/refuse verdicts surface through the same poll. Register + is the LAST step (marker-last, Scenario B) and every prior step is idempotent (`AddStoragePath` + dedups) → a crash leaves at most an unregistered orphan, never a broken/duplicate registration. + Red-proof `TestStorageInit_DetachedSurvivesClientDisconnect` (pre-fix: cancelled-ctx chain fails at + mount, NOT registered → FAIL; fixed: detached job registers exactly once). +- **F7 (LOW) — the "Vissza" (Back) anchor on `/storage/init` and `/storage/attach` now routes to + `/storage`** (was `/settings`). The init success link also points to `/storage` (where the new + drive appears). Test `TestStorageWizardBackAnchors_PointToStorage`. + ### v0.140.0 — Direction-2 immediate-sync: hub→box wait channel client (2026-07-16) The other half of the immediacy arc (Direction 1 = v0.139.0 box→hub trigger). An operator action on diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 73667ea..ba57ce5 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -92,7 +92,8 @@ type Server struct { // NAS add orchestration (verify-before-commit): the single-flight job slot + the two seams. // netAgentFn nil → the shared agentClient(); netProbeFn nil → runNetProbe (the uid-1000 re-exec). - netAdd netAddState + netAdd netAddState + storageInit storageInitState netAgentFn func() (netAgent, error) // fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0). fabUpload uploadState diff --git a/controller/internal/web/storage_handlers.go b/controller/internal/web/storage_handlers.go index 274537f..e147ded 100644 --- a/controller/internal/web/storage_handlers.go +++ b/controller/internal/web/storage_handlers.go @@ -108,13 +108,19 @@ type storageInitResult struct { // UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation // (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In // either refusal it performs NO further (destructive or mount) action. -func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) { +func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string, progress func(string)) (storageInitResult, error) { + setP := func(phase string) { + if progress != nil { + progress(phase) + } + } if !validFSTypes[fstype] { return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype) } // 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device // is allowed only with the customer's confirmation bound to its durable id; system/backup needs // an operator signature. + setP(storageInitPhaseFormatting) fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID) if errors.Is(err, agentapi.ErrNeedsConfirmation) { // USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against. @@ -135,6 +141,7 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs } // 2. Resolve the NEW fs UUID. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so // resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id. + setP(storageInitPhaseMounting) uuid := resolveEnrollUUID(ctx, agent, device) if uuid == "" { return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót") @@ -147,6 +154,10 @@ func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fs return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err) } s.attachIntoGuest(ctx, agent, where) + // 5. Register the stable path — THE LAST step (marker-last, F6/Scenario B): a crash before this + // leaves at most a mounted-but-unregistered drive (surfaced by the list), never a broken + // registration, and AddStoragePath dedups a re-run so there is never a duplicate entry. + setP(storageInitPhaseRegistering) stable := stablePathForName(path.Base(where)) if err := s.registerStoragePath(stable, label, setDefault); err != nil { return storageInitResult{}, err @@ -273,6 +284,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost: s.handleStorageInit(w, r) + case r.URL.Path == "/api/storage/init/status" && r.Method == http.MethodGet: + s.handleStorageInitStatus(w, r) case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost: s.handleStorageAttach(w, r) case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost: @@ -527,20 +540,33 @@ func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) { writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil) return } - res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID) - if err != nil { - writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil) + // F6: run format→mount→register as a DETACHED job (off the request context) the wizard polls via + // /api/storage/init/status. A closed tab / lost connection can no longer abort the post-mkfs + // mount+register leg. The confirm/refuse verdicts (fast, pre-mkfs) surface through the same poll. + started := s.startStorageInit(agent, storageInitParams{ + device: req.Device, fstype: req.FSType, where: where, label: req.Label, + setDefault: req.SetDefault, confirmed: req.Confirmed, durableID: req.DurableID, + }) + if !started { + writeDiskJSON(w, http.StatusConflict, false, "már folyamatban van egy meghajtó-inicializálás", nil) return } - if res.NeedsConfirmation { - writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res) + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "phase": storageInitPhaseFormatting}) +} + +// handleStorageInitStatus reports the last / in-flight drive-init job (deep copy; "idle" when never +// ran). The wizard polls this for the 3-step progress + the confirm/refuse verdicts (F6). +func (s *Server) handleStorageInitStatus(w http.ResponseWriter, r *http.Request) { + job := s.storageInit.snapshot() + if job == nil { + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"phase": "idle"}) return } - if res.Refused { - writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res) - return - } - writeDiskJSON(w, http.StatusOK, true, "", res) + writeDiskJSON(w, http.StatusOK, true, "", map[string]any{ + "phase": job.Phase, "device": job.Device, "where": job.Where, "error": job.Error, + "reason": job.Reason, "durable_id": job.DurableID, "opsign": job.Opsign, + "started_at": job.StartedAt, "updated_at": job.UpdatedAt, + }) } // storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount — diff --git a/controller/internal/web/storage_handlers_test.go b/controller/internal/web/storage_handlers_test.go index d5ec5b0..44749b9 100644 --- a/controller/internal/web/storage_handlers_test.go +++ b/controller/internal/web/storage_handlers_test.go @@ -11,8 +11,10 @@ import ( "os" "path/filepath" "sort" + "strings" "testing" "text/template" + "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "gitea.dooplex.hu/admin/felhom-controller/internal/config" @@ -63,7 +65,13 @@ func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string, confirm m.formatCalls = append(m.formatCalls, formatCall{device, fstype, durableID, confirmed}) return m.formatRes, m.formatErr } -func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error { +func (m *mockAgent) AssignDisk(ctx context.Context, uuid, where, fstype, _ string) error { + // Respect cancellation — a mount over a dead client/request context fails (this is the F6 bug's + // mechanism: a disconnect after format aborts the mount+register leg). Background ctx never + // cancels, so existing happy-path tests are unaffected. + if err := ctx.Err(); err != nil { + return err + } m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype}) return m.assignErr } @@ -104,7 +112,7 @@ func TestRunStorageInit_SystemBackupRefusal(t *testing.T) { PendingOp: &agentapi.PendingOp{Op: "storage_wipe", HostScope: "host-1", DurableID: "byuuid:1234", FSType: "ext4"}, }, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "") + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -133,7 +141,7 @@ func TestRunStorageInit_UserDataNeedsConfirmation(t *testing.T) { Role: "user-data", DurableID: "byid:wwn-abc", }, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "") + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -158,7 +166,7 @@ func TestRunStorageInit_UserDataConfirmedProceeds(t *testing.T) { {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-1", Role: "user-data"}, }}, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc") + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, true, "byid:wwn-abc", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -179,7 +187,7 @@ func TestRunStorageInit_Success(t *testing.T) { {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"}, }}, } - res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "") + res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true, false, "", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -204,6 +212,86 @@ func TestRunStorageInit_Success(t *testing.T) { } } +// F6 (VALIDATION-n100) — initialize must end in a USABLE (mounted+registered) drive even when the +// client disconnects mid-format. Two halves: +// RED-PROOF: runStorageInit on a CANCELLED context (the disconnect) aborts at the mount step → +// the device is formatted but NOT registered (the N100-observed state). This is exactly what +// the pre-fix handler did (it ran the chain on r.Context()). +// FIX: startStorageInit runs the chain on a DETACHED context → it registers regardless of the +// client, and leaves EXACTLY ONE registry entry (marker-last, Scenario B). +func TestStorageInit_DetachedSurvivesClientDisconnect(t *testing.T) { + newMock := func() *mockAgent { + return &mockAgent{ + formatRes: agentapi.FormatResult{Device: "/dev/sdb1", Formatted: true, DataBearing: false}, + disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{ + {Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:NEW-9999"}, + }}, + } + } + + t.Run("cancelled_ctx_does_not_register", func(t *testing.T) { + s := testServer(t) + agent := newMock() + ctx, cancel := context.WithCancel(context.Background()) + cancel() // the client disconnected right after confirm + _, err := s.runStorageInit(ctx, agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "HDD", true, false, "", nil) + if err == nil { + t.Fatal("expected the disconnected (cancelled-ctx) chain to fail at mount") + } + if got := s.settings.GetStoragePaths(); len(got) != 0 { + t.Fatalf("a disconnected init must NOT register (F6 bug): got %+v", got) + } + }) + + t.Run("detached_job_registers_exactly_once", func(t *testing.T) { + s := testServer(t) + agent := newMock() + if !s.startStorageInit(agent, storageInitParams{ + device: "/dev/sdb1", fstype: "ext4", where: "/mnt/hdd1", label: "HDD", setDefault: true, + }) { + t.Fatal("startStorageInit refused (slot unexpectedly busy)") + } + var job *storageInitJob + for i := 0; i < 300; i++ { + if job = s.storageInit.snapshot(); job != nil && (job.Phase == storageInitPhaseDone || job.Phase == storageInitPhaseFailed) { + break + } + time.Sleep(10 * time.Millisecond) + } + if job == nil || job.Phase != storageInitPhaseDone { + t.Fatalf("detached job did not reach done: %+v", job) + } + if job.Where != "/mnt/felhom-drives/hdd1" { + t.Fatalf("done job registered path = %q, want the stable /mnt/felhom-drives/hdd1", job.Where) + } + paths := s.settings.GetStoragePaths() + if len(paths) != 1 || paths[0].Path != "/mnt/felhom-drives/hdd1" { + t.Fatalf("detached init must register EXACTLY ONE stable path (marker-last): %+v", paths) + } + if len(agent.assignCalls) != 1 { + t.Fatalf("expected exactly one mount (assign): %+v", agent.assignCalls) + } + }) +} + +// F7 (VALIDATION-n100) — the "Vissza" (Back) anchor on the init/attach wizards must route to +// /storage, not /settings. One assertion per template. +func TestStorageWizardBackAnchors_PointToStorage(t *testing.T) { + for _, file := range []string{"templates/storage_init.html", "templates/storage_attach.html"} { + b, err := templateFS.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + src := string(b) + if !strings.Contains(src, `← Vissza`) { + t.Errorf("%s: the Vissza Back anchor must point to /storage (F7)", file) + } + if strings.Contains(src, `← Vissza`) { + t.Errorf("%s: the Vissza Back anchor still points to /settings (F7 regression)", file) + } + } +} + // Attach is non-destructive: resolve UUID → assign → register (no format). func TestRunStorageAttach_Success(t *testing.T) { s := testServer(t) diff --git a/controller/internal/web/storage_init_job.go b/controller/internal/web/storage_init_job.go new file mode 100644 index 0000000..225cd3d --- /dev/null +++ b/controller/internal/web/storage_init_job.go @@ -0,0 +1,157 @@ +package web + +import ( + "context" + "sync" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/logx" +) + +// Drive-initialize orchestration (F6, VALIDATION-n100). POST /api/storage/init no longer runs the +// format→mount→register chain ON THE REQUEST CONTEXT — a closed tab or a lost connection cancelled +// that context after mkfs (the agent's mkfs continues detached, but the controller's mount+register +// leg was aborted, leaving a formatted-but-unusable drive). It now starts a DETACHED job (the +// netstorage-add shape: single-flight, deep-copied status, register-LAST) the wizard polls via +// GET /api/storage/init/status. The chain reuses runStorageInit unchanged (format via the agent's +// crash-safe detached mkfs → resolve UUID → AssignDisk → attachIntoGuest → registerStoragePath → +// SyncFileBrowserMounts); this file only moves it off the request context and makes it pollable. +// +// Crash-safety (Scenario B): registerStoragePath is the LAST step (marker-last), and every prior +// step is idempotent (mkfs idempotent; AssignDisk idempotent; AddStoragePath dedups a re-register). +// So the worst outcome of a controller crash mid-chain is a formatted+mounted-but-unregistered +// drive surfaced by the drive list — never a registered-but-broken path, and never a duplicate +// registry entry: a re-run of the wizard self-heals to the terminal state. + +// storageInitJob is the poll-visible orchestration state (GET /api/storage/init/status). +type storageInitJob struct { + Device string `json:"device"` + Where string `json:"where,omitempty"` // the registered stable path (done only) + Phase string `json:"phase"` // formatting | mounting | registering | done | failed | needs_confirmation | refused + Error string `json:"error,omitempty"` + Reason string `json:"reason,omitempty"` // refusal/confirm reason (Hungarian, from the agent) + DurableID string `json:"durable_id,omitempty"` // needs_confirmation: the durable id to confirm against + Opsign string `json:"opsign,omitempty"` // refused: the operator opsign command + StartedAt time.Time `json:"started_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +const ( + storageInitPhaseFormatting = "formatting" + storageInitPhaseMounting = "mounting" + storageInitPhaseRegistering = "registering" + storageInitPhaseDone = "done" + storageInitPhaseFailed = "failed" + storageInitPhaseNeedsConfirm = "needs_confirmation" + storageInitPhaseRefused = "refused" +) + +// storageInitDeadline bounds the WHOLE chain on the detached context. The dominant term is the +// agent's mkfs (bounded to 60 min agent-side); a wall-clock ceiling well above any real format +// keeps a wedged agent from pinning the single-flight slot forever. +const storageInitDeadline = 65 * time.Minute + +// storageInitParams carries the validated init request into the detached job. +type storageInitParams struct { + device, fstype, where, label, durableID string + setDefault, confirmed bool +} + +// storageInitState is the single-flight slot (netAddState shape: acquire/release/set/snapshot, +// deep-copied status). One guest initializes one drive at a time. +type storageInitState struct { + mu sync.Mutex + running bool + cur *storageInitJob +} + +func (s *storageInitState) acquire(job *storageInitJob) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.running { + return false + } + s.running = true + cp := *job + s.cur = &cp + return true +} + +func (s *storageInitState) release() { + s.mu.Lock() + s.running = false + s.mu.Unlock() +} + +func (s *storageInitState) set(job *storageInitJob) { + s.mu.Lock() + cp := *job + s.cur = &cp + s.mu.Unlock() +} + +// snapshot returns a copy of the last / in-flight job (nil = never ran). +func (s *storageInitState) snapshot() *storageInitJob { + s.mu.Lock() + defer s.mu.Unlock() + if s.cur == nil { + return nil + } + cp := *s.cur + return &cp +} + +// startStorageInit claims the single-flight slot and launches the detached init chain. false = an +// init is already in flight (Scenario/edge: never two chains on one device). +func (s *Server) startStorageInit(agent diskAgent, p storageInitParams) bool { + now := time.Now().UTC() + job := &storageInitJob{ + Device: p.device, Where: p.where, Phase: storageInitPhaseFormatting, + StartedAt: now, UpdatedAt: now, + } + if !s.storageInit.acquire(job) { + return false + } + go s.runStorageInitJob(agent, p, job) + return true +} + +// runStorageInitJob drives runStorageInit on a DETACHED context (a closed tab can no longer abort +// the mount+register leg — F6) and records the phase/outcome in the poll slot. +func (s *Server) runStorageInitJob(agent diskAgent, p storageInitParams, job *storageInitJob) { + defer s.storageInit.release() + ctx, cancel := context.WithTimeout(context.Background(), storageInitDeadline) + defer cancel() + start := time.Now() + logx.Infof(s.logger, "[web] storage init %q started (device %s, fs %s)", p.where, p.device, p.fstype) + + progress := func(phase string) { + logx.Debugf(s.logger, "[web] storage init %q phase %s -> %s (%dms elapsed)", + p.where, job.Phase, phase, time.Since(start).Milliseconds()) + job.Phase = phase + job.UpdatedAt = time.Now().UTC() + s.storageInit.set(job) + } + + res, err := s.runStorageInit(ctx, agent, p.device, p.fstype, p.where, p.label, p.setDefault, p.confirmed, p.durableID, progress) + job.UpdatedAt = time.Now().UTC() + switch { + case err != nil: + job.Phase = storageInitPhaseFailed + job.Error = err.Error() + logx.Warnf(s.logger, "[web] storage init %q failed: %v (%dms)", p.where, err, time.Since(start).Milliseconds()) + case res.NeedsConfirmation: + job.Phase = storageInitPhaseNeedsConfirm + job.DurableID = res.DurableID + job.Reason = res.Reason + case res.Refused: + job.Phase = storageInitPhaseRefused + job.Reason = res.Reason + job.Opsign = res.Opsign + default: + job.Phase = storageInitPhaseDone + job.Where = res.Where + logx.Infof(s.logger, "[web] storage init done: %s registered (%dms)", res.Where, time.Since(start).Milliseconds()) + } + s.storageInit.set(job) +} diff --git a/controller/internal/web/templates/storage_attach.html b/controller/internal/web/templates/storage_attach.html index 4b75fde..118c33a 100644 --- a/controller/internal/web/templates/storage_attach.html +++ b/controller/internal/web/templates/storage_attach.html @@ -3,7 +3,7 @@ diff --git a/controller/internal/web/templates/storage_init.html b/controller/internal/web/templates/storage_init.html index 24fc5ba..f44bed1 100644 --- a/controller/internal/web/templates/storage_init.html +++ b/controller/internal/web/templates/storage_init.html @@ -3,7 +3,7 @@ @@ -111,7 +111,9 @@ function pickDisk(radio){ document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'}); } -// postInit runs the init POST. confirmed/durableId carry the customer's wipe confirmation (user-data). +// postInit STARTS the detached init job (F6). confirmed/durableId carry the customer's wipe +// confirmation (user-data). The chain (format → mount → register) now runs server-side off the +// request context and is followed via pollInitStatus — a closed tab no longer aborts it. async function postInit(confirmed, durableId){ var body={device:selDevice, fstype:document.getElementById('fstype').value, mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value, @@ -120,24 +122,52 @@ async function postInit(confirmed, durableId){ return {status:r.status, j:await r.json()}; } +// stepper renders the 3-step progress (Formázás → Csatolás → Regisztrálás) for the running phase. +function stepper(phase){ + var steps=['Formázás','Csatolás','Regisztrálás']; + var order={formatting:0,mounting:1,registering:2}; + var cur=order[phase]!=null?order[phase]:0; + var rows=steps.map(function(lbl,i){ + var state=i'+(i+1)+'. '+esc(lbl)+' — '+state+''; + }); + return '
'+rows.join('')+'
'; +} + +function sleep(ms){ return new Promise(function(r){ setTimeout(r,ms); }); } + +// pollInitStatus follows the detached job to a terminal state (F6): 3-step progress while running, +// the confirm/refuse verdicts, or success/failure. +async function pollInitStatus(out, btn){ + for(;;){ + await sleep(1000); + var r; try{ r=await fetch('/api/storage/init/status'); }catch(e){ continue; } + var j; try{ j=await r.json(); }catch(e){ continue; } + if(!j.ok){ out.innerHTML='
Az állapot lekérdezése sikertelen.
'; btn.disabled=false; return; } + var d=j.data||{}; var ph=d.phase; + if(ph==='formatting'||ph==='mounting'||ph==='registering'){ out.innerHTML=stepper(ph); continue; } + if(ph==='idle'){ continue; } // slot not yet populated + if(ph==='needs_confirmation'){ renderConfirm(d.durable_id, out, btn); return; } + if(ph==='refused'){ + out.innerHTML='
Operátori aláírás szükséges. Ez a meghajtó védett (rendszer/biztonsági mentés).' + +(d.opsign?('
'+esc(d.opsign)+'
'):'')+'
'; + btn.disabled=false; return; + } + if(ph==='done'){ finishInit(d.where, out); return; } + if(ph==='failed'){ out.innerHTML='
Hiba: '+esc(d.error||'')+'
'; btn.disabled=false; return; } + } +} + async function submitInit(ev){ ev.preventDefault(); var btn=document.getElementById('init-btn'); var out=document.getElementById('init-result'); - btn.disabled=true; out.innerHTML='

Formázás és csatlakoztatás folyamatban…

'; + btn.disabled=true; out.innerHTML=stepper('formatting'); try{ var res=await postInit(false, ""); - // USER-DATA data-bearing → the customer must confirm the wipe (type-to-confirm), then re-submit. - if(res.status===409 && res.j.data && res.j.data.needs_confirmation){ - renderConfirm(res.j.data.durable_id, out, btn); - return false; - } - // SYSTEM/BACKUP (shouldn't reach here — filtered out — but surface the opsign if it does). - if(res.status===409 && res.j.data && res.j.data.refused){ - out.innerHTML='
Operátori aláírás szükséges. Ez a meghajtó védett (rendszer/biztonsági mentés).' - +(res.j.data.opsign?('
'+esc(res.j.data.opsign)+'
'):'')+'
'; - btn.disabled=false; return false; - } - finishInit(res.j, out); + if(res.status===409){ out.innerHTML='
'+esc((res.j&&res.j.error)||'Már folyamatban van egy inicializálás.')+'
'; btn.disabled=false; return false; } + if(!res.j.ok){ out.innerHTML='
Hiba: '+esc(res.j.error||'')+'
'; btn.disabled=false; return false; } + await pollInitStatus(out, btn); }catch(e){ out.innerHTML='
Hiba: '+esc(e.message)+'
'; btn.disabled=false; } return false; } @@ -150,18 +180,19 @@ function renderConfirm(durableId, out, btn){ +'
' +'
'; document.getElementById('init-confirm-go').onclick=async function(){ - var cr=document.getElementById('init-confirm-result'); cr.innerHTML='

Törlés és inicializálás folyamatban…

'; + var cr=document.getElementById('init-confirm-result'); cr.innerHTML='

Törlés és inicializálás indítása…

'; try{ var res2=await postInit(true, durableId); + if(res2.status===409){ cr.innerHTML='
'+esc((res2.j&&res2.j.error)||'')+'
'; return; } if(!res2.j.ok){ cr.innerHTML='
Hiba: '+esc(res2.j.error||'')+'
'; return; } - finishInit(res2.j, out); + out.innerHTML=stepper('formatting'); + await pollInitStatus(out, btn); }catch(e){ cr.innerHTML='
Hiba: '+esc(e.message)+'
'; } }; } -function finishInit(j, out){ - if(!j.ok){ out.innerHTML='
Hiba: '+esc(j.error||'')+'
'; document.getElementById('init-btn').disabled=false; return; } - out.innerHTML='
A meghajtó sikeresen inicializálva és regisztrálva: '+esc(j.data&&j.data.where)+'. Vissza a Beállításokhoz →
'; +function finishInit(where, out){ + out.innerHTML='
A meghajtó sikeresen inicializálva és regisztrálva: '+esc(where)+'. Vissza a tárhelyhez →
'; } loadDisks();