v0.141.0: F6 initialize-to-usable (detached crash-safe init job + status poll) + F7 Vissza back-routes

- F6: POST /api/storage/init runs format→mount→register as a DETACHED single-flight job (context.Background, netAddState shape) the wizard polls via GET /api/storage/init/status; 3-step progress; register-last marker-last crash-safety. Fixes the client-disconnect-aborts-mount bug. No agent change (chain reaches FileBrowser sync = controller-only). Red-proof TestStorageInit_DetachedSurvivesClientDisconnect.
- F7: storage_init/attach Vissza → /storage (was /settings). Test TestStorageWizardBackAnchors_PointToStorage.
This commit is contained in:
2026-07-17 09:20:59 +02:00
parent 5be2449267
commit 0025a3b090
7 changed files with 365 additions and 38 deletions
+2 -1
View File
@@ -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
+37 -11
View File
@@ -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 —
@@ -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, `<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>`) {
t.Errorf("%s: the Vissza Back anchor must point to /storage (F7)", file)
}
if strings.Contains(src, `<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>`) {
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)
+157
View File
@@ -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)
}
@@ -3,7 +3,7 @@
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Meglévő meghajtó csatolása</h2>
</div>
</div>
@@ -3,7 +3,7 @@
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<a href="/storage" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Új meghajtó inicializálása</h2>
</div>
</div>
@@ -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<cur?'kész':(i===cur?'folyamatban…':'vár');
var style=i<cur?'color:var(--accent-ok,#3a9a4a)':(i===cur?'font-weight:600':'opacity:.5');
return '<div style="'+style+'">'+(i+1)+'. '+esc(lbl)+' — '+state+'</div>';
});
return '<div class="init-steps" style="display:flex;flex-direction:column;gap:.25rem">'+rows.join('')+'</div>';
}
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='<div class="alert alert-error">Az állapot lekérdezése sikertelen.</div>'; 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='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
+(d.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-0);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(d.opsign)+'</pre>'):'')+'</div>';
btn.disabled=false; return;
}
if(ph==='done'){ finishInit(d.where, out); return; }
if(ph==='failed'){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(d.error||'')+'</div>'; 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='<p class="form-hint">Formázás és csatlakoztatás folyamatban…</p>';
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='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong> Ez a meghajtó védett (rendszer/biztonsági mentés).'
+(res.j.data.opsign?('<pre class="mono" style="white-space:pre-wrap;background:var(--bg-0);padding:.75rem;border-radius:6px;margin-top:.5rem">'+esc(res.j.data.opsign)+'</pre>'):'')+'</div>';
btn.disabled=false; return false;
}
finishInit(res.j, out);
if(res.status===409){ out.innerHTML='<div class="alert alert-warning">'+esc((res.j&&res.j.error)||'Már folyamatban van egy inicializálás.')+'</div>'; btn.disabled=false; return false; }
if(!res.j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(res.j.error||'')+'</div>'; btn.disabled=false; return false; }
await pollInitStatus(out, btn);
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; btn.disabled=false; }
return false;
}
@@ -150,18 +180,19 @@ function renderConfirm(durableId, out, btn){
+'<div class="form-actions" style="gap:.6rem;margin-top:.75rem"><button id="init-confirm-go" class="btn btn-danger-outline" disabled>Törlés és inicializálás megerősítése</button></div>'
+'<div id="init-confirm-result" style="margin-top:.6rem"></div>';
document.getElementById('init-confirm-go').onclick=async function(){
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás folyamatban</p>';
var cr=document.getElementById('init-confirm-result'); cr.innerHTML='<p class="form-hint">Törlés és inicializálás indítása</p>';
try{
var res2=await postInit(true, durableId);
if(res2.status===409){ cr.innerHTML='<div class="alert alert-warning">'+esc((res2.j&&res2.j.error)||'')+'</div>'; return; }
if(!res2.j.ok){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(res2.j.error||'')+'</div>'; return; }
finishInit(res2.j, out);
out.innerHTML=stepper('formatting');
await pollInitStatus(out, btn);
}catch(e){ cr.innerHTML='<div class="alert alert-error">Hiba: '+esc(e.message)+'</div>'; }
};
}
function finishInit(j, out){
if(!j.ok){ out.innerHTML='<div class="alert alert-error">Hiba: '+esc(j.error||'')+'</div>'; document.getElementById('init-btn').disabled=false; return; }
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(j.data&&j.data.where)+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
function finishInit(where, out){
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+esc(where)+'</strong>. <a href="/storage">Vissza a tárhelyhez →</a></div>';
}
loadDisks();
</script>