v0.43.0: rebuilt storage management (guided init/attach/eject on agent disk model)

Controller-only UI/orchestration over the agent's disk endpoints + StoragePath
registry. New: storage overview (data_bearing badges), guided init (format ->
resolve fs UUID -> assign -> register; data-bearing REFUSAL surfaces the
felhom-opsign command, no force-format), guided attach, eject (+deregister,
dependent-guest warning). agentapi: DiskInfo.DurableID/FSUUID + FormatResult.
PendingOp (parsed from the 403). Honest buttons (migrate disabled, no 404s).
Phase 3: removed dead CrossDrive blocks in deploy.html/backups.html.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 19:47:58 +02:00
parent 8fcd49304d
commit 29a9dcdd8c
11 changed files with 819 additions and 212 deletions
+71 -12
View File
@@ -186,6 +186,19 @@ type DiskInfo struct {
Class string `json:"class"`
DataBearing bool `json:"data_bearing"`
DataReason string `json:"data_reason"`
// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
// fs UUID (strip the "uuid:" prefix) is the key the controller passes to AssignDisk — it's the
// only way the de-privileged controller learns a mount key it cannot read off the device itself.
DurableID string `json:"durable_id"`
}
// FSUUID returns the raw filesystem UUID from a "uuid:<…>" DurableID, or "" if this disk's identity
// is not a filesystem UUID (network/lvm targets — not assignable as a host mount).
func (d DiskInfo) FSUUID() string {
if rest, ok := strings.CutPrefix(d.DurableID, "uuid:"); ok {
return rest
}
return ""
}
// DisksResponse mirrors GET /disks.
@@ -196,11 +209,26 @@ type DisksResponse struct {
// FormatResult mirrors POST /disks/format (the success/refusal payload).
type FormatResult struct {
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
VMID int `json:"vmid"`
Device string `json:"device"`
Formatted bool `json:"formatted"`
DataBearing bool `json:"data_bearing"`
Reason string `json:"reason"`
PendingOp *PendingOp `json:"pending_op,omitempty"`
}
// PendingOp mirrors the agent's bound destructive intent on a data-bearing refusal. The controller
// surfaces the exact `felhom-opsign` command from it — it CANNOT complete a destructive format itself.
type PendingOp struct {
Op string `json:"op"` // e.g. "storage_wipe"
HostScope string `json:"host_scope"` // the agent's host id (anti-retarget)
DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
FSType string `json:"fstype"` // the filesystem to mkfs after the wipe
}
// OpsignCommand returns the literal command the operator must run offline to authorize the wipe.
func (p PendingOp) OpsignCommand() string {
return fmt.Sprintf("felhom-opsign -op %s -host %s -durable-id %s", p.Op, p.HostScope, p.DurableID)
}
// ErrFormatRefused is returned by FormatDisk when the agent refuses a data-bearing format
@@ -253,20 +281,51 @@ func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, erro
// is irrelevant. Only a device the agent reads as blank is formatted.
func (c *Client) FormatDisk(ctx context.Context, device, fstype string) (FormatResult, error) {
var out FormatResult
body, err := c.post(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype})
// Status-aware POST: the agent returns the FULL FormatResponse (incl. pending_op) even on the
// 403 refusal, so we must read the body on non-2xx rather than discarding it.
data, status, err := c.postWithStatus(ctx, "/disks/format", map[string]string{"device": device, "fstype": fstype})
if err != nil {
// A data-bearing refusal comes back as HTTP 403 (the post helper turns it into an error).
if strings.Contains(err.Error(), "HTTP 403") {
return FormatResult{Device: device, Formatted: false, DataBearing: true}, ErrFormatRefused
}
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /disks/format: %w", err)
// data is the envelope's {data:…} payload (present on both success and the 403 refusal).
if len(data) > 0 {
_ = json.Unmarshal(data, &out) // best-effort; fields default on a missing/partial body
}
if status == http.StatusForbidden || (out.DataBearing && !out.Formatted) {
out.DataBearing = true
out.Formatted = false
return out, ErrFormatRefused // carries PendingOp for the caller to surface the opsign command
}
return out, nil
}
// postWithStatus issues an authenticated JSON POST and returns the envelope's data payload + the HTTP
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
func (c *Client) postWithStatus(ctx context.Context, path string, body any) (json.RawMessage, int, error) {
buf, err := json.Marshal(body)
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf))
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.hc.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env apiResponse
if err := json.Unmarshal(raw, &env); err != nil {
return nil, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err)
}
return env.Data, resp.StatusCode, nil
}
// ---- slice 9: host metrics (the customer host-health view) -------------------------------
// HostMetrics mirrors the agent's GET /host/metrics `host` block (shared HostMetrics wire shape).
+4
View File
@@ -227,6 +227,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.settingsStorageSchedulableHandler(w, r)
case path == "/settings/storage/label" && r.Method == http.MethodPost:
s.settingsStorageLabelHandler(w, r)
case path == "/settings/storage/init" && r.Method == http.MethodGet:
s.storageWizardPageHandler(w, r, "storage_init")
case path == "/settings/storage/attach" && r.Method == http.MethodGet:
s.storageWizardPageHandler(w, r, "storage_attach")
case path == "/backup/restore" && r.Method == http.MethodPost:
s.backupRestoreHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
+277
View File
@@ -0,0 +1,277 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path"
"regexp"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin
// orchestrator over the agent's authoritative disk endpoints (format/assign/eject, all data-bearing-
// gated on the agent) + the local StoragePath registry. It holds NO destructive authority: a data-
// bearing format is REFUSED by the agent, and the only thing the controller does with that refusal is
// surface the exact `felhom-opsign` command — there is no force-format path here.
// diskAgent is the subset of *agentapi.Client the orchestration needs (an interface so it's testable
// without a live agent). *agentapi.Client satisfies it.
type diskAgent interface {
Disks(ctx context.Context) (agentapi.DisksResponse, error)
FormatDisk(ctx context.Context, device, fstype string) (agentapi.FormatResult, error)
AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
}
// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -).
var mountNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,40}$`)
// validFSTypes are the filesystems the init flow offers (the agent re-validates).
var validFSTypes = map[string]bool{"ext4": true, "xfs": true}
// mountWhere builds + validates the mount target from a user-supplied name → "/mnt/<name>".
func mountWhere(name string) (string, error) {
name = strings.TrimSpace(name)
if !mountNameRe.MatchString(name) {
return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)")
}
return "/mnt/" + name, nil
}
// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device`
// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found.
func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
for _, d := range disks.Disks {
if d.BackingDevice == device {
return d.FSUUID()
}
}
return ""
}
// storageInitResult is the outcome of an init attempt (JSON-rendered to the wizard).
type storageInitResult struct {
Registered bool `json:"registered"`
Where string `json:"where,omitempty"`
// Refusal (data-bearing): the operator must sign offline. No bypass.
Refused bool `json:"refused,omitempty"`
Reason string `json:"reason,omitempty"`
Opsign string `json:"opsign,omitempty"`
}
// runStorageInit is the testable core of the init flow: format → (refuse?) → resolve new UUID →
// assign → register. On a data-bearing refusal it returns a result with Refused+Opsign and performs
// NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
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 decides. A data-bearing device is refused.
fr, err := agent.FormatDisk(ctx, device, fstype)
if errors.Is(err, agentapi.ErrFormatRefused) {
res := storageInitResult{Refused: true, Reason: fr.Reason}
if fr.PendingOp != nil {
res.Opsign = fr.PendingOp.OpsignCommand()
}
return res, nil // STOP — no bypass; the UI surfaces Opsign.
}
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err)
}
if !fr.Formatted {
return storageInitResult{}, fmt.Errorf("az eszköz nem lett megformázva (%s)", fr.Reason)
}
// 2. Resolve the NEW fs UUID (re-list disks; the device's storage now carries a fresh UUID).
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("formázás kész, de a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, 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")
}
// 3. Mount (benign assign) + 4. register.
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
return storageInitResult{Registered: true, Where: where}, nil
}
// runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate)
// and registers it. The UUID is resolved server-side from the device.
func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
disks, err := agent.Disks(ctx)
if err != nil {
return storageInitResult{}, fmt.Errorf("a meghajtólista nem olvasható: %w", err)
}
uuid := fsUUIDForDevice(disks, device)
if uuid == "" {
return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)")
}
if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
}
if err := s.registerStoragePath(where, label, setDefault); err != nil {
return storageInitResult{}, err
}
return storageInitResult{Registered: true, Where: where}, nil
}
// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
if strings.TrimSpace(label) == "" {
label = settings.InferStorageLabel(where)
}
sp := settings.StoragePath{
Path: where,
Label: label,
IsDefault: setDefault,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.settings.AddStoragePath(sp); err != nil {
return fmt.Errorf("regisztráció sikertelen: %w", err)
}
go s.SyncFileBrowserMounts()
return nil
}
// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------
// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
// client-side from GET /api/disks; the form posts to /api/storage/{init,attach}).
func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request, tmpl string) {
title := "Új meghajtó inicializálása"
if tmpl == "storage_attach" {
title = "Meglévő meghajtó csatolása"
}
data := s.baseData(tmpl, title)
s.render(w, tmpl, data)
}
// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
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/attach" && r.Method == http.MethodPost:
s.handleStorageAttach(w, r)
case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
s.handleStorageEject(w, r)
default:
http.NotFound(w, r)
}
}
type storageProvReq struct {
Device string `json:"device"`
FSType string `json:"fstype"`
MountName string `json:"mount_name"`
Label string `json:"label"`
SetDefault bool `json:"set_default"`
}
func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
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)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
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)
}
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
var req storageProvReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
where, err := mountWhere(req.MountName)
if err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
return
}
if req.Device == "" {
writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
// handleStorageEject unmounts a host mount (benign, data preserved) and DEREGISTERS its StoragePath.
// It surfaces the agent's dependent-guest warning. (Distinct from the signature-gated decommission.)
func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
var req struct {
Where string `json:"where"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
return
}
req.Where = path.Clean(strings.TrimSpace(req.Where))
if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
return
}
agent, err := s.agentClient()
if err != nil {
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
return
}
res, err := agent.EjectDisk(r.Context(), req.Where)
if err != nil {
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
return
}
// Deregister the path (best-effort — the unmount already succeeded).
if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
} else {
go s.SyncFileBrowserMounts()
}
writeDiskJSON(w, http.StatusOK, true, "", res)
}
@@ -0,0 +1,160 @@
package web
import (
"context"
"io"
"log"
"path/filepath"
"testing"
"text/template"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// TestTemplatesParse forces every HTML template (incl. the new storage wizards and the de-priv
// cleanups) to parse — they are otherwise only parsed at server startup (template.Must).
func TestTemplatesParse(t *testing.T) {
s := &Server{}
if _, err := template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"); err != nil {
t.Fatalf("templates parse: %v", err)
}
}
// mockAgent records calls so tests can assert the refusal path performs NO mount/destructive action.
type mockAgent struct {
disks agentapi.DisksResponse
formatRes agentapi.FormatResult
formatErr error
assignErr error
assignCalls []assignCall
disksCalls int
}
type assignCall struct{ uuid, where, fstype string }
func (m *mockAgent) Disks(context.Context) (agentapi.DisksResponse, error) {
m.disksCalls++
return m.disks, nil
}
func (m *mockAgent) FormatDisk(_ context.Context, device, fstype string) (agentapi.FormatResult, error) {
return m.formatRes, m.formatErr
}
func (m *mockAgent) AssignDisk(_ context.Context, uuid, where, fstype, _ string) error {
m.assignCalls = append(m.assignCalls, assignCall{uuid, where, fstype})
return m.assignErr
}
func (m *mockAgent) EjectDisk(_ context.Context, where string) (agentapi.EjectResult, error) {
return agentapi.EjectResult{Ejected: where}, nil
}
func testServer(t *testing.T) *Server {
t.Helper()
lg := log.New(io.Discard, "", 0)
sett, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), lg)
if err != nil {
t.Fatalf("settings: %v", err)
}
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}
}
// SECURITY: a data-bearing refusal must surface the opsign command and perform NO assign/register.
func TestRunStorageInit_DataBearingRefusal(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
formatErr: agentapi.ErrFormatRefused,
formatRes: agentapi.FormatResult{
Device: "/dev/sdb1", DataBearing: true, Formatted: false, Reason: "ext4 signature",
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)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Refused {
t.Fatal("expected Refused=true on a data-bearing device")
}
if res.Opsign != "felhom-opsign -op storage_wipe -host host-1 -durable-id byuuid:1234" {
t.Errorf("opsign command not surfaced: %q", res.Opsign)
}
if len(agent.assignCalls) != 0 {
t.Fatalf("REFUSAL MUST NOT mount: got %d assign call(s)", len(agent.assignCalls))
}
if len(s.settings.GetStoragePaths()) != 0 {
t.Fatal("REFUSAL MUST NOT register a StoragePath")
}
}
// Happy path: format → resolve new fs UUID from the disk list → assign with that UUID → register.
func TestRunStorageInit_Success(t *testing.T) {
s := testServer(t)
agent := &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"},
}},
}
res, err := s.runStorageInit(context.Background(), agent, "/dev/sdb1", "ext4", "/mnt/hdd1", "Külső HDD", true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered || res.Where != "/mnt/hdd1" {
t.Fatalf("expected registered at /mnt/hdd1, got %+v", res)
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "NEW-9999" || agent.assignCalls[0].where != "/mnt/hdd1" {
t.Fatalf("assign must use the resolved fs UUID + mount path: %+v", agent.assignCalls)
}
paths := s.settings.GetStoragePaths()
if len(paths) != 1 || paths[0].Path != "/mnt/hdd1" || paths[0].Label != "Külső HDD" || !paths[0].IsDefault || !paths[0].Schedulable {
t.Fatalf("StoragePath not registered as expected: %+v", paths)
}
}
// Attach is non-destructive: resolve UUID → assign → register (no format).
func TestRunStorageAttach_Success(t *testing.T) {
s := testServer(t)
agent := &mockAgent{
disks: agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{Name: "felhom-usb", BackingDevice: "/dev/sdb1", DurableID: "uuid:EXISTING-42"},
}},
}
res, err := s.runStorageAttach(context.Background(), agent, "/dev/sdb1", "", "/mnt/media", "Média", false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !res.Registered {
t.Fatal("expected registered")
}
if len(agent.assignCalls) != 1 || agent.assignCalls[0].uuid != "EXISTING-42" {
t.Fatalf("attach must assign by the existing fs UUID: %+v", agent.assignCalls)
}
}
func TestFSUUIDForDevice(t *testing.T) {
disks := agentapi.DisksResponse{Disks: []agentapi.DiskInfo{
{BackingDevice: "/dev/sda1", DurableID: "uuid:AAAA"},
{BackingDevice: "/dev/sdb1", DurableID: "store:lvm"}, // non-fs identity → no UUID
}}
if got := fsUUIDForDevice(disks, "/dev/sda1"); got != "AAAA" {
t.Errorf("fsUUIDForDevice(sda1) = %q, want AAAA", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdb1"); got != "" {
t.Errorf("fsUUIDForDevice(non-fs) = %q, want empty", got)
}
if got := fsUUIDForDevice(disks, "/dev/sdc1"); got != "" {
t.Errorf("fsUUIDForDevice(absent) = %q, want empty", got)
}
}
func TestMountWhere(t *testing.T) {
if w, err := mountWhere("hdd_1"); err != nil || w != "/mnt/hdd_1" {
t.Errorf("mountWhere(hdd_1) = %q, %v", w, err)
}
for _, bad := range []string{"", "../etc", "a/b", "x y", "/abs"} {
if _, err := mountWhere(bad); err == nil {
t.Errorf("mountWhere(%q) should be rejected", bad)
}
}
}
@@ -335,9 +335,6 @@
<span class="tier-browsable" title="A mentés böngészhető fájlrendszerben">📁</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/deploy" class="btn btn-xs btn-outline">Beállítás</a>
<button class="btn btn-xs btn-outline"
onclick="triggerCrossDriveBackup('{{.StackName}}', this)">
Futtatás most</button>
</div>
{{else}}
<span class="layer-auto-ok">✓ 1. mentés auto</span>
@@ -364,11 +361,6 @@
</div>
{{end}}
{{if .Backup.CrossDriveSummary}}
<div class="cross-drive-actions" style="margin-top:1rem">
<button class="btn btn-sm btn-outline" onclick="triggerAllCrossDrive(this)">Összes 2. mentés futtatása most</button>
</div>
{{end}}
</div>
{{end}}
@@ -604,50 +596,6 @@ function toggleTier(header) {
}
}
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Fut...';
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Futtatás most';
return;
}
btn.textContent = 'Fut...';
setTimeout(function() { location.reload(); }, 5000);
})
.catch(function(e) {
alert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Futtatás most';
});
}
function triggerAllCrossDrive(btn) {
btn.disabled = true;
btn.textContent = 'Indítás...';
fetch('/api/backup/cross-drive/run-all', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
alert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Összes futtatása most';
return;
}
btn.textContent = 'Mentések futnak...';
setTimeout(function() { location.reload(); }, 5000);
})
.catch(function(e) {
alert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Összes futtatása most';
});
}
function triggerBackupFromPage() {
const btn = document.getElementById('backup-page-btn');
btn.disabled = true;
+2 -146
View File
@@ -68,9 +68,9 @@
{{end}}
</div>
{{if .OtherStoragePaths}}
<a href="/stacks/{{.Meta.Slug}}/migrate" class="btn btn-sm btn-outline" style="margin-top:.75rem">
<span class="btn btn-sm btn-outline" style="margin-top:.75rem;opacity:.45;cursor:not-allowed" title="Hamarosan">
📦 Mozgatás másik tárolóra
</a>
</span>
{{end}}
</div>
{{end}}
@@ -116,94 +116,6 @@
<a href="/backups" style="color:var(--accent-blue)">Mentési állapot →</a>
</span>
</div>
<hr style="border-color:var(--border);margin:1rem 0">
<p style="font-weight:500;margin-bottom:1rem">2. mentés — másolat másik meghajtóra:</p>
{{if .BackupDestWarning}}
<div class="alert {{if eq .BackupDestWarningSeverity "critical"}}alert-error{{else}}alert-warning{{end}}" style="margin-bottom:1rem">{{.BackupDestWarning}}</div>
{{end}}
{{if not .BackupDestPaths}}
<div class="alert alert-info">
Másik adattároló szükséges a másolat készítéséhez.
<a href="/settings" style="color:var(--accent-blue)">Csatlakoztass egy külső meghajtót a Beállítások oldalon.</a>
</div>
{{else}}
<form method="post" action="/settings/cross-backup/{{.Meta.Slug}}">
{{.CSRFField}}
<div class="settings-grid" style="margin-bottom:1rem">
<div class="settings-row">
<span class="settings-label">Engedélyezve</span>
<label class="toggle" style="margin:0">
<input type="checkbox" name="cross_drive_enabled" id="cross-drive-enabled"
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}checked{{end}}
onchange="toggleCrossDriveFields()">
<span class="toggle-label">Igen</span>
</label>
</div>
<div class="settings-row">
<span class="settings-label">Cél tárhely</span>
<select name="cross_drive_dest" id="cd-dest" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}>
{{range .BackupDestPaths}}
<option value="{{.Path}}"
{{if and $.CrossDriveConfig (eq $.CrossDriveConfig.DestinationPath .Path)}}selected{{end}}>
{{.Label}} ({{.Path}}){{if .IsDefault}} ★{{end}}
{{if .FreeHuman}} — {{.FreeHuman}} szabad{{end}}
</option>
{{end}}
</select>
</div>
<div class="settings-row">
<span class="settings-label">Ütemezés</span>
<div>
<select name="cross_drive_schedule" id="cd-schedule" class="form-control cross-drive-field" style="max-width:20rem"
{{if not (and .CrossDriveConfig .CrossDriveConfig.Enabled)}}disabled{{end}}
onchange="onScheduleChange()">
<option value="daily" {{if and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "daily")}}selected{{end}}>
Naponta (az éjszakai mentés után)
</option>
<option value="weekly" {{if or (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "weekly")) (and .CrossDriveConfig (eq .CrossDriveConfig.Schedule "manual"))}}selected{{end}}>
Hetente, vasárnap (az éjszakai mentés után)
</option>
</select>
<div id="weekly-note" class="form-hint" style="margin-top:.5rem;display:{{if and .CrossDriveConfig (or (eq .CrossDriveConfig.Schedule "weekly") (eq .CrossDriveConfig.Schedule "manual"))}}block{{else}}none{{end}}">
Heti mentés esetén visszaállításkor az adatbázis is a mentés napjára áll vissza
a konzisztencia érdekében. A mentés napja és a visszaállítás között keletkezett
adatbázis-változások elvesznek (max. 7 nap).
</div>
</div>
</div>
</div>
{{if .CrossDriveConfig}}
{{if .CrossDriveConfig.LastRun}}
<div class="form-hint" style="margin-bottom:.75rem">
Utolsó futás: {{.CrossDriveConfig.LastRun}}
{{if eq .CrossDriveConfig.LastStatus "ok"}}Sikeres{{else if eq .CrossDriveConfig.LastStatus "error"}}Hiba: {{.CrossDriveConfig.LastError}}{{else if eq .CrossDriveConfig.LastStatus "running"}}Fut...{{end}}
{{if .CrossDriveConfig.LastDuration}} ({{.CrossDriveConfig.LastDuration}}){{end}}
{{if .CrossDriveConfig.LastSizeHuman}} — {{.CrossDriveConfig.LastSizeHuman}}{{end}}
</div>
{{end}}
{{end}}
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button type="submit" class="btn btn-sm btn-primary">Beállítások mentése</button>
{{if and .CrossDriveConfig .CrossDriveConfig.Enabled}}
<button type="button" class="btn btn-sm btn-outline"
onclick="triggerCrossDriveBackup('{{.Meta.Slug}}', this)">
Mentés most
</button>
{{end}}
</div>
</form>
<div class="form-hint" style="margin-top:.75rem;color:var(--text-muted)">
A cél meghajtó legyen más fizikai eszköz a meghibásodás elleni védelem érdekében.
</div>
{{end}}
</div>
{{end}}
@@ -775,62 +687,6 @@ function buildPostDeployCard(stackName) {
return html;
}
function toggleCrossDriveFields() {
var enabled = document.getElementById('cross-drive-enabled').checked;
var fields = document.querySelectorAll('.cross-drive-field');
for (var i = 0; i < fields.length; i++) {
fields[i].disabled = !enabled;
}
}
function onScheduleChange() {
var sel = document.getElementById('cd-schedule');
var note = document.getElementById('weekly-note');
if (sel && note) {
note.style.display = sel.value === 'weekly' ? 'block' : 'none';
}
}
function triggerCrossDriveBackup(stackName, btn) {
btn.disabled = true;
btn.textContent = 'Mentés folyamatban...';
fetch('/api/stacks/' + stackName + '/cross-backup/run', {method: 'POST', headers: csrfHeaders()})
.then(function(r) { return r.json(); })
.then(function(d) {
if (!d.ok) {
showAlert('Hiba: ' + (d.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Mentés most';
return;
}
btn.textContent = 'Mentés folyamatban...';
// Poll status
var poll = setInterval(function() {
fetch('/api/stacks/' + stackName + '/cross-backup/status')
.then(function(r) { return r.json(); })
.then(function(s) {
if (!s.ok || !s.data) return;
if (!s.data.running) {
clearInterval(poll);
var status = s.data.last_status;
if (status === 'ok') {
btn.textContent = 'Mentés kész';
} else {
btn.textContent = 'Hiba';
showAlert('Hiba: ' + (s.data.last_error || 'Ismeretlen hiba'));
}
setTimeout(function() { location.reload(); }, 2000);
}
}).catch(function(){});
}, 3000);
})
.catch(function(e) {
showAlert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Mentés most';
});
}
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');
@@ -289,7 +289,7 @@ function pollUntilBack() {
<div class="storage-app-row">
<a href="/apps/{{.Stack}}" class="storage-app-link">{{.Name}}</a>
{{if .SizeHuman}}<span class="mono form-hint">{{.SizeHuman}}</span>{{end}}
<a href="/stacks/{{.Stack}}/migrate" class="btn btn-xs btn-outline" title="Adatok áthelyezése másik tárolóra">📦 Mozgatás</a>
<span class="btn btn-xs btn-outline" style="opacity:.45;cursor:not-allowed" title="Hamarosan">📦 Mozgatás</span>
</div>
{{end}}
</div>
@@ -334,7 +334,7 @@ function pollUntilBack() {
</form>
{{end}}
{{if and (gt .AppCount 0) .HasOtherPaths}}
<a href="/settings/storage/migrate-drive?source={{.Path}}" class="btn btn-xs btn-outline">📦 Összes adat átköltöztetése</a>
<span class="btn btn-xs btn-outline" style="opacity:.45;cursor:not-allowed" title="Hamarosan">📦 Összes adat átköltöztetése</span>
{{end}}
</div>
{{end}}
@@ -352,6 +352,53 @@ function pollUntilBack() {
<a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a>
</div>
<div style="margin-top:1.5rem">
<h4 style="margin-bottom:.25rem">Meghajtók (ügynök nézet)</h4>
<p class="form-hint" style="margin-bottom:.75rem">A host-ügynök által észlelt meghajtók élő nézete (a tárolás végrehajtása az ügynöké).</p>
<div id="agent-disks">Betöltés…</div>
</div>
<script>
window.__registeredPaths=[{{range .StoragePaths}}{{if .Path}}"{{.Path}}",{{end}}{{end}}];
(function(){
function badge(d){
if(d.backing_device===""){ return ''; }
return d.data_bearing
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>'
: '<span class="badge badge-ok">Üres</span>';
}
function reg(d, registered){ return registered[d.mount_path] ? '<span class="badge badge-ok">Regisztrálva</span>' : (d.mount_path?'<span class="badge">Nem regisztrált</span>':''); }
async function load(){
var box=document.getElementById('agent-disks'); if(!box) return;
try{
var r=await fetch('/api/disks'); var j=await r.json();
if(!j.ok){ box.innerHTML='<p class="form-hint">'+(j.error||'Nem elérhető')+'</p>'; return; }
var disks=(j.data&&j.data.disks)||[];
if(disks.length===0){ box.innerHTML='<p class="form-hint">Nincs észlelt meghajtó.</p>'; return; }
var registered={}; (window.__registeredPaths||[]).forEach(function(p){registered[p]=true;});
var html='<table class="data-table"><thead><tr><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Csatolás</th><th>Osztály</th><th>Adat</th><th>Reg.</th><th></th></tr></thead><tbody>';
disks.forEach(function(d){
var ej = (d.mount_path && d.mount_path.indexOf('/mnt/')===0) ? '<button class="btn btn-xs btn-danger-outline" onclick="ejectDisk(\''+d.mount_path+'\')">Leválasztás</button>' : '';
html+='<tr><td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+(d.backing_device||'—')+'</td><td class="mono">'+(d.mount_path||'—')+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td><td>'+reg(d,registered)+'</td><td>'+ej+'</td></tr>';
});
html+='</tbody></table>';
box.innerHTML=html;
}catch(e){ box.innerHTML='<p class="form-hint">Hiba: '+e.message+'</p>'; }
}
window.ejectDisk=async function(where){
if(!confirm('Leválasztja a(z) '+where+' meghajtót? Az adatok megmaradnak, de az ott lévő alkalmazások elveszítik a tárhelyet.')) return;
try{
var r=await fetch('/api/storage/eject',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({where:where})});
var j=await r.json();
if(!j.ok){ alert('Hiba: '+(j.error||'')); return; }
var dep=(j.data&&j.data.dependent_guests)||[];
if(dep.length>0){ alert('Leválasztva. Figyelem: '+dep.length+' vendég (VMID: '+dep.join(', ')+') függött ettől a tárhelytől.'); }
location.reload();
}catch(e){ alert('Hiba: '+e.message); }
};
load();
})();
</script>
<details class="storage-add-details">
<summary class="btn btn-sm btn-outline" style="margin-top:.75rem;cursor:pointer">Már csatlakoztatott tárhely hozzáadása kézzel</summary>
<form method="POST" action="/settings/storage/add" class="storage-add-form">
@@ -0,0 +1,97 @@
{{define "storage_attach"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Meglévő meghajtó csatolása</h2>
</div>
</div>
<div class="settings-card">
<h3>1. Meghajtó kiválasztása</h3>
<p class="settings-card-desc">Válassza ki a már fájlrendszerrel rendelkező meghajtót.
<strong>A meghajtón lévő adatok nem törlődnek</strong> — a csatolás csak elérhetővé teszi azokat.</p>
<div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
<div id="disk-list">Betöltés…</div>
</div>
<div class="settings-card" id="cfg-card" style="display:none">
<h3>2. Konfiguráció</h3>
<form id="attach-form" onsubmit="return submitAttach(event)">
<div class="form-group">
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device"></span>
</div>
<div class="form-group">
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<span class="mono" style="opacity:.6">/mnt/</span>
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1"
pattern="[a-zA-Z0-9_-]+" required style="max-width:180px">
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
</div>
<div class="form-group">
<label for="storage-label">Megnevezés</label>
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
</div>
<label class="toggle" style="margin-bottom:1.25rem">
<input type="checkbox" id="set-default">
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
</label>
<div class="form-actions" style="gap:.75rem">
<button type="submit" class="btn btn-primary" id="attach-btn">Csatolás</button>
<a href="/settings" class="btn btn-outline">Mégsem</a>
</div>
<div id="attach-result" style="margin-top:1rem"></div>
</form>
</div>
<script>
var selDevice = "", selFSType = "";
async function loadDisks(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
// Attachable: has a backing device, an fs-UUID identity (durable_id "uuid:…"), and isn't mounted yet.
var attachable = disks.filter(function(d){ return d.backing_device!=="" && (d.durable_id||"").indexOf("uuid:")===0 && !d.mount_path; });
if(attachable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs csatolható (fájlrendszerrel rendelkező, még nem csatolt) meghajtó.</p>'; return; }
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Osztály</th></tr></thead><tbody>';
attachable.forEach(function(d){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" onchange="pickDisk(this)"></td>'
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td><td>'+(d.class||'—')+'</td></tr>';
});
html+='</tbody></table>';
document.getElementById('disk-list').innerHTML=html;
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
}
function pickDisk(radio){
selDevice=radio.value;
document.getElementById('sel-device').textContent=selDevice;
document.getElementById('cfg-card').style.display='block';
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
}
async function submitAttach(ev){
ev.preventDefault();
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
try{
var body={device:selDevice, fstype:"", mount_name:document.getElementById('mount-name').value,
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
var r=await fetch('/api/storage/attach',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen csatolva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
return false;
}
loadDisks();
</script>
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,125 @@
{{define "storage_init"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/settings" class="btn btn-sm btn-outline">← Vissza</a>
<h2>Új meghajtó inicializálása</h2>
</div>
</div>
<div class="settings-card">
<h3>1. Meghajtó kiválasztása</h3>
<p class="settings-card-desc">Válassza ki a formázandó meghajtót. A formázás biztonságát a host-ügynök
garantálja: <strong>adatot tartalmazó meghajtó nem formázható operátori aláírás nélkül</strong>.</p>
<div id="disk-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
<div id="disk-list">Betöltés…</div>
</div>
<div class="settings-card" id="cfg-card" style="display:none">
<h3>2. Konfiguráció</h3>
<form id="init-form" onsubmit="return submitInit(event)">
<div class="form-group">
<label>Kiválasztott eszköz</label>
<span class="settings-value mono" id="sel-device"></span>
</div>
<div class="form-group">
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
<div style="display:flex;align-items:center;gap:.25rem">
<span class="mono" style="opacity:.6">/mnt/</span>
<input type="text" id="mount-name" class="form-control" placeholder="hdd_1"
pattern="[a-zA-Z0-9_-]+" required style="max-width:180px">
</div>
<span class="form-hint">A meghajtó a /mnt/&lt;név&gt; útvonalra kerül.</span>
</div>
<div class="form-group">
<label for="fstype">Fájlrendszer</label>
<select id="fstype" class="form-control" style="max-width:180px">
<option value="ext4" selected>ext4</option>
<option value="xfs">xfs</option>
</select>
</div>
<div class="form-group">
<label for="storage-label">Megnevezés</label>
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
</div>
<label class="toggle" style="margin-bottom:1.25rem">
<input type="checkbox" id="set-default" checked>
<span class="toggle-label">Beállítás alapértelmezett adattárolóként új telepítéseknél</span>
</label>
<div class="alert alert-warning" id="warn-databearing" style="display:none;margin-bottom:1rem">
⚠️ A kiválasztott meghajtó <strong>adatot tartalmaz</strong>. A formázás védelmi okból csak
operátori aláírással hajtható végre — a rendszer megmutatja a szükséges parancsot.
</div>
<div class="form-actions" style="gap:.75rem">
<button type="submit" class="btn btn-danger-outline" id="init-btn">Inicializálás indítása</button>
<a href="/settings" class="btn btn-outline">Mégsem</a>
</div>
<div id="init-result" style="margin-top:1rem"></div>
</form>
</div>
<script>
var selDevice = "", selDataBearing = false;
function badge(d){
if(d.backing_device===""){ return '<span class="badge"></span>'; }
return d.data_bearing
? '<span class="badge badge-error" title="'+(d.data_reason||'')+'">Adatot tartalmaz</span>'
: '<span class="badge badge-ok">Üres — formázható</span>';
}
async function loadDisks(){
try{
var r = await fetch('/api/disks'); var j = await r.json();
if(!j.ok){ throw new Error(j.error||'Hiba'); }
var disks = (j.data&&j.data.disks)||[];
var formattable = disks.filter(function(d){return d.backing_device!=="";});
if(formattable.length===0){ document.getElementById('disk-list').innerHTML='<p class="form-hint">Nincs formázható (blokkeszközzel rendelkező) meghajtó.</p>'; return; }
var html='<table class="data-table"><thead><tr><th></th><th>Tároló</th><th>Típus</th><th>Eszköz</th><th>Állapot</th><th>Osztály</th><th>Adat</th></tr></thead><tbody>';
formattable.forEach(function(d,i){
html+='<tr><td><input type="radio" name="disk" value="'+d.backing_device+'" data-db="'+(d.data_bearing?'1':'0')+'" onchange="pickDisk(this)"></td>'
+'<td>'+d.name+'</td><td>'+d.type+'</td><td class="mono">'+d.backing_device+'</td>'
+'<td>'+(d.state==='attached'?'csatlakoztatva':d.state)+'</td><td>'+(d.class||'—')+'</td><td>'+badge(d)+'</td></tr>';
});
html+='</tbody></table>';
document.getElementById('disk-list').innerHTML=html;
}catch(e){ var el=document.getElementById('disk-error'); el.style.display='block'; el.textContent='Meghajtók betöltése sikertelen: '+e.message; }
}
function pickDisk(radio){
selDevice=radio.value; selDataBearing=radio.getAttribute('data-db')==='1';
document.getElementById('sel-device').textContent=selDevice;
document.getElementById('warn-databearing').style.display=selDataBearing?'block':'none';
document.getElementById('cfg-card').style.display='block';
document.getElementById('cfg-card').scrollIntoView({behavior:'smooth'});
}
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>';
try{
var body={device:selDevice, fstype:document.getElementById('fstype').value,
mount_name:document.getElementById('mount-name').value, label:document.getElementById('storage-label').value,
set_default:document.getElementById('set-default').checked};
var r=await fetch('/api/storage/init',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
var j=await r.json();
if(r.status===409 && j.data && j.data.refused){
out.innerHTML='<div class="alert alert-warning"><strong>Operátori aláírás szükséges.</strong><br>'
+'A meghajtó adatot tartalmaz, ezért a formázás védelmi okból nem hajtható végre automatikusan'
+(j.data.reason?(' ('+j.data.reason+')'):'')+'.<br><br>Az engedélyezéshez futtassa offline az operátor gépén:'
+'<pre class="mono" style="white-space:pre-wrap;background:var(--bg-primary);padding:.75rem;border-radius:6px;margin-top:.5rem">'+(j.data.opsign||'(nem elérhető)')+'</pre>'
+'Az aláírás után a Hub végrehajtja a műveletet; ezután térjen vissza ide.</div>';
btn.disabled=false; return false;
}
if(!j.ok){ throw new Error(j.error||'Hiba'); }
out.innerHTML='<div class="alert alert-success">✅ A meghajtó sikeresen inicializálva és regisztrálva: <strong class="mono">'+(j.data.where||'')+'</strong>. <a href="/settings">Vissza a Beállításokhoz →</a></div>';
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
return false;
}
loadDisks();
</script>
{{template "layout_end" .}}
{{end}}