R-280: attach list from mounted-but-unregistered filesystems; two-clicks promise made conditional
gates / gates (push) Successful in 17s
gates / gates (push) Successful in 17s
After a reinstall the data drive could not be re-attached through any dashboard route: both candidate lists came from the agent's unclaimed-disk scan, and the rebuilt box's drives are claimed. The restore page said it was two clicks while pointing at an empty picker. The attach list now also carries the controller's own mounted-but-unregistered filesystems. initialize is untouched, so the format wizard's system/backup protection is unchanged. The 'two clicks' sentence is conditional on the picker being non-empty, and says something true and actionable when it is not.
This commit is contained in:
@@ -1,3 +1,40 @@
|
|||||||
|
## v0.211.0 — the wall a rebuilt box could not get past (2026-08-10, R-280) — MinAgent 0.127.0
|
||||||
|
|
||||||
|
**R-280 — after a reinstall the data drive can be re-attached, and the page stops promising a click
|
||||||
|
that does not exist.** Measured on the rebuilt demo-hp 2026-08-09: the restore page diagnosed the
|
||||||
|
situation perfectly, said *„Ez két kattintás"*, and pointed at a picker holding nothing. It was zero
|
||||||
|
clicks; getting past it needed an internal path no customer could produce.
|
||||||
|
|
||||||
|
**Why the obvious fix would not have worked, kept here because it cost the session an hour.** The
|
||||||
|
agent builds BOTH `initialize` and `attach` from its unclaimed-DISK scan, and widening that scan is
|
||||||
|
the fix the finding proposed. But the filesystem a rebuilt box must re-register is an **in-guest**
|
||||||
|
one — on demo-hp `/mnt/sys_drive`, the guest's own 70 GB data volume, which is what the escape hatch
|
||||||
|
actually registered. The agent enumerates HOST block devices and would have offered the 1 TB NVMe
|
||||||
|
(the `felhom-backup` target): the wrong drive, non-destructively attached, customer data still
|
||||||
|
unreachable. **So this ships in the controller and the agent is unchanged** — MinAgent stays 0.127.0.
|
||||||
|
|
||||||
|
- **`attach` now also carries the controller's own mounted-but-unregistered filesystems**
|
||||||
|
(`internal/web/attach_sources.go`), read from its own mount table — the controller runs in-guest
|
||||||
|
with `/mnt` bind-mounted in, so what it can see is what it can register. `initialize` is passed
|
||||||
|
through **untouched**: the format wizard's system/backup protection lives in the agent's scan and
|
||||||
|
is not widened by a single line here (pinned by `TestMergeAttachCandidates_InitializeIsUntouched`,
|
||||||
|
whose red-proof put the customer's data volume in the FORMAT list).
|
||||||
|
- **A union, not a replacement.** The agent's entries serve the case this wizard was built for — a
|
||||||
|
fresh external drive carrying a filesystem, not yet mounted — which a mount table cannot report
|
||||||
|
precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
|
||||||
|
- **These candidates are REGISTERED in place, never mounted** (`POST /api/storage/register-mounted`).
|
||||||
|
The posted path is re-derived server-side and refused if it is not currently offered, so the route
|
||||||
|
cannot register an arbitrary directory.
|
||||||
|
- **The „két kattintás" sentence is now conditional on the picker being non-empty**, and the false
|
||||||
|
branch says what is true and names a route. Both branches are render-tested.
|
||||||
|
- **Exclusions, each with a reason in the code:** the guest rootfs at `/mnt`, the intermediary-model
|
||||||
|
parent `/mnt/felhom-drives`, tmpfs/overlay, anything outside `/mnt/`, non-ext4/xfs, already-
|
||||||
|
registered paths — and **any bind alias of the rootfs, excluded by DEVICE**, because a bind
|
||||||
|
republishes a filesystem under a second path and registering that one would put app data on the
|
||||||
|
box's own root. That last guard was found by trying to build the live reproduction, not by review.
|
||||||
|
- **Fail-safe:** an unreadable mount table yields an EMPTY list, never a permissive one — and because
|
||||||
|
the page gates its promise on that list, "we could not look" renders as "we cannot offer this".
|
||||||
|
|
||||||
## v0.210.0 — two pictures that were not true (2026-08-08, R-259 / R-258) — MinAgent 0.127.0
|
## v0.210.0 — two pictures that were not true (2026-08-08, R-259 / R-258) — MinAgent 0.127.0
|
||||||
|
|
||||||
Both are the same shape: something the box already knows, drawn as its opposite.
|
Both are the same shape: something the box already knows, drawn as its opposite.
|
||||||
|
|||||||
@@ -424,6 +424,11 @@ type DiskCandidate struct {
|
|||||||
Mountable bool `json:"mountable"`
|
Mountable bool `json:"mountable"`
|
||||||
MountSource string `json:"mount_source,omitempty"`
|
MountSource string `json:"mount_source,omitempty"`
|
||||||
DurableID string `json:"durable_id,omitempty"`
|
DurableID string `json:"durable_id,omitempty"`
|
||||||
|
// AlreadyMounted marks a candidate the CONTROLLER contributed from its own mount table (R-280),
|
||||||
|
// not one the agent scanned. The agent NEVER sets it. Its action is REGISTER the existing
|
||||||
|
// mountpoint — sending it down the device-attach path would try to mount an in-guest path as if
|
||||||
|
// it were a raw device. See web/attach_sources.go for why the agent's scan cannot supply these.
|
||||||
|
AlreadyMounted bool `json:"already_mounted,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
// CandidatesResult mirrors GET /disks/candidates: disks free to enroll, split into initialize (all
|
||||||
|
|||||||
@@ -126,8 +126,18 @@ func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
|
// agentDiskCandidatesHandler proxies GET /api/disks/candidates → agent GET /disks/candidates (Impl-2b):
|
||||||
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
|
// the raw-device scan (Impl-2a) that feeds the enrollment wizards. The agent's unclaimed-disk filter
|
||||||
// already excludes claimed/OS/enrolled disks (fail-safe), so the controller passes the list through
|
// already excludes claimed/OS/enrolled disks (fail-safe), so `initialize` passes through UNTOUCHED —
|
||||||
// untouched — no controller-side filtering.
|
// no controller-side filtering, and the system/backup drives it hides from the format wizard stay
|
||||||
|
// hidden.
|
||||||
|
//
|
||||||
|
// R-280: `attach` additionally carries the controller's own mounted-but-unregistered filesystems.
|
||||||
|
// The agent's scan alone left a rebuilt box with an empty picker under a sentence promising „két
|
||||||
|
// kattintás", because the drive that must be re-registered is an in-guest filesystem no host-disk
|
||||||
|
// scan can see. Attaching is non-destructive, so this list is additive by nature — it can only ever
|
||||||
|
// offer MORE places to put data back, never a new way to erase any. Why the union rather than a
|
||||||
|
// replacement: the agent's entries serve the case this endpoint was built for — a fresh external
|
||||||
|
// drive that already carries a filesystem and is not yet mounted — which the mount table cannot
|
||||||
|
// report precisely because it is not mounted. Dropping them would fix the reinstall and break the USB.
|
||||||
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
client, err := s.agentClient()
|
client, err := s.agentClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -140,7 +150,35 @@ func (s *Server) agentDiskCandidatesHandler(w http.ResponseWriter, r *http.Reque
|
|||||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
writeDiskJSON(w, http.StatusOK, true, "", mergeAttachCandidates(resp, s.attachableStores()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeAttachCandidates adds the mounted-but-unregistered stores to `attach` and returns the result.
|
||||||
|
// `initialize` is passed through untouched — the ONE line that keeps the format wizard's protection
|
||||||
|
// intact, and the reason this is a separate function rather than two appends at the call site: it can
|
||||||
|
// be tested, and a change to it fails a test instead of shipping.
|
||||||
|
func mergeAttachCandidates(resp agentapi.CandidatesResult, stores []mountedStore) agentapi.CandidatesResult {
|
||||||
|
resp.Attach = append(resp.Attach, mountedStoreCandidates(stores)...)
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountedStoreCandidates renders mounted-but-unregistered stores in the picker's shape. MountSource
|
||||||
|
// carries the mountpoint (the thing the register action needs); Device is display only.
|
||||||
|
func mountedStoreCandidates(stores []mountedStore) []agentapi.DiskCandidate {
|
||||||
|
out := make([]agentapi.DiskCandidate, 0, len(stores))
|
||||||
|
for _, m := range stores {
|
||||||
|
out = append(out, agentapi.DiskCandidate{
|
||||||
|
Device: m.Device,
|
||||||
|
FSType: m.FSType,
|
||||||
|
MountSource: m.Path,
|
||||||
|
DataBearing: true,
|
||||||
|
Mountable: true,
|
||||||
|
// Size is deliberately absent: measuring it means statfs on a possibly-wedged device
|
||||||
|
// inside a request handler, and a picker entry is actionable without it.
|
||||||
|
AlreadyMounted: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →
|
// sortDisksForView orders the agent's disk list deterministically (user-data → system → backup →
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-280 — where the `attach` list comes from, and why it is NOT the agent's disk scan.
|
||||||
|
//
|
||||||
|
// After a reinstall the customer's drives survive but their REGISTRATION does not, and every restore
|
||||||
|
// then refuses. The restore page diagnosed that correctly and sent the customer to a page offering
|
||||||
|
// nothing: `GET /disks/candidates` returned `{"initialize":[],"attach":[]}`.
|
||||||
|
//
|
||||||
|
// The agent builds BOTH lists from its unclaimed-DISK scan (felhom-agent
|
||||||
|
// internal/localapi/disks.go handleDiskCandidates → storage.ListCandidateDisks). That filter is
|
||||||
|
// CORRECT for `initialize` — never offer to format a disk in use — and over-broad for `attach`,
|
||||||
|
// which is non-destructive.
|
||||||
|
//
|
||||||
|
// But widening the agent's scan would still not fix it, and that is the part worth writing down:
|
||||||
|
// the filesystem that must be re-registered after a reinstall is an IN-GUEST one. On the rebuilt
|
||||||
|
// demo-hp the drive the customer needed was `/mnt/sys_drive` (the guest's own data volume), and the
|
||||||
|
// escape hatch that unblocked everything registered exactly that path. The agent's host-disk scan
|
||||||
|
// cannot see it — it enumerates HOST block devices, and would have offered the 1 TB NVMe (the
|
||||||
|
// felhom-backup target) instead: the wrong drive, non-destructively attached, and the customer's
|
||||||
|
// data still not reachable.
|
||||||
|
//
|
||||||
|
// So the attach source is the controller's OWN mount table. The controller runs in-guest with /mnt
|
||||||
|
// bind-mounted in, so the filesystems it can see ARE the ones it can register — the source and the
|
||||||
|
// action finally agree.
|
||||||
|
//
|
||||||
|
// These candidates are ALREADY MOUNTED, so the action is REGISTER, never mount-a-device. That is why
|
||||||
|
// they carry AlreadyMounted: the wizard must not send them down the device-attach path, which would
|
||||||
|
// try to mount an in-guest path as if it were a raw device.
|
||||||
|
|
||||||
|
// mountedFSTypes are the on-disk filesystems a mounted store may carry. Deliberately the same pair
|
||||||
|
// the agent calls attach-mountable (storage.mountableFSTypes) and the init flow offers
|
||||||
|
// (validFSTypes) — a third dialect here is how the three lists drift apart.
|
||||||
|
var mountedFSTypes = map[string]bool{"ext4": true, "xfs": true}
|
||||||
|
|
||||||
|
// managedDrivesParent is the intermediary-model PARENT. Children of it are real drives; the parent
|
||||||
|
// itself is the container that holds them and is never a storage destination of its own.
|
||||||
|
const managedDrivesParent = "/mnt/felhom-drives"
|
||||||
|
|
||||||
|
// mountedStore is one already-mounted filesystem the controller can register as a storage location.
|
||||||
|
type mountedStore struct {
|
||||||
|
Path string // the in-guest mountpoint, e.g. /mnt/sys_drive — what gets registered, verbatim
|
||||||
|
Device string // backing device, for display only ("/dev/mapper/pve-vm--9201--disk--1")
|
||||||
|
FSType string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMountTable turns mount-table text (/proc/mounts format) into (device, mountpoint, fstype)
|
||||||
|
// rows. Space escaping (\040) is undone so a path with a space still compares — the same handling
|
||||||
|
// the agent's own procMounts does.
|
||||||
|
func parseMountTable(text string) [][3]string {
|
||||||
|
var out [][3]string
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, [3]string{
|
||||||
|
fields[0],
|
||||||
|
strings.ReplaceAll(fields[1], `\040`, " "),
|
||||||
|
fields[2],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountedUnregisteredStores returns the mounted filesystems under /mnt that are NOT already in the
|
||||||
|
// storage registry — the drives a customer can attach (register) without anything being erased.
|
||||||
|
//
|
||||||
|
// ⚠ FAIL-SAFE: an unreadable mount table yields an EMPTY list, never a permissive one. "We could not
|
||||||
|
// look" must never render as "here is what you may attach" — and, because the caller gates the
|
||||||
|
// „two kattintás" sentence on this list being non-empty, an empty list makes the page say so plainly
|
||||||
|
// rather than promise a click that does not exist.
|
||||||
|
func mountedUnregisteredStores(mountsText string, registered map[string]bool) []mountedStore {
|
||||||
|
rows := parseMountTable(mountsText)
|
||||||
|
|
||||||
|
// Devices that back the box's OWN root. A bind mount republishes a filesystem under a second
|
||||||
|
// path, and a bind of the rootfs at /mnt/<name> looks exactly like a data drive to everything
|
||||||
|
// below — offering it would invite the customer to store app data on the root filesystem and
|
||||||
|
// fill it. Excluded by DEVICE, so no alias can smuggle it back in under a different path.
|
||||||
|
rootDevices := map[string]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if w := path.Clean(row[1]); w == "/" || w == "/mnt" {
|
||||||
|
rootDevices[row[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []mountedStore
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, row := range rows {
|
||||||
|
dev, where, fstype := row[0], path.Clean(row[1]), row[2]
|
||||||
|
|
||||||
|
if rootDevices[dev] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real block device only. Excludes overlay/tmpfs/proc/sysfs and every virtual mount.
|
||||||
|
if !strings.HasPrefix(dev, "/dev/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// An on-disk filesystem we can actually hand to apps.
|
||||||
|
if !mountedFSTypes[fstype] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The storage convention is /mnt/<name>. `/mnt` itself is the guest rootfs mount, not a drive.
|
||||||
|
if !strings.HasPrefix(where, "/mnt/") || where == "/mnt" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The intermediary-model parent holds drives; it is not one.
|
||||||
|
if where == managedDrivesParent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Already registered → not a candidate. This is what makes a healthy box render exactly as
|
||||||
|
// before: its store is registered, so it never appears here (Scenario D).
|
||||||
|
if registered[where] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// /proc/mounts lists a mountpoint once per mount event; a bind or a re-mount would otherwise
|
||||||
|
// produce the same path twice in the picker.
|
||||||
|
if seen[where] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[where] = true
|
||||||
|
out = append(out, mountedStore{Path: where, Device: dev, FSType: fstype})
|
||||||
|
}
|
||||||
|
// Deterministic order — the picker must not reshuffle between reloads.
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readMountTable reads the controller's own mount table. Returns "" on failure, which
|
||||||
|
// mountedUnregisteredStores turns into an empty (never permissive) list.
|
||||||
|
func readMountTable() string {
|
||||||
|
b, err := os.ReadFile("/proc/mounts")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredStoragePaths is the set of paths already in the registry, for the exclusion above.
|
||||||
|
func (s *Server) registeredStoragePaths() map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
if s.settings == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
out[path.Clean(sp.Path)] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachableStores is the one derivation both the candidates endpoint and the restore page's
|
||||||
|
// precondition read, so the picker and the sentence pointing at it cannot disagree — the same
|
||||||
|
// single-derivation rule R-252 applied to HasRestoreDestination.
|
||||||
|
func (s *Server) attachableStores() []mountedStore {
|
||||||
|
return mountedUnregisteredStores(readMountTable(), s.registeredStoragePaths())
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// R-280 — THE DRIVE CAN BE RE-ATTACHED AFTER A REINSTALL.
|
||||||
|
//
|
||||||
|
// Measured on the rebuilt demo-hp (2026-08-09): the restore page diagnosed the situation correctly
|
||||||
|
// and then sent the customer to a picker that was empty. `GET /disks/candidates` answered
|
||||||
|
// `{"initialize":[],"attach":[]}` because BOTH lists come from the agent's unclaimed-DISK scan, and
|
||||||
|
// the box's NVMe is claimed (it is the felhom-backup target). Getting past it needed an internal path
|
||||||
|
// no customer could produce.
|
||||||
|
//
|
||||||
|
// These tests pin the SOURCE of the attach list, and the mount table below is the real one from that
|
||||||
|
// box — /mnt/sys_drive is the guest data volume the escape hatch had to register by hand.
|
||||||
|
|
||||||
|
// demoHPMounts is guest 9201's actual mount table on the rebuilt demo-hp, trimmed to the rows that
|
||||||
|
// matter. Keeping the real shape means the fixture cannot quietly diverge from the box.
|
||||||
|
const demoHPMounts = `proc /proc proc rw,relatime 0 0
|
||||||
|
/dev/mapper/pve-vm--9201--disk--0 /mnt ext4 rw,relatime,stripe=16 0 0
|
||||||
|
/dev/mapper/pve-root /mnt/felhom-drives ext4 rw,relatime,errors=remount-ro 0 0
|
||||||
|
/dev/mapper/pve-vm--9201--disk--1 /mnt/sys_drive ext4 rw,relatime,stripe=16 0 0
|
||||||
|
tmpfs /dev/shm tmpfs rw,nosuid,nodev 0 0
|
||||||
|
overlay /var/lib/docker/overlay2/x/merged overlay rw,relatime 0 0
|
||||||
|
`
|
||||||
|
|
||||||
|
// ── SCENARIO A — a mounted, unregistered filesystem on a CLAIMED disk is offered ────────────────
|
||||||
|
//
|
||||||
|
// RED-PROOF (the one that matters): revert the attach list to the unclaimed-disk scan — i.e. make
|
||||||
|
// mountedUnregisteredStores return nil, or drop the `resp.Attach = append(...)` line in
|
||||||
|
// agentDiskCandidatesHandler. This fails with `attach candidates: 0`, which IS yesterday's wall:
|
||||||
|
// an empty picker under a sentence promising two clicks.
|
||||||
|
func TestMountedUnregisteredStores_OffersTheGuestDataVolume(t *testing.T) {
|
||||||
|
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{})
|
||||||
|
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("attach candidates: %d, want 1 (/mnt/sys_drive) — got %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Path != "/mnt/sys_drive" {
|
||||||
|
t.Errorf("offered %q, want /mnt/sys_drive — the drive the rebuilt box could not re-attach", got[0].Path)
|
||||||
|
}
|
||||||
|
if got[0].FSType != "ext4" {
|
||||||
|
t.Errorf("fstype %q, want ext4", got[0].FSType)
|
||||||
|
}
|
||||||
|
if got[0].Device != "/dev/mapper/pve-vm--9201--disk--1" {
|
||||||
|
t.Errorf("device %q — the backing device is shown to the customer and must be the real one", got[0].Device)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The disk is CLAIMED — that is the whole point. The claim filter is a property of the agent's scan,
|
||||||
|
// and this source deliberately does not consult it, because attaching erases nothing. This pins that
|
||||||
|
// the mount table alone decides, so re-introducing a claim check here would fail.
|
||||||
|
func TestMountedUnregisteredStores_ClaimedDiskIsStillOffered(t *testing.T) {
|
||||||
|
// pve-vm--9201--disk--1 is LVM on the OS disk: claimed by every definition the agent uses.
|
||||||
|
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{})
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("a claimed-but-mounted filesystem was filtered out — attaching is non-destructive, " +
|
||||||
|
"and this filter is exactly what made the picker empty on the rebuilt box")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO D — a normal box with a registered store is unchanged ──────────────────────────────
|
||||||
|
|
||||||
|
// RED-PROOF: drop the `registered[where]` exclusion and this fails with the already-registered store
|
||||||
|
// offered for attaching a second time.
|
||||||
|
func TestMountedUnregisteredStores_RegisteredStoreIsNotOffered(t *testing.T) {
|
||||||
|
got := mountedUnregisteredStores(demoHPMounts, map[string]bool{"/mnt/sys_drive": true})
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("a healthy box offered %+v — its store is registered, so the picker must be empty "+
|
||||||
|
"and the page byte-identical to before this change", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The exclusions, each with the reason it exists ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestMountedUnregisteredStores_Exclusions(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, table, why string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"guest rootfs at /mnt",
|
||||||
|
"/dev/mapper/pve-vm--9201--disk--0 /mnt ext4 rw 0 0\n",
|
||||||
|
"/mnt is the guest rootfs mount, not a drive — offering it would register the box's own root",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"the managed parent itself",
|
||||||
|
"/dev/mapper/pve-root /mnt/felhom-drives ext4 rw 0 0\n",
|
||||||
|
"the intermediary-model parent holds drives; it is not one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tmpfs",
|
||||||
|
"tmpfs /mnt/scratch tmpfs rw 0 0\n",
|
||||||
|
"a RAM filesystem would silently lose the customer's data on reboot",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"overlay",
|
||||||
|
"overlay /mnt/ovl overlay rw 0 0\n",
|
||||||
|
"not a real block device",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"outside /mnt",
|
||||||
|
"/dev/sdb1 /srv/data ext4 rw 0 0\n",
|
||||||
|
"the storage convention is /mnt/<name>; registering outside it is the manual-add path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"unsupported fs",
|
||||||
|
"/dev/sdb1 /mnt/win ntfs rw 0 0\n",
|
||||||
|
"ntfs is data-bearing but not one the stack hands to apps",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := mountedUnregisteredStores(c.table, map[string]bool{}); len(got) != 0 {
|
||||||
|
t.Errorf("offered %+v — %s", got, c.why)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FAIL-SAFE: an unreadable mount table yields nothing, never everything. Paired with the caller's
|
||||||
|
// non-empty assertion, "we could not look" renders as "we cannot offer this", never as a promise.
|
||||||
|
func TestMountedUnregisteredStores_UnreadableTableOffersNothing(t *testing.T) {
|
||||||
|
if got := mountedUnregisteredStores("", map[string]bool{}); len(got) != 0 {
|
||||||
|
t.Errorf("an unreadable mount table produced %+v — it must produce nothing", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mountpoint listed twice (bind / re-mount) must appear once, or the picker shows a duplicate.
|
||||||
|
func TestMountedUnregisteredStores_DeduplicatesMountpoints(t *testing.T) {
|
||||||
|
table := "/dev/sdb1 /mnt/data ext4 rw 0 0\n/dev/sdb1 /mnt/data ext4 rw,remount 0 0\n"
|
||||||
|
if got := mountedUnregisteredStores(table, map[string]bool{}); len(got) != 1 {
|
||||||
|
t.Errorf("got %d entries, want 1 — a re-mount must not double the picker row", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO C — nothing attachable, said plainly ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
// RED-PROOF: remove the `{{if .HasAttachDestination}}` conditional from backups_restore.html and this
|
||||||
|
// fails on the first assertion — the „két kattintás" promise returns over an empty picker, which is
|
||||||
|
// the exact sentence that cost the rehearsal its time.
|
||||||
|
func TestRestorePage_NothingAttachable_DoesNotPromiseTwoClicks(t *testing.T) {
|
||||||
|
d := restoreData()
|
||||||
|
d["NoRestoreDestination"] = true
|
||||||
|
d["HasAttachDestination"] = false
|
||||||
|
html := renderBackupPage(t, "backups_restore", d)
|
||||||
|
|
||||||
|
if strings.Contains(html, "két kattintás") {
|
||||||
|
t.Error("R-280: the page still promises „két kattintás" +
|
||||||
|
"\" while there is nothing to click — it is zero clicks, and the customer cannot get past it")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "Csatolható meghajtót viszont most nem látunk") {
|
||||||
|
t.Error("R-280: the page does not say plainly that there is nothing to attach")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, "üzemeltető") {
|
||||||
|
t.Error("R-280: a refusal with no route is the R-252 defect again — it must name what to do instead")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO A, rendered — the promise is kept only when it is true ─────────────────────────────
|
||||||
|
|
||||||
|
func TestRestorePage_Attachable_KeepsTheTwoClicksRoute(t *testing.T) {
|
||||||
|
d := restoreData()
|
||||||
|
d["NoRestoreDestination"] = true
|
||||||
|
d["HasAttachDestination"] = true
|
||||||
|
html := renderBackupPage(t, "backups_restore", d)
|
||||||
|
|
||||||
|
if !strings.Contains(html, "két kattintás") {
|
||||||
|
t.Error("with a real destination the original instruction must survive — this change narrows " +
|
||||||
|
"a false promise, it does not remove a true one")
|
||||||
|
}
|
||||||
|
if !strings.Contains(html, `href="/storage"`) {
|
||||||
|
t.Error("the instruction no longer routes to the picker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SCENARIO B — `initialize` is left exactly as it was ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The format wizard hides system and backup drives, and it says so on the page. That protection is a
|
||||||
|
// property of the agent's unclaimed scan, and the mounted-store source must never reach it.
|
||||||
|
//
|
||||||
|
// RED-PROOF: switch the initialize list to the new source too — in mergeAttachCandidates, add
|
||||||
|
// `resp.Initialize = append(resp.Initialize, mountedStoreCandidates(stores)...)`. This fails with the
|
||||||
|
// guest data volume offered for FORMATTING, which is the protection breaking in the open.
|
||||||
|
func TestMergeAttachCandidates_InitializeIsUntouched(t *testing.T) {
|
||||||
|
agentSaid := agentapi.CandidatesResult{
|
||||||
|
VMID: 9201,
|
||||||
|
Initialize: []agentapi.DiskCandidate{{Device: "/dev/sdd", FSType: ""}},
|
||||||
|
Attach: []agentapi.DiskCandidate{{Device: "/dev/sdd", MountSource: "/dev/sdd1", FSType: "ext4"}},
|
||||||
|
}
|
||||||
|
stores := []mountedStore{{Path: "/mnt/sys_drive", Device: "/dev/mapper/pve-vm--9201--disk--1", FSType: "ext4"}}
|
||||||
|
|
||||||
|
got := mergeAttachCandidates(agentSaid, stores)
|
||||||
|
|
||||||
|
// initialize: byte-for-byte the agent's list.
|
||||||
|
if len(got.Initialize) != 1 || got.Initialize[0].Device != "/dev/sdd" {
|
||||||
|
t.Fatalf("initialize was modified: %+v — the format wizard's system/backup protection lives "+
|
||||||
|
"in the agent's unclaimed scan, and widening it is how a customer is offered their own "+
|
||||||
|
"data drive to format", got.Initialize)
|
||||||
|
}
|
||||||
|
for _, c := range got.Initialize {
|
||||||
|
if c.AlreadyMounted {
|
||||||
|
t.Errorf("a mounted store reached the FORMAT list: %+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attach: the agent's entry survives (the fresh-USB case) AND the mounted store is added.
|
||||||
|
if len(got.Attach) != 2 {
|
||||||
|
t.Fatalf("attach has %d entries, want 2 (agent's + the mounted store) — got %+v", len(got.Attach), got.Attach)
|
||||||
|
}
|
||||||
|
var foundMounted bool
|
||||||
|
for _, c := range got.Attach {
|
||||||
|
if c.MountSource == "/mnt/sys_drive" {
|
||||||
|
foundMounted = true
|
||||||
|
if !c.AlreadyMounted {
|
||||||
|
t.Error("the mounted store is not flagged already_mounted — the wizard would send it " +
|
||||||
|
"down the device-attach path and try to mount an in-guest path as a raw device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMounted {
|
||||||
|
t.Error("the mounted store did not reach attach — this is the rebuilt box's empty picker")
|
||||||
|
}
|
||||||
|
if got.Attach[0].Device != "/dev/sdd" {
|
||||||
|
t.Error("the agent's own attach entry was dropped — a fresh external drive with a filesystem " +
|
||||||
|
"on it is exactly what this wizard was built for, and the mount table cannot report it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bind mount republishes a filesystem under a second path. A bind of the guest ROOTFS under
|
||||||
|
// /mnt/<name> is indistinguishable from a data drive by path alone — and registering it would put
|
||||||
|
// app data on the root filesystem.
|
||||||
|
//
|
||||||
|
// RED-PROOF: drop the `rootDevices[dev]` exclusion and this fails with /mnt/rootcopy offered.
|
||||||
|
func TestMountedUnregisteredStores_RootfsAliasIsNotOffered(t *testing.T) {
|
||||||
|
table := demoHPMounts + "/dev/mapper/pve-vm--9201--disk--0 /mnt/rootcopy ext4 rw 0 0\n"
|
||||||
|
got := mountedUnregisteredStores(table, map[string]bool{})
|
||||||
|
|
||||||
|
for _, m := range got {
|
||||||
|
if m.Path == "/mnt/rootcopy" {
|
||||||
|
t.Error("a bind of the guest rootfs was offered as an attachable store — registering it " +
|
||||||
|
"would store the customer's app data on the box's own root filesystem")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The real drive on a DIFFERENT device must survive the new exclusion.
|
||||||
|
if len(got) != 1 || got[0].Path != "/mnt/sys_drive" {
|
||||||
|
t.Errorf("the genuine data volume was lost to the rootfs guard: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1053,6 +1053,11 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
|
// predicate so the page and the resolver cannot disagree. FALSE on a healthy box, where the
|
||||||
// template renders exactly as before (Scenario E).
|
// template renders exactly as before (Scenario E).
|
||||||
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
|
data["NoRestoreDestination"] = !s.backupMgr.HasRestoreDestination()
|
||||||
|
// R-280: the notice above told the customer this was „két kattintás" and pointed at a picker
|
||||||
|
// that was empty, so it was zero clicks. The promise is now conditional on the destination it
|
||||||
|
// points at actually having something in it — and when it does not, the page says so and names
|
||||||
|
// what to do instead. Same derivation the picker uses, so the two cannot disagree.
|
||||||
|
data["HasAttachDestination"] = len(s.attachableStores()) > 0
|
||||||
}
|
}
|
||||||
s.executeTemplate(w, r, "backups_restore", data)
|
s.executeTemplate(w, r, "backups_restore", data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleStorageImpact(w, r)
|
s.handleStorageImpact(w, r)
|
||||||
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
|
case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
|
||||||
s.handleStorageRegister(w, r)
|
s.handleStorageRegister(w, r)
|
||||||
|
case r.URL.Path == "/api/storage/register-mounted" && r.Method == http.MethodPost:
|
||||||
|
s.handleStorageRegisterMounted(w, r)
|
||||||
// E-2 Parts 3+4. State drives the degraded banner and the OFFER; assign is the offer's
|
// E-2 Parts 3+4. State drives the degraded banner and the OFFER; assign is the offer's
|
||||||
// ACCEPTANCE and the ONLY writer of the role — registration above deliberately does not set it.
|
// ACCEPTANCE and the ONLY writer of the role — registration above deliberately does not set it.
|
||||||
case r.URL.Path == "/api/storage/backup-target" && r.Method == http.MethodGet:
|
case r.URL.Path == "/api/storage/backup-target" && r.Method == http.MethodGet:
|
||||||
@@ -865,6 +867,53 @@ func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": stable, "raw": req.Where})
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": stable, "raw": req.Where})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleStorageRegisterMounted registers an ALREADY-mounted, unregistered filesystem verbatim
|
||||||
|
// (R-280). It is the action behind an `already_mounted` attach candidate, and it is the route the
|
||||||
|
// rebuilt demo-hp needed: the escape hatch that unblocked that box registered `/mnt/sys_drive`, an
|
||||||
|
// in-guest path, and nothing in the dashboard offered it.
|
||||||
|
//
|
||||||
|
// It differs from handleStorageRegister deliberately: that one takes the agent's RAW /mnt/<name> host
|
||||||
|
// mount and registers the STABLE /mnt/felhom-drives/<name> the intermediary model binds it to. These
|
||||||
|
// candidates are not agent drives and have no stable twin — the mountpoint IS the path to register,
|
||||||
|
// so translating it would register a directory that does not exist.
|
||||||
|
//
|
||||||
|
// The posted path is NOT trusted: it is matched against the freshly re-derived set of mounted,
|
||||||
|
// unregistered filesystems. A path that is not currently offered is refused, so this cannot be used
|
||||||
|
// to register an arbitrary directory.
|
||||||
|
func (s *Server) handleStorageRegisterMounted(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
SetDefault bool `json:"set_default"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
want := path.Clean(strings.TrimSpace(req.Path))
|
||||||
|
var match *mountedStore
|
||||||
|
for _, m := range s.attachableStores() {
|
||||||
|
if m.Path == want {
|
||||||
|
found := m
|
||||||
|
match = &found
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match == nil {
|
||||||
|
// Names a reason the customer can act on, and a route — never a bare refusal.
|
||||||
|
writeDiskJSON(w, http.StatusBadRequest, false,
|
||||||
|
"Ez a meghajtó most nem csatolható — lehet, hogy már regisztrálva van, vagy időközben lecsatolódott. Frissítsd az oldalt, és nézd meg a Tárhely → Meghajtók listát.", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.registerStoragePath(match.Path, req.Label, req.SetDefault); err != nil {
|
||||||
|
s.logger.Printf("[WARN] [web] mounted-store register %s failed: %v", match.Path, err)
|
||||||
|
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.logger.Printf("[INFO] [web] storage path registered (already-mounted store): %s", match.Path)
|
||||||
|
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": match.Path})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
|
||||||
var req storageProvReq
|
var req storageProvReq
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
|||||||
@@ -71,12 +71,24 @@
|
|||||||
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
|
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
|
||||||
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
|
<!-- R-252: the precondition a rebuilt box fails, said BEFORE the customer presses a button that
|
||||||
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
|
would refuse. Rendered only when it is true — a healthy box sees nothing new here. -->
|
||||||
|
<!-- R-280: the „két kattintás" promise is conditional on the picker it points at being non-empty.
|
||||||
|
It was printed unconditionally, and on a rebuilt box the picker had nothing in it — the
|
||||||
|
sentence sent the customer to an empty page and the wall had no way past it. The false branch
|
||||||
|
says what is true and names a route, rather than promising a click that does not exist. -->
|
||||||
{{if .NoRestoreDestination}}
|
{{if .NoRestoreDestination}}
|
||||||
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
|
<p class="form-hint" style="border-left:2px solid var(--amber);padding-left:.75rem">
|
||||||
<strong>Előbb csatold vissza az adatmeghajtót.</strong> A mentéseid megvannak, és a meghajtók is
|
<strong>Előbb csatold vissza az adatmeghajtót.</strong> A mentéseid megvannak, és a meghajtók is
|
||||||
megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová
|
megvannak — újratelepítés után viszont a gép még nem ismeri őket, ezért most nincs hová
|
||||||
visszaállítani. Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
|
visszaállítani.
|
||||||
|
{{if .HasAttachDestination}}
|
||||||
|
Ez két kattintás: <a href="/storage" style="color:var(--blue)">Tárhely →
|
||||||
Meghajtók</a>, „Meglévő meghajtó csatolása". Utána gyere vissza ide.
|
Meghajtók</a>, „Meglévő meghajtó csatolása". Utána gyere vissza ide.
|
||||||
|
{{else}}
|
||||||
|
Csatolható meghajtót viszont most nem látunk ezen a gépen, ezért ezt innen nem tudod
|
||||||
|
elindítani. Ha a meghajtó be van kötve, de nincs csatlakoztatva, az üzemeltető tudja
|
||||||
|
csatlakoztatni — jelezd neki. A <a href="/storage" style="color:var(--blue)">Tárhely →
|
||||||
|
Meghajtók</a> oldalon látod, mit ismer most a gép.
|
||||||
|
{{end}}
|
||||||
</p>
|
</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .OffsiteStoreState "unreadable"}}
|
{{if eq .OffsiteStoreState "unreadable"}}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@
|
|||||||
<label>Kiválasztott eszköz</label>
|
<label>Kiválasztott eszköz</label>
|
||||||
<span class="settings-value mono" id="sel-device">—</span>
|
<span class="settings-value mono" id="sel-device">—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<!-- R-280: an already-mounted store has no mount name to choose — it is already at its path,
|
||||||
|
and the action is to register that path. The group is hidden for those. -->
|
||||||
|
<div class="form-group" id="mount-name-group">
|
||||||
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
|
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
|
||||||
<div style="display:flex;align-items:center;gap:.25rem">
|
<div style="display:flex;align-items:center;gap:.25rem">
|
||||||
<span class="mono" style="opacity:.6">/mnt/</span>
|
<span class="mono" style="opacity:.6">/mnt/</span>
|
||||||
@@ -32,6 +34,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="form-hint">A meghajtó a /mnt/<név> útvonalra kerül.</span>
|
<span class="form-hint">A meghajtó a /mnt/<név> útvonalra kerül.</span>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="form-hint" id="mounted-note" style="display:none">
|
||||||
|
Ez a meghajtó már csatlakoztatva van, csak a gép nem tartja nyilván. A „Csatolás" a
|
||||||
|
meglévő helyén veszi nyilvántartásba — <strong>semmi nem törlődik</strong>.
|
||||||
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="storage-label">Megnevezés</label>
|
<label for="storage-label">Megnevezés</label>
|
||||||
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
|
<input type="text" id="storage-label" class="form-control" placeholder="Külső HDD 1TB" maxlength="50">
|
||||||
@@ -49,7 +55,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var selDevice = "", selFSType = "";
|
var selDevice = "", selFSType = "", selMounted = false;
|
||||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,function(c){return {'&':'&','<':'<','>':'>','"':'"'}[c];}); }
|
||||||
function fmtSize(b){
|
function fmtSize(b){
|
||||||
if(!b) return '';
|
if(!b) return '';
|
||||||
@@ -74,11 +80,15 @@ async function loadDisks(){
|
|||||||
if(d.fstype) parts.push(esc(d.fstype));
|
if(d.fstype) parts.push(esc(d.fstype));
|
||||||
var sub = parts.join(' · ');
|
var sub = parts.join(' · ');
|
||||||
// Attach the FS-bearing node (mount_source, e.g. /dev/sdd1); the agent resolves its UUID.
|
// Attach the FS-bearing node (mount_source, e.g. /dev/sdd1); the agent resolves its UUID.
|
||||||
|
// R-280: for an already-mounted store mount_source is its MOUNTPOINT (/mnt/sys_drive) and the
|
||||||
|
// action is register-in-place, so the card is labelled by the path the customer will see.
|
||||||
var dev = d.mount_source || d.device;
|
var dev = d.mount_source || d.device;
|
||||||
|
var mounted = !!d.already_mounted;
|
||||||
|
if(mounted){ title = esc(d.mount_source); sub = [esc(d.device), esc(d.fstype)].filter(Boolean).join(' · '); }
|
||||||
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
html+='<label class="drive-card role-user-data is-selectable" id="dc-'+i+'">'
|
||||||
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" onchange="pickDisk(this)">'
|
+'<div class="drive-card-top"><div class="drive-select"><input type="radio" name="disk" value="'+esc(dev)+'" data-fs="'+esc(d.fstype)+'" data-i="'+i+'" data-mounted="'+(mounted?'1':'')+'" onchange="pickDisk(this)">'
|
||||||
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
+'<div class="drive-id"><span class="drive-name">'+title+'</span><span class="drive-sub">'+sub+'</span></div></div>'
|
||||||
+'<div class="drive-badges"><span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span></div></div></label>';
|
+'<div class="drive-badges">'+(mounted?'<span class="badge badge-ok">Már csatlakoztatva</span>':'<span class="badge badge-ok">Fájlrendszer: '+esc(d.fstype)+'</span>')+'</div></div></label>';
|
||||||
});
|
});
|
||||||
html+='</div>';
|
html+='</div>';
|
||||||
document.getElementById('disk-list').innerHTML=html;
|
document.getElementById('disk-list').innerHTML=html;
|
||||||
@@ -87,7 +97,14 @@ async function loadDisks(){
|
|||||||
|
|
||||||
function pickDisk(radio){
|
function pickDisk(radio){
|
||||||
selDevice=radio.value; selFSType=radio.getAttribute('data-fs')||"";
|
selDevice=radio.value; selFSType=radio.getAttribute('data-fs')||"";
|
||||||
|
selMounted=!!radio.getAttribute('data-mounted');
|
||||||
document.getElementById('sel-device').textContent=selDevice;
|
document.getElementById('sel-device').textContent=selDevice;
|
||||||
|
// An already-mounted store keeps its own path; there is no name to pick. Dropping `required` too,
|
||||||
|
// or the hidden empty field blocks form submission with no visible cause.
|
||||||
|
var mng=document.getElementById('mount-name-group'), mni=document.getElementById('mount-name');
|
||||||
|
mng.style.display = selMounted ? 'none' : '';
|
||||||
|
mni.required = !selMounted;
|
||||||
|
document.getElementById('mounted-note').style.display = selMounted ? '' : 'none';
|
||||||
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
|
document.querySelectorAll('.drive-card').forEach(function(c){c.classList.remove('is-picked');});
|
||||||
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
|
var card=document.getElementById('dc-'+radio.getAttribute('data-i')); if(card) card.classList.add('is-picked');
|
||||||
document.getElementById('cfg-card').style.display='block';
|
document.getElementById('cfg-card').style.display='block';
|
||||||
@@ -99,12 +116,22 @@ async function submitAttach(ev){
|
|||||||
var btn=document.getElementById('attach-btn'); var out=document.getElementById('attach-result');
|
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>';
|
btn.disabled=true; out.innerHTML='<p class="form-hint">Csatlakoztatás folyamatban…</p>';
|
||||||
try{
|
try{
|
||||||
var body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
|
// R-280: an already-mounted store is REGISTERED in place. Sending it to /api/storage/attach would
|
||||||
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
|
// ask the agent to mount an in-guest path as if it were a raw device.
|
||||||
var r=await fetch('/api/storage/attach',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
|
var url, body;
|
||||||
|
if(selMounted){
|
||||||
|
url='/api/storage/register-mounted';
|
||||||
|
body={path:selDevice, label:document.getElementById('storage-label').value,
|
||||||
|
set_default:document.getElementById('set-default').checked};
|
||||||
|
}else{
|
||||||
|
url='/api/storage/attach';
|
||||||
|
body={device:selDevice, fstype:selFSType, mount_name:document.getElementById('mount-name').value,
|
||||||
|
label:document.getElementById('storage-label').value, set_default:document.getElementById('set-default').checked};
|
||||||
|
}
|
||||||
|
var r=await fetch(url,{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify(body)});
|
||||||
var j=await r.json();
|
var j=await r.json();
|
||||||
if(!j.ok){ throw new Error(j.error||'Hiba'); }
|
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>';
|
out.innerHTML='<div class="alert alert-success">A meghajtó sikeresen '+(selMounted?'nyilvántartásba véve':'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; }
|
}catch(e){ out.innerHTML='<div class="alert alert-error">Hiba: '+e.message+'</div>'; btn.disabled=false; }
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user