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:
@@ -1,5 +1,37 @@
|
||||
## Changelog
|
||||
|
||||
### v0.43.0 — rebuilt storage management (guided init/attach/eject on the agent disk model) (2026-06-11)
|
||||
|
||||
After the 8C de-privileging, the storage UI's buttons pointed at deleted routes (`/settings/storage/init`,
|
||||
`/attach`, `/migrate-drive`, per-stack `/migrate`) — all 404. Everything underneath already worked (the
|
||||
agent owns disk execution + the data-bearing signature gate; the controller has the `agentapi` client +
|
||||
`/api/disks/*` proxies + the `StoragePath` registry). This is a controller-only UI/orchestration layer
|
||||
over those.
|
||||
|
||||
- **Storage overview** (`settings.html`, driven by `GET /api/disks`): the agent's live disk view — name,
|
||||
type, state, device, mount, class, and the **`data_bearing` badge** + registered cross-reference.
|
||||
- **Guided init** (`/settings/storage/init` + `POST /api/storage/init`): pick a disk → format → resolve
|
||||
the new fs UUID from the re-listed disks → assign (mount) → register the `StoragePath`. **A data-bearing
|
||||
device is REFUSED** by the agent; the UI surfaces the exact `felhom-opsign -op storage_wipe -host … -durable-id …`
|
||||
command and stops — **there is no force-format path** (the gate is the agent's; the controller has no
|
||||
destructive authority).
|
||||
- **Guided attach** (`/settings/storage/attach` + `POST /api/storage/attach`): non-destructive — resolve
|
||||
the existing fs UUID → assign → register.
|
||||
- **Eject** (`POST /api/storage/eject`): benign unmount (data preserved) + deregister, surfacing the
|
||||
agent's dependent-guest warning.
|
||||
- **`agentapi`**: `DiskInfo` gains `DurableID` (+ `FSUUID()` to strip the `uuid:` prefix — the assign
|
||||
key); `FormatResult` gains `PendingOp` (+ `OpsignCommand()`), now parsed from the agent's 403 body
|
||||
(the old path discarded it). Pairs with `felhom-agent` v0.22.0, which exposes `durable_id` in `/disks`.
|
||||
- **Honest buttons**: init/attach are wired; migrate (drive + per-stack) is disabled "Hamarosan" — no 404s.
|
||||
- **De-priv template debt (Phase 3)**: removed the dead `CrossDrive*` blocks in `deploy.html` (the "2.
|
||||
mentés" form + 3 JS fns) and `backups.html` (the run buttons + 2 JS fns) — they referenced fields the
|
||||
de-privileged handlers no longer provide (a `gt/eq` over a missing field 500s the page).
|
||||
- Migration (controller-side rsync) is intentionally deferred to its own slice (the migrate buttons are
|
||||
disabled, not dead).
|
||||
- Tests: the init refusal surfaces the `pending_op`/opsign and performs **no** assign/register; success
|
||||
assigns with the resolved UUID + registers the expected `StoragePath`; a template-parse test guards all
|
||||
pages.
|
||||
|
||||
### v0.42.1 — real Let's Encrypt cert: wildcard proactive issuance via the controller route (2026-06-11)
|
||||
|
||||
The base-infra traefik obtained **no** real cert (acme.json empty) — both routers relied on the
|
||||
|
||||
@@ -677,6 +677,8 @@ func main() {
|
||||
// disk execution; the controller forwards list/assign/eject/format.
|
||||
mux.Handle("/api/disks", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
|
||||
mux.Handle("/api/disks/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeDiskAPI))))
|
||||
// Guided storage provisioning (init/attach/eject orchestration over the agent disk API + registry).
|
||||
mux.Handle("/api/storage/", webServer.RequireAuth(webServer.CsrfProtect(http.HandlerFunc(webServer.ServeStorageAPI))))
|
||||
// Host metrics API — thin proxy to the host agent (slice 9). Read-only host-wide health +
|
||||
// per-storage capacity for the monitoring view; the de-privileged controller can't read the
|
||||
// host itself. GET only, so no CSRF wrapper needed.
|
||||
|
||||
@@ -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.
|
||||
@@ -201,6 +214,21 @@ type FormatResult struct {
|
||||
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).
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/<név> ú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/<név> ú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}}
|
||||
Reference in New Issue
Block a user