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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user