v0.15.0: Attach existing drive wizard (bind mount, no format)
New Settings wizard to attach drives with existing filesystems without formatting. Mounts partition at staging path, lets user browse and pick a subfolder, then bind-mounts it at /mnt/<name> with fstab entries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,17 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### What was just completed (2026-02-18 session 50)
|
||||||
|
- **v0.15.0 — Attach Existing Drive (bind mount wizard):**
|
||||||
|
|
||||||
|
New feature: Settings → "Meglévő meghajtó csatolása" wizard. Allows attaching a drive that already has a filesystem (ext4, etc.) without formatting. Solves the real-world scenario where a customer's drive contains existing data that must be preserved.
|
||||||
|
|
||||||
|
**How it works:** The partition is mounted read-only at a hidden staging path (`/mnt/.felhom-raw/<label>`). A directory browser lets the user navigate the drive's contents and create a new folder. The selected folder is bind-mounted at `/mnt/<hdd-name>`, keeping the controller's data isolated from existing files. Two fstab entries (raw + bind, both with `nofail`) ensure the mount survives reboots.
|
||||||
|
|
||||||
|
**Wizard flow:** Scan → Select partition (only shows partitions with existing FS) → Mount raw + Browse directories → Create folder if needed → Configure mount name + label → Finalize (bind mount + fstab + permissions + register). Cancel cleans up the temp mount.
|
||||||
|
|
||||||
|
**New files (4):** `internal/storage/attach.go`, `internal/storage/attach_linux.go`, `internal/storage/attach_other.go`, `internal/web/templates/storage_attach.html`
|
||||||
|
**Modified files (3):** `internal/web/storage_handlers.go` (6 new API handlers), `internal/web/server.go` (route + activeRawMount field), `internal/web/templates/settings.html` (button)
|
||||||
|
|
||||||
### What was just completed (2026-02-18 session 49)
|
### What was just completed (2026-02-18 session 49)
|
||||||
- **v0.14.2 — Backup Bug Fixes (4 fixes from code review):**
|
- **v0.14.2 — Backup Bug Fixes (4 fixes from code review):**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
// AttachRequest holds parameters for attaching an existing partition
|
||||||
|
// without formatting — using a bind mount from a subfolder.
|
||||||
|
type AttachRequest struct {
|
||||||
|
DevicePath string // "/dev/sdb1" — must have an existing filesystem
|
||||||
|
MountName string // "hdd_1" → bind-mounts at /mnt/hdd_1
|
||||||
|
SubPath string // full path on raw mount to bind-mount (e.g., "/mnt/.felhom-raw/hdd_1/felhom_data")
|
||||||
|
Label string // Display label for the UI
|
||||||
|
SetDefault bool // Register as default storage path
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirEntry represents a directory entry returned by ListDirectories.
|
||||||
|
type DirEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
HasChildren bool `json:"has_children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawMountBase is the hidden staging directory where partitions are
|
||||||
|
// temporarily mounted for browsing before the final bind mount.
|
||||||
|
const RawMountBase = "/mnt/.felhom-raw"
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MountRaw temporarily mounts a partition at a staging path for browsing.
|
||||||
|
// The partition is mounted read-only so the user can inspect its contents
|
||||||
|
// before choosing a subfolder for the final bind mount.
|
||||||
|
// Returns the raw mount path (e.g., "/mnt/.felhom-raw/hdd_1").
|
||||||
|
func MountRaw(devicePath string) (string, error) {
|
||||||
|
// --- Validate device path ---
|
||||||
|
if !strings.HasPrefix(devicePath, "/dev/") {
|
||||||
|
return "", fmt.Errorf("érvénytelen eszközútvonal: /dev/-vel kell kezdődnie")
|
||||||
|
}
|
||||||
|
if strings.Contains(devicePath, "..") {
|
||||||
|
return "", fmt.Errorf("érvénytelen eszközútvonal: nem tartalmazhat ..-t")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(HostDevicePath(devicePath)); err != nil {
|
||||||
|
return "", fmt.Errorf("az eszköz nem létezik: %s", devicePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
isSystem, err := IsSystemDisk(devicePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("rendszermeghajtó ellenőrzése sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
if isSystem {
|
||||||
|
return "", fmt.Errorf("ez a rendszermeghajtó — nem csatolható")
|
||||||
|
}
|
||||||
|
|
||||||
|
mounted, err := IsDeviceMounted(devicePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("csatlakoztatási állapot ellenőrzése sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
if mounted {
|
||||||
|
return "", fmt.Errorf("az eszköz már csatlakoztatva van")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detect filesystem ---
|
||||||
|
fsType, err := getBlkidValue(devicePath, "TYPE")
|
||||||
|
if err != nil || fsType == "" {
|
||||||
|
return "", fmt.Errorf("nincs fájlrendszer az eszközön (%s) — használja az inicializálás varázslót", devicePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get label for naming the raw mount directory
|
||||||
|
label, _ := getBlkidValue(devicePath, "LABEL")
|
||||||
|
uuid, _ := getBlkidValue(devicePath, "UUID")
|
||||||
|
|
||||||
|
// Choose a directory name: prefer label, fall back to UUID prefix
|
||||||
|
dirName := label
|
||||||
|
if dirName == "" && uuid != "" {
|
||||||
|
dirName = uuid[:8] // use first 8 chars of UUID
|
||||||
|
}
|
||||||
|
if dirName == "" {
|
||||||
|
dirName = filepath.Base(devicePath) // "sdb1"
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPath := filepath.Join(RawMountBase, dirName)
|
||||||
|
|
||||||
|
// Check if already raw-mounted (idempotent)
|
||||||
|
if inUse, _ := IsMountPathInUse(rawPath); inUse {
|
||||||
|
return rawPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create staging directory
|
||||||
|
if err := os.MkdirAll(rawPath, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("nem hozható létre a staging mappa: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount read-only for browsing
|
||||||
|
if out, err := exec.Command("mount", "-t", fsType, "-o", "defaults,noatime,ro",
|
||||||
|
HostDevicePath(devicePath), rawPath).CombinedOutput(); err != nil {
|
||||||
|
os.Remove(rawPath)
|
||||||
|
return "", fmt.Errorf("csatlakoztatás sikertelen: %s — %w", string(out), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDirectories returns the subdirectories at the given path.
|
||||||
|
// Only directories are returned; files, symlinks, and "lost+found" are excluded.
|
||||||
|
func ListDirectories(basePath string) ([]DirEntry, error) {
|
||||||
|
// Security: only allow browsing under the raw mount staging area
|
||||||
|
cleanPath := filepath.Clean(basePath)
|
||||||
|
if !strings.HasPrefix(cleanPath, RawMountBase) {
|
||||||
|
return nil, fmt.Errorf("érvénytelen útvonal: csak %s alatti mappák böngészhetők", RawMountBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("mappa olvasása sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []DirEntry
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
// Skip lost+found and hidden directories
|
||||||
|
if name == "lost+found" || strings.HasPrefix(name, ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(cleanPath, name)
|
||||||
|
|
||||||
|
// Check if this directory has subdirectories
|
||||||
|
hasChildren := false
|
||||||
|
if subEntries, err := os.ReadDir(fullPath); err == nil {
|
||||||
|
for _, se := range subEntries {
|
||||||
|
if se.IsDir() && se.Name() != "lost+found" && !strings.HasPrefix(se.Name(), ".") {
|
||||||
|
hasChildren = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs = append(dirs, DirEntry{
|
||||||
|
Name: name,
|
||||||
|
Path: fullPath,
|
||||||
|
HasChildren: hasChildren,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return dirs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDirectory creates a new directory at basePath/name.
|
||||||
|
// The raw mount is remounted read-write if needed.
|
||||||
|
func CreateDirectory(basePath, name string) (string, error) {
|
||||||
|
// Security: only allow creation under the raw mount staging area
|
||||||
|
cleanBase := filepath.Clean(basePath)
|
||||||
|
if !strings.HasPrefix(cleanBase, RawMountBase) {
|
||||||
|
return "", fmt.Errorf("érvénytelen útvonal: csak %s alatti mappák módosíthatók", RawMountBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate directory name (same rules as mount names)
|
||||||
|
if err := ValidateMountName(name); err != nil {
|
||||||
|
return "", fmt.Errorf("érvénytelen mappanév: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPath := filepath.Join(cleanBase, name)
|
||||||
|
|
||||||
|
// Check if already exists
|
||||||
|
if fi, err := os.Stat(targetPath); err == nil {
|
||||||
|
if fi.IsDir() {
|
||||||
|
return targetPath, nil // already exists, idempotent
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("a cél már létezik és nem mappa")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remount read-write (the raw mount is initially read-only)
|
||||||
|
rawMountPoint := findRawMountPoint(cleanBase)
|
||||||
|
if rawMountPoint != "" {
|
||||||
|
_ = exec.Command("mount", "-o", "remount,rw", rawMountPoint).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(targetPath, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("mappa létrehozása sikertelen: %w", err)
|
||||||
|
}
|
||||||
|
_ = exec.Command("chown", "1000:1000", targetPath).Run()
|
||||||
|
|
||||||
|
return targetPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeAttach creates the bind mount, fstab entries, and sets up permissions.
|
||||||
|
// Progress updates are sent on the progress channel.
|
||||||
|
// Returns the final mount path (/mnt/<name>) on success.
|
||||||
|
func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string, error) {
|
||||||
|
send := func(step, msg string, pct int) {
|
||||||
|
progress <- FormatProgress{Step: step, Message: msg, Percent: pct}
|
||||||
|
}
|
||||||
|
fail := func(step, msg string, err error) error {
|
||||||
|
errStr := ""
|
||||||
|
if err != nil {
|
||||||
|
errStr = err.Error()
|
||||||
|
}
|
||||||
|
progress <- FormatProgress{Step: "error", Message: msg, Error: errStr, Percent: 0}
|
||||||
|
return fmt.Errorf("%s: %w", msg, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mountPath := "/mnt/" + req.MountName
|
||||||
|
|
||||||
|
// --- Step 1: Validate ---
|
||||||
|
send("validating", "Paraméterek ellenőrzése...", 5)
|
||||||
|
|
||||||
|
if err := ValidateMountName(req.MountName); err != nil {
|
||||||
|
return "", fail("validating", "Érvénytelen csatlakoztatási név", err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(req.DevicePath, "/dev/") {
|
||||||
|
return "", fail("validating", "Érvénytelen eszközútvonal", fmt.Errorf("must start with /dev/"))
|
||||||
|
}
|
||||||
|
if strings.Contains(req.DevicePath, "..") {
|
||||||
|
return "", fail("validating", "Érvénytelen eszközútvonal", fmt.Errorf("must not contain .."))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subpath is under the raw mount area
|
||||||
|
cleanSub := filepath.Clean(req.SubPath)
|
||||||
|
if !strings.HasPrefix(cleanSub, RawMountBase) {
|
||||||
|
return "", fail("validating", "Érvénytelen almappa útvonal", fmt.Errorf("subpath must be under %s", RawMountBase))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(cleanSub); err != nil {
|
||||||
|
return "", fail("validating", "Az almappa nem létezik: "+cleanSub, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inUse, err := IsMountPathInUse(mountPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fail("validating", "Csatlakoztatási útvonal ellenőrzése sikertelen", err)
|
||||||
|
}
|
||||||
|
if inUse {
|
||||||
|
return "", fail("validating", "A csatlakoztatási útvonal már használatban van: "+mountPath, fmt.Errorf("mount path in use"))
|
||||||
|
}
|
||||||
|
|
||||||
|
send("validating", "Ellenőrzés kész", 15)
|
||||||
|
|
||||||
|
// --- Step 2: Ensure raw mount is read-write ---
|
||||||
|
send("mounting", "Fájlrendszer előkészítése...", 20)
|
||||||
|
|
||||||
|
rawMountPoint := findRawMountPoint(cleanSub)
|
||||||
|
if rawMountPoint != "" {
|
||||||
|
_ = exec.Command("mount", "-o", "remount,rw", rawMountPoint).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Get device info for fstab ---
|
||||||
|
fsType, _ := getBlkidValue(req.DevicePath, "TYPE")
|
||||||
|
if fsType == "" {
|
||||||
|
fsType = "ext4" // fallback
|
||||||
|
}
|
||||||
|
uuid, err := getBlkidValue(req.DevicePath, "UUID")
|
||||||
|
if err != nil || uuid == "" {
|
||||||
|
return "", fail("mounting", "UUID lekérése sikertelen", fmt.Errorf("empty UUID for %s", req.DevicePath))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the raw mount directory name (the direct child of RawMountBase)
|
||||||
|
relFromBase := strings.TrimPrefix(cleanSub, RawMountBase+"/")
|
||||||
|
rawDirName := strings.SplitN(relFromBase, "/", 2)[0]
|
||||||
|
rawMountPath := filepath.Join(RawMountBase, rawDirName)
|
||||||
|
|
||||||
|
// Determine the subfolder relative to the raw mount
|
||||||
|
subRel := strings.TrimPrefix(cleanSub, rawMountPath)
|
||||||
|
subRel = strings.TrimPrefix(subRel, "/")
|
||||||
|
|
||||||
|
send("mounting", "fstab bejegyzések hozzáadása...", 35)
|
||||||
|
|
||||||
|
// Backup fstab (non-fatal)
|
||||||
|
_ = BackupFstab(FstabPath)
|
||||||
|
|
||||||
|
// Fstab entry 1: raw partition mount
|
||||||
|
// Use nofail so a missing disk doesn't block boot
|
||||||
|
if err := AppendFstabEntry(FstabPath, uuid, rawMountPath, fsType, "defaults,nofail,noatime"); err != nil {
|
||||||
|
return "", fail("mounting", "fstab bejegyzés hozzáadása sikertelen (raw mount)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fstab entry 2: bind mount from subfolder to final path
|
||||||
|
bindSource := cleanSub
|
||||||
|
if err := appendBindFstabEntry(FstabPath, bindSource, mountPath); err != nil {
|
||||||
|
// Roll back the raw mount fstab entry
|
||||||
|
_ = RemoveFstabEntry(FstabPath, uuid)
|
||||||
|
return "", fail("mounting", "fstab bejegyzés hozzáadása sikertelen (bind mount)", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 4: Create bind mount ---
|
||||||
|
send("mounting", fmt.Sprintf("Bind mount: %s → %s ...", cleanSub, mountPath), 50)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(mountPath, 0755); err != nil {
|
||||||
|
_ = RemoveFstabEntry(FstabPath, uuid)
|
||||||
|
_ = removeBindFstabEntry(FstabPath, mountPath)
|
||||||
|
return "", fail("mounting", "Csatlakoztatási mappa nem hozható létre: "+mountPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if out, err := exec.Command("mount", "--bind", cleanSub, mountPath).CombinedOutput(); err != nil {
|
||||||
|
_ = RemoveFstabEntry(FstabPath, uuid)
|
||||||
|
_ = removeBindFstabEntry(FstabPath, mountPath)
|
||||||
|
return "", fail("mounting", "Bind mount sikertelen: "+string(out), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bind mount
|
||||||
|
verifyOut, verifyErr := exec.Command("findmnt", "-n", "-o", "SOURCE", "--target", mountPath).Output()
|
||||||
|
if verifyErr != nil || strings.TrimSpace(string(verifyOut)) == "" {
|
||||||
|
_ = exec.Command("umount", mountPath).Run()
|
||||||
|
_ = RemoveFstabEntry(FstabPath, uuid)
|
||||||
|
_ = removeBindFstabEntry(FstabPath, mountPath)
|
||||||
|
return "", fail("mounting", "A bind mount nem ellenőrizhető", fmt.Errorf("mount point %s not found after bind mount", mountPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
send("mounting", "Csatlakoztatva: "+mountPath, 70)
|
||||||
|
|
||||||
|
// --- Step 5: Permissions + subdirs ---
|
||||||
|
send("permissions", "Mappák létrehozása és jogosultságok beállítása...", 80)
|
||||||
|
|
||||||
|
_ = exec.Command("chown", "1000:1000", mountPath).Run()
|
||||||
|
|
||||||
|
for _, subdir := range []string{"storage", "Dokumentumok"} {
|
||||||
|
dir := filepath.Join(mountPath, subdir)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err == nil {
|
||||||
|
_ = exec.Command("chown", "1000:1000", dir).Run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send("done", "Meghajtó sikeresen csatolva: "+mountPath, 100)
|
||||||
|
|
||||||
|
return mountPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupRawMount unmounts a staging raw mount and removes its directory.
|
||||||
|
// Called when the user cancels the attach wizard.
|
||||||
|
func CleanupRawMount(rawPath string) error {
|
||||||
|
cleanPath := filepath.Clean(rawPath)
|
||||||
|
if !strings.HasPrefix(cleanPath, RawMountBase) {
|
||||||
|
return fmt.Errorf("érvénytelen útvonal: csak %s alatti csatlakozások távolíthatók el", RawMountBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmount
|
||||||
|
_ = exec.Command("umount", cleanPath).Run()
|
||||||
|
|
||||||
|
// Remove empty directory
|
||||||
|
_ = os.Remove(cleanPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
// getBlkidValue runs blkid to get a single value (TYPE, UUID, LABEL) for a device.
|
||||||
|
func getBlkidValue(devicePath, tag string) (string, error) {
|
||||||
|
out, err := exec.Command("blkid", "-o", "value", "-s", tag, HostDevicePath(devicePath)).Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findRawMountPoint finds the mount point for a path under RawMountBase.
|
||||||
|
// E.g., for "/mnt/.felhom-raw/hdd_1/some/sub" it returns "/mnt/.felhom-raw/hdd_1".
|
||||||
|
func findRawMountPoint(path string) string {
|
||||||
|
cleanPath := filepath.Clean(path)
|
||||||
|
if !strings.HasPrefix(cleanPath, RawMountBase+"/") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rel := strings.TrimPrefix(cleanPath, RawMountBase+"/")
|
||||||
|
parts := strings.SplitN(rel, "/", 2)
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(RawMountBase, parts[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendBindFstabEntry appends a bind mount fstab entry.
|
||||||
|
func appendBindFstabEntry(fstabPath, source, target string) error {
|
||||||
|
existing, err := os.ReadFile(fstabPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot read fstab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := fmt.Sprintf("\n# Bind mount (auto-generated by felhom-controller)\n%s\t%s\tnone\tbind,nofail\t0 0\n", source, target)
|
||||||
|
newContent := append(existing, []byte(entry)...)
|
||||||
|
|
||||||
|
tmpPath := fstabPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, newContent, 0644); err != nil {
|
||||||
|
return fmt.Errorf("cannot write fstab tmp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, fstabPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeBindFstabEntry removes the bind mount fstab entry for the given target mount path.
|
||||||
|
func removeBindFstabEntry(fstabPath, targetMountPath string) error {
|
||||||
|
data, err := os.ReadFile(fstabPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read fstab: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
var kept []string
|
||||||
|
for i := 0; i < len(lines); i++ {
|
||||||
|
line := lines[i]
|
||||||
|
// Remove both the comment line and the bind mount line
|
||||||
|
if strings.Contains(line, "Bind mount (auto-generated by felhom-controller)") {
|
||||||
|
// Check if the next line is the actual bind entry for this target
|
||||||
|
if i+1 < len(lines) && strings.Contains(lines[i+1], targetMountPath) {
|
||||||
|
i++ // skip the bind line too
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(line, targetMountPath) && strings.Contains(line, "bind") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
newContent := strings.Join(kept, "\n")
|
||||||
|
tmpPath := fstabPath + ".tmp"
|
||||||
|
if err := os.WriteFile(tmpPath, []byte(newContent), 0644); err != nil {
|
||||||
|
return fmt.Errorf("cannot write fstab tmp file: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmpPath, fstabPath); err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("cannot rename fstab tmp file: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// MountRaw is not supported on non-Linux platforms.
|
||||||
|
func MountRaw(devicePath string) (string, error) {
|
||||||
|
return "", fmt.Errorf("storage attach is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDirectories is not supported on non-Linux platforms.
|
||||||
|
func ListDirectories(basePath string) ([]DirEntry, error) {
|
||||||
|
return nil, fmt.Errorf("storage attach is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDirectory is not supported on non-Linux platforms.
|
||||||
|
func CreateDirectory(basePath, name string) (string, error) {
|
||||||
|
return "", fmt.Errorf("storage attach is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinalizeAttach is not supported on non-Linux platforms.
|
||||||
|
func FinalizeAttach(req AttachRequest, progress chan<- FormatProgress) (string, error) {
|
||||||
|
return "", fmt.Errorf("storage attach is only supported on Linux")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupRawMount is not supported on non-Linux platforms.
|
||||||
|
func CleanupRawMount(rawPath string) error {
|
||||||
|
return fmt.Errorf("storage attach is only supported on Linux")
|
||||||
|
}
|
||||||
@@ -40,6 +40,9 @@ type Server struct {
|
|||||||
// Disk operation state (format/migrate jobs)
|
// Disk operation state (format/migrate jobs)
|
||||||
diskJobMu sync.Mutex
|
diskJobMu sync.Mutex
|
||||||
diskJob *activeDiskJob
|
diskJob *activeDiskJob
|
||||||
|
|
||||||
|
// Active raw mount for the attach wizard (empty when not in use)
|
||||||
|
activeRawMount string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, logger *log.Logger, version string) *Server {
|
||||||
@@ -117,6 +120,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.backupRestoreHandler(w, r)
|
s.backupRestoreHandler(w, r)
|
||||||
case path == "/settings/storage/init":
|
case path == "/settings/storage/init":
|
||||||
s.storageInitHandler(w, r)
|
s.storageInitHandler(w, r)
|
||||||
|
case path == "/settings/storage/attach":
|
||||||
|
s.storageAttachHandler(w, r)
|
||||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
|
||||||
name := strings.TrimPrefix(path, "/stacks/")
|
name := strings.TrimPrefix(path, "/stacks/")
|
||||||
name = strings.TrimSuffix(name, "/migrate")
|
name = strings.TrimSuffix(name, "/migrate")
|
||||||
|
|||||||
@@ -142,6 +142,18 @@ func (s *Server) storageAPIHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.storageMigrateStatusAPIHandler(w, r)
|
s.storageMigrateStatusAPIHandler(w, r)
|
||||||
case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
|
case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
|
||||||
s.staleDataCleanupHandler(w, r)
|
s.staleDataCleanupHandler(w, r)
|
||||||
|
case path == "/api/storage/attach/mount-raw" && r.Method == http.MethodPost:
|
||||||
|
s.storageAttachMountRawHandler(w, r)
|
||||||
|
case path == "/api/storage/attach/browse" && r.Method == http.MethodGet:
|
||||||
|
s.storageAttachBrowseHandler(w, r)
|
||||||
|
case path == "/api/storage/attach/mkdir" && r.Method == http.MethodPost:
|
||||||
|
s.storageAttachMkdirHandler(w, r)
|
||||||
|
case path == "/api/storage/attach" && r.Method == http.MethodPost:
|
||||||
|
s.storageAttachAPIHandler(w, r)
|
||||||
|
case path == "/api/storage/attach/status" && r.Method == http.MethodGet:
|
||||||
|
s.storageAttachStatusAPIHandler(w, r)
|
||||||
|
case path == "/api/storage/attach/cancel" && r.Method == http.MethodPost:
|
||||||
|
s.storageAttachCancelHandler(w, r)
|
||||||
default:
|
default:
|
||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
}
|
}
|
||||||
@@ -823,3 +835,258 @@ func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
"errors": errors,
|
"errors": errors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Attach Existing Drive Wizard ---
|
||||||
|
|
||||||
|
// storageAttachHandler serves the attach wizard page.
|
||||||
|
func (s *Server) storageAttachHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := s.baseData("settings", "Meglévő meghajtó csatolása")
|
||||||
|
s.render(w, "storage_attach", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachMountRawHandler handles POST /api/storage/attach/mount-raw.
|
||||||
|
// Temporarily mounts a partition at a staging path for browsing.
|
||||||
|
func (s *Server) storageAttachMountRawHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
DevicePath string `json:"device_path"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.DevicePath == "" {
|
||||||
|
jsonError(w, "Hiányzó eszközútvonal", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any previous raw mount first
|
||||||
|
s.diskJobMu.Lock()
|
||||||
|
if s.activeRawMount != "" {
|
||||||
|
_ = storage.CleanupRawMount(s.activeRawMount)
|
||||||
|
s.activeRawMount = ""
|
||||||
|
}
|
||||||
|
s.diskJobMu.Unlock()
|
||||||
|
|
||||||
|
rawPath, err := storage.MountRaw(req.DevicePath)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] storageAttachMountRaw: %v", err)
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.diskJobMu.Lock()
|
||||||
|
s.activeRawMount = rawPath
|
||||||
|
s.diskJobMu.Unlock()
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Raw mount for attach: %s → %s", req.DevicePath, rawPath)
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"raw_path": rawPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachBrowseHandler handles GET /api/storage/attach/browse?path=...
|
||||||
|
// Lists directories at the given path within the raw mount staging area.
|
||||||
|
func (s *Server) storageAttachBrowseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
browsePath := r.URL.Query().Get("path")
|
||||||
|
if browsePath == "" {
|
||||||
|
jsonError(w, "Hiányzó útvonal paraméter", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: validate path is under the raw mount staging area
|
||||||
|
cleanPath := filepath.Clean(browsePath)
|
||||||
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
||||||
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirs, err := storage.ListDirectories(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"path": cleanPath,
|
||||||
|
"dirs": dirs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachMkdirHandler handles POST /api/storage/attach/mkdir.
|
||||||
|
// Creates a new directory in the raw mount staging area.
|
||||||
|
func (s *Server) storageAttachMkdirHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Path == "" || req.Name == "" {
|
||||||
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: validate path is under the raw mount staging area
|
||||||
|
cleanPath := filepath.Clean(req.Path)
|
||||||
|
if !strings.HasPrefix(cleanPath, storage.RawMountBase) {
|
||||||
|
jsonError(w, "Érvénytelen útvonal", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
createdPath, err := storage.CreateDirectory(cleanPath, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Created directory for attach: %s", createdPath)
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"created_path": createdPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachAPIHandler handles POST /api/storage/attach — starts the final attach job.
|
||||||
|
func (s *Server) storageAttachAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
DevicePath string `json:"device_path"`
|
||||||
|
MountName string `json:"mount_name"`
|
||||||
|
SubPath string `json:"sub_path"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
SetDefault bool `json:"set_default"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DevicePath == "" || req.MountName == "" || req.SubPath == "" {
|
||||||
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job, ok := s.tryStartDiskJob("attach")
|
||||||
|
if !ok {
|
||||||
|
jsonError(w, "Egy másik lemezművelet folyamatban van", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Printf("[INFO] Storage attach started: device=%s mountName=%s subPath=%s by %s",
|
||||||
|
req.DevicePath, req.MountName, req.SubPath, r.RemoteAddr)
|
||||||
|
|
||||||
|
attachReq := storage.AttachRequest{
|
||||||
|
DevicePath: req.DevicePath,
|
||||||
|
MountName: req.MountName,
|
||||||
|
SubPath: req.SubPath,
|
||||||
|
Label: req.Label,
|
||||||
|
SetDefault: req.SetDefault,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
progressCh := make(chan storage.FormatProgress, 32)
|
||||||
|
go func() {
|
||||||
|
for p := range progressCh {
|
||||||
|
job.appendFmtProg(p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
mountPath, err := storage.FinalizeAttach(attachReq, progressCh)
|
||||||
|
close(progressCh)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("[ERROR] Storage attach failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear raw mount tracking (it's now permanent via fstab)
|
||||||
|
s.diskJobMu.Lock()
|
||||||
|
s.activeRawMount = ""
|
||||||
|
s.diskJobMu.Unlock()
|
||||||
|
|
||||||
|
// Auto-register the new storage path
|
||||||
|
label := req.Label
|
||||||
|
if label == "" {
|
||||||
|
label = settings.InferStorageLabel(mountPath)
|
||||||
|
}
|
||||||
|
sp := settings.StoragePath{
|
||||||
|
Path: mountPath,
|
||||||
|
Label: label,
|
||||||
|
IsDefault: req.SetDefault,
|
||||||
|
Schedulable: true,
|
||||||
|
AddedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if err := s.settings.AddStoragePath(sp); err != nil {
|
||||||
|
s.logger.Printf("[WARN] Failed to register storage path after attach: %v", err)
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
|
||||||
|
s.syncFileBrowserMounts()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"msg": "Csatolás elindítva",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachStatusAPIHandler handles GET /api/storage/attach/status.
|
||||||
|
func (s *Server) storageAttachStatusAPIHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
job := s.currentDiskJob()
|
||||||
|
if job == nil || job.jobType != "attach" {
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"active": false,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p, ok := job.lastFmtProg()
|
||||||
|
if !ok {
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"active": true,
|
||||||
|
"step": "starting",
|
||||||
|
"msg": "Csatolás elindult...",
|
||||||
|
"pct": 0,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"active": !job.isDone(),
|
||||||
|
"step": p.Step,
|
||||||
|
"msg": p.Message,
|
||||||
|
"pct": p.Percent,
|
||||||
|
"error": p.Error,
|
||||||
|
"done": job.isDone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// storageAttachCancelHandler handles POST /api/storage/attach/cancel.
|
||||||
|
// Cleans up the temporary raw mount when the user cancels the wizard.
|
||||||
|
func (s *Server) storageAttachCancelHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.diskJobMu.Lock()
|
||||||
|
rawMount := s.activeRawMount
|
||||||
|
s.activeRawMount = ""
|
||||||
|
s.diskJobMu.Unlock()
|
||||||
|
|
||||||
|
if rawMount == "" {
|
||||||
|
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Nincs aktív raw mount"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := storage.CleanupRawMount(rawMount); err != nil {
|
||||||
|
s.logger.Printf("[WARN] Failed to cleanup raw mount %s: %v", rawMount, err)
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[INFO] Cleaned up raw mount: %s", rawMount)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{"ok": true, "msg": "Raw mount eltávolítva"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@
|
|||||||
|
|
||||||
<div style="margin-top:1rem;display:flex;gap:.75rem;flex-wrap:wrap">
|
<div style="margin-top:1rem;display:flex;gap:.75rem;flex-wrap:wrap">
|
||||||
<a href="/settings/storage/init" class="btn btn-sm btn-outline">🔧 Új meghajtó inicializálása</a>
|
<a href="/settings/storage/init" class="btn btn-sm btn-outline">🔧 Új meghajtó inicializálása</a>
|
||||||
|
<a href="/settings/storage/attach" class="btn btn-sm btn-outline">🔗 Meglévő meghajtó csatolása</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details class="storage-add-details">
|
<details class="storage-add-details">
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
{{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>
|
||||||
|
|
||||||
|
<!-- Step 1: Scan -->
|
||||||
|
<div class="settings-card" id="wizard-scan">
|
||||||
|
<h3>1. Meghajtók keresése</h3>
|
||||||
|
<p class="settings-card-desc">Keresse meg a rendszerhez csatlakoztatott, meglévő fájlrendszerrel rendelkező meghajtókat.</p>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" onclick="scanDisks()" id="scan-btn">🔍 Meghajtók keresése</button>
|
||||||
|
<div id="scan-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
|
||||||
|
|
||||||
|
<div id="scan-result" style="display:none;margin-top:1.5rem">
|
||||||
|
<div id="available-disks"></div>
|
||||||
|
<div id="system-disks-note" style="display:none;margin-top:1rem"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Browse -->
|
||||||
|
<div class="settings-card" id="wizard-browse" style="display:none">
|
||||||
|
<h3>2. Mappa kiválasztása</h3>
|
||||||
|
<p class="settings-card-desc">Válasszon ki egy mappát a meghajtón, amelyet a controller használni fog. Új mappát is létrehozhat.</p>
|
||||||
|
|
||||||
|
<div id="browse-info" class="settings-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Partíció</span>
|
||||||
|
<span class="settings-value mono" id="browse-device"></span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Fájlrendszer</span>
|
||||||
|
<span class="settings-value mono" id="browse-fstype"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="browse-error" class="alert alert-error" style="display:none;margin-bottom:1rem"></div>
|
||||||
|
|
||||||
|
<div id="dir-browser" style="border:1px solid var(--border);border-radius:6px;padding:1rem;background:var(--card-bg);margin-bottom:1rem">
|
||||||
|
<div id="dir-breadcrumb" class="form-hint mono" style="margin-bottom:.75rem"></div>
|
||||||
|
<div id="dir-list" style="min-height:100px"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap;margin-bottom:1rem">
|
||||||
|
<div class="form-group" style="margin-bottom:0">
|
||||||
|
<label for="new-dir-name">Új mappa neve</label>
|
||||||
|
<input type="text" id="new-dir-name" class="form-control" placeholder="felhom_data"
|
||||||
|
pattern="[a-zA-Z0-9_]+" style="max-width:200px">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline" onclick="createDir()" id="mkdir-btn">📁 Mappa létrehozása</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="selected-dir-info" class="alert alert-info" style="display:none;margin-bottom:1rem">
|
||||||
|
Kiválasztott mappa: <strong id="selected-dir-display" class="mono"></strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="gap:.75rem">
|
||||||
|
<button class="btn btn-primary" onclick="goToConfigure()" id="browse-next-btn" disabled>Tovább →</button>
|
||||||
|
<button class="btn btn-outline" onclick="cancelAttach()">Mégsem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Configure -->
|
||||||
|
<div class="settings-card" id="wizard-configure" style="display:none">
|
||||||
|
<h3>3. Konfiguráció</h3>
|
||||||
|
<p class="settings-card-desc">Adja meg a csatolás paramétereit.</p>
|
||||||
|
|
||||||
|
<form id="attach-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kiválasztott partíció</label>
|
||||||
|
<span class="settings-value mono" id="config-device-display"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kiválasztott mappa</label>
|
||||||
|
<span class="settings-value mono" id="config-subpath-display"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="mount-name">Csatlakoztatási név <span class="required">*</span></label>
|
||||||
|
<div class="form-inline">
|
||||||
|
<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:160px">
|
||||||
|
</div>
|
||||||
|
<span class="form-hint">Pl. hdd_1 → a mappa a /mnt/hdd_1 ú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.5rem">
|
||||||
|
<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-info" style="margin-bottom:1.5rem">
|
||||||
|
<strong>ℹ️ Megjegyzés:</strong> A meghajtón lévő adatok <strong>NEM</strong> törlődnek.
|
||||||
|
A controller csak a kiválasztott mappában dolgozik.<br>
|
||||||
|
<strong>⚠️ A csatlakozási pont (/mnt/<név>) a meghajtó lecsatolásáig nem módosítható.</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions" style="gap:.75rem">
|
||||||
|
<button type="submit" class="btn btn-primary" id="attach-btn">Csatolás</button>
|
||||||
|
<button type="button" class="btn btn-outline" onclick="backToBrowse()">← Vissza</button>
|
||||||
|
</div>
|
||||||
|
<div id="attach-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Progress -->
|
||||||
|
<div class="settings-card" id="wizard-progress" style="display:none">
|
||||||
|
<h3>4. Csatolás folyamatban...</h3>
|
||||||
|
|
||||||
|
<div class="disk-progress-steps" id="progress-steps">
|
||||||
|
<div class="disk-step" id="pstep-validating"><span class="disk-step-icon">○</span> Ellenőrzés</div>
|
||||||
|
<div class="disk-step" id="pstep-mounting"><span class="disk-step-icon">○</span> Csatlakoztatás</div>
|
||||||
|
<div class="disk-step" id="pstep-permissions"><span class="disk-step-icon">○</span> Mappák és jogosultságok</div>
|
||||||
|
<div class="disk-step" id="pstep-done"><span class="disk-step-icon">○</span> Regisztráció</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:1.5rem;display:flex;align-items:center;gap:1rem">
|
||||||
|
<div class="progress-bar-task" style="flex:1">
|
||||||
|
<div class="progress-fill" id="progress-fill" style="width:0%"></div>
|
||||||
|
</div>
|
||||||
|
<span id="progress-percent" style="font-size:0.9rem;color:var(--text-muted);font-family:'JetBrains Mono',monospace;white-space:nowrap">0%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="progress-msg" class="form-hint" style="margin-top:.75rem"></div>
|
||||||
|
<div id="progress-error" class="alert alert-error" style="display:none;margin-top:1rem"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Done -->
|
||||||
|
<div class="settings-card" id="wizard-done" style="display:none">
|
||||||
|
<h3>✅ Meghajtó sikeresen csatolva!</h3>
|
||||||
|
<div id="done-info" class="settings-grid" style="margin-top:1rem">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Útvonal</span>
|
||||||
|
<span class="settings-value mono" id="done-path"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/settings" class="btn btn-primary" style="margin-top:1.5rem">← Vissza a Beállításokhoz</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var selectedDevice = null;
|
||||||
|
var selectedPartition = null;
|
||||||
|
var currentBrowsePath = '';
|
||||||
|
var rawMountPath = '';
|
||||||
|
var selectedSubPath = '';
|
||||||
|
var pollTimer = null;
|
||||||
|
|
||||||
|
// --- Step 1: Scan ---
|
||||||
|
|
||||||
|
function scanDisks() {
|
||||||
|
var btn = document.getElementById('scan-btn');
|
||||||
|
var errEl = document.getElementById('scan-error');
|
||||||
|
var resultEl = document.getElementById('scan-result');
|
||||||
|
btn.textContent = 'Keresés...';
|
||||||
|
btn.disabled = true;
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
resultEl.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/storage/scan', {method:'POST'})
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
btn.textContent = '🔍 Meghajtók keresése';
|
||||||
|
btn.disabled = false;
|
||||||
|
if (!data.ok) {
|
||||||
|
errEl.textContent = data.error || 'Ismeretlen hiba';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderScanResult(data);
|
||||||
|
resultEl.style.display = 'block';
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
btn.textContent = '🔍 Meghajtók keresése';
|
||||||
|
btn.disabled = false;
|
||||||
|
errEl.textContent = 'Hálózati hiba: ' + e.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScanResult(data) {
|
||||||
|
var availEl = document.getElementById('available-disks');
|
||||||
|
var sysEl = document.getElementById('system-disks-note');
|
||||||
|
|
||||||
|
// Filter: only show disks that have at least one partition with a filesystem
|
||||||
|
var disksWithFS = [];
|
||||||
|
if (data.available) {
|
||||||
|
data.available.forEach(function(disk) {
|
||||||
|
if (disk.Partitions) {
|
||||||
|
var fsPartitions = disk.Partitions.filter(function(p) { return p.FSType && p.FSType !== ''; });
|
||||||
|
if (fsPartitions.length > 0) {
|
||||||
|
disksWithFS.push({disk: disk, partitions: fsPartitions});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disksWithFS.length === 0) {
|
||||||
|
availEl.innerHTML = '<div class="empty-state" style="padding:1rem">Nem található meglévő fájlrendszerrel rendelkező meghajtó.<br>' +
|
||||||
|
'<span class="form-hint">Ha üres meghajtót szeretne inicializálni, használja az <a href="/settings/storage/init">inicializálás varázslót</a>.</span></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var html = '<h4 style="margin-bottom:.75rem">Talált meghajtók csatolható partíciókkal:</h4>';
|
||||||
|
disksWithFS.forEach(function(item) {
|
||||||
|
var disk = item.disk;
|
||||||
|
html += '<div style="margin-bottom:1rem">';
|
||||||
|
html += '<div class="form-hint" style="margin-bottom:.5rem">' + disk.Path + ' — ' + (disk.Size || '?') +
|
||||||
|
(disk.Model ? ' — ' + disk.Model : '') + '</div>';
|
||||||
|
|
||||||
|
item.partitions.forEach(function(part) {
|
||||||
|
var info = part.FSType;
|
||||||
|
if (part.Label) info += ', címke: ' + part.Label;
|
||||||
|
if (part.UUID) info += ', UUID: ' + part.UUID.substring(0, 8) + '...';
|
||||||
|
|
||||||
|
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent;margin-bottom:.5rem" ' +
|
||||||
|
'onclick="selectPartition(this, \'' + part.Path + '\', \'' + (part.FSType || '') + '\', \'' + (part.Label || '') + '\')" ' +
|
||||||
|
'data-path="' + part.Path + '">' +
|
||||||
|
'<div class="storage-path-header"><div class="storage-path-info">' +
|
||||||
|
'<span class="storage-path-label">○ ' + part.Path + ' — ' + (part.Size || '?') + '</span>' +
|
||||||
|
'<span class="form-hint">' + info + '</span>' +
|
||||||
|
'</div></div></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
availEl.innerHTML = html;
|
||||||
|
|
||||||
|
if (data.system && data.system.length > 0) {
|
||||||
|
var sysNames = data.system.map(function(d){ return d.Path + ' (' + (d.Size||'?') + ')'; }).join(', ');
|
||||||
|
sysEl.innerHTML = '<span class="form-hint">A rendszermeghajtó(k) nem választhatók: ' + sysNames + '</span>';
|
||||||
|
sysEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPartition(el, path, fsType, label) {
|
||||||
|
// Deselect all
|
||||||
|
document.querySelectorAll('[data-path]').forEach(function(d) {
|
||||||
|
d.style.border = '2px solid transparent';
|
||||||
|
var lbl = d.querySelector('.storage-path-label');
|
||||||
|
if (lbl) lbl.textContent = lbl.textContent.replace('● ', '○ ');
|
||||||
|
});
|
||||||
|
// Select this
|
||||||
|
el.style.border = '2px solid var(--accent-blue)';
|
||||||
|
var lbl = el.querySelector('.storage-path-label');
|
||||||
|
if (lbl) lbl.textContent = lbl.textContent.replace('○ ', '● ');
|
||||||
|
|
||||||
|
selectedDevice = path;
|
||||||
|
selectedPartition = {path: path, fsType: fsType, label: label};
|
||||||
|
|
||||||
|
// Mount raw and go to browse
|
||||||
|
mountRawAndBrowse(path, fsType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Browse ---
|
||||||
|
|
||||||
|
function mountRawAndBrowse(devicePath, fsType) {
|
||||||
|
var errEl = document.getElementById('scan-error');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/storage/attach/mount-raw', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({device_path: devicePath})
|
||||||
|
}).then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
errEl.textContent = data.error || 'Raw mount sikertelen';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rawMountPath = data.raw_path;
|
||||||
|
|
||||||
|
// Show browse step
|
||||||
|
document.getElementById('browse-device').textContent = devicePath;
|
||||||
|
document.getElementById('browse-fstype').textContent = fsType;
|
||||||
|
document.getElementById('wizard-scan').style.display = 'none';
|
||||||
|
document.getElementById('wizard-browse').style.display = 'block';
|
||||||
|
document.getElementById('wizard-browse').scrollIntoView({behavior:'smooth'});
|
||||||
|
|
||||||
|
// Browse root
|
||||||
|
browseDirectory(rawMountPath);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
errEl.textContent = 'Hálózati hiba: ' + e.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseDirectory(path) {
|
||||||
|
currentBrowsePath = path;
|
||||||
|
var errEl = document.getElementById('browse-error');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Update breadcrumb
|
||||||
|
var rel = path.replace(rawMountPath, '') || '/';
|
||||||
|
document.getElementById('dir-breadcrumb').textContent = 'Aktuális mappa: ' + rel;
|
||||||
|
|
||||||
|
fetch('/api/storage/attach/browse?path=' + encodeURIComponent(path))
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
errEl.textContent = data.error || 'Hiba a mappák listázásakor';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderDirList(data.dirs || [], path);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
errEl.textContent = 'Hálózati hiba: ' + e.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDirList(dirs, basePath) {
|
||||||
|
var listEl = document.getElementById('dir-list');
|
||||||
|
var html = '';
|
||||||
|
|
||||||
|
// "Use this directory" option (select the current directory itself)
|
||||||
|
html += '<div class="storage-path-item" style="cursor:pointer;border:2px solid transparent;margin-bottom:.5rem;padding:.5rem .75rem" ' +
|
||||||
|
'onclick="selectDir(this, \'' + escapeJS(basePath) + '\')" data-dirpath="' + escapeAttr(basePath) + '">' +
|
||||||
|
'<span class="storage-path-label">📂 . (ez a mappa)</span></div>';
|
||||||
|
|
||||||
|
// Parent directory (if not at root)
|
||||||
|
if (basePath !== rawMountPath) {
|
||||||
|
var parentPath = basePath.substring(0, basePath.lastIndexOf('/'));
|
||||||
|
if (parentPath.length < rawMountPath.length) parentPath = rawMountPath;
|
||||||
|
html += '<div style="padding:.3rem .75rem;cursor:pointer;opacity:.7" onclick="browseDirectory(\'' + escapeJS(parentPath) + '\')">' +
|
||||||
|
'📁 .. (szülő mappa)</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirs.length === 0) {
|
||||||
|
html += '<div class="form-hint" style="padding:.5rem .75rem">Üres mappa</div>';
|
||||||
|
} else {
|
||||||
|
dirs.forEach(function(dir) {
|
||||||
|
html += '<div style="display:flex;align-items:center;gap:.5rem;margin-bottom:.25rem">';
|
||||||
|
// Clickable to navigate into
|
||||||
|
if (dir.has_children) {
|
||||||
|
html += '<div style="padding:.3rem .75rem;cursor:pointer;flex:1" onclick="browseDirectory(\'' + escapeJS(dir.path) + '\')">' +
|
||||||
|
'📁 ' + dir.name + ' →</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div style="padding:.3rem .75rem;flex:1">📁 ' + dir.name + '</div>';
|
||||||
|
}
|
||||||
|
// Select button
|
||||||
|
html += '<button class="btn btn-xs btn-outline" onclick="selectDir(null, \'' + escapeJS(dir.path) + '\')">Kiválasztás</button>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDir(el, path) {
|
||||||
|
selectedSubPath = path;
|
||||||
|
document.getElementById('selected-dir-display').textContent = path.replace(rawMountPath, '') || '/';
|
||||||
|
document.getElementById('selected-dir-info').style.display = 'block';
|
||||||
|
document.getElementById('browse-next-btn').disabled = false;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
document.querySelectorAll('[data-dirpath]').forEach(function(d) {
|
||||||
|
d.style.border = '2px solid transparent';
|
||||||
|
});
|
||||||
|
if (el) {
|
||||||
|
el.style.border = '2px solid var(--accent-blue)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-fill mount name from partition label if available
|
||||||
|
var mountInput = document.getElementById('mount-name');
|
||||||
|
if (!mountInput.value && selectedPartition && selectedPartition.label) {
|
||||||
|
mountInput.value = selectedPartition.label.replace(/[^a-zA-Z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDir() {
|
||||||
|
var nameInput = document.getElementById('new-dir-name');
|
||||||
|
var name = nameInput.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
var errEl = document.getElementById('browse-error');
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/storage/attach/mkdir', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({path: currentBrowsePath, name: name})
|
||||||
|
}).then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
errEl.textContent = data.error || 'Mappa létrehozása sikertelen';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nameInput.value = '';
|
||||||
|
// Auto-select the created directory
|
||||||
|
selectedSubPath = data.created_path;
|
||||||
|
document.getElementById('selected-dir-display').textContent = data.created_path.replace(rawMountPath, '');
|
||||||
|
document.getElementById('selected-dir-info').style.display = 'block';
|
||||||
|
document.getElementById('browse-next-btn').disabled = false;
|
||||||
|
// Refresh directory listing
|
||||||
|
browseDirectory(currentBrowsePath);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
errEl.textContent = 'Hálózati hiba: ' + e.message;
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToConfigure() {
|
||||||
|
if (!selectedSubPath) return;
|
||||||
|
document.getElementById('config-device-display').textContent = selectedDevice;
|
||||||
|
document.getElementById('config-subpath-display').textContent = selectedSubPath.replace(rawMountPath, '') || '/ (gyökérmappa)';
|
||||||
|
document.getElementById('wizard-browse').style.display = 'none';
|
||||||
|
document.getElementById('wizard-configure').style.display = 'block';
|
||||||
|
document.getElementById('wizard-configure').scrollIntoView({behavior:'smooth'});
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToBrowse() {
|
||||||
|
document.getElementById('wizard-configure').style.display = 'none';
|
||||||
|
document.getElementById('wizard-browse').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelAttach() {
|
||||||
|
// Cleanup raw mount
|
||||||
|
fetch('/api/storage/attach/cancel', {method:'POST'}).catch(function(){});
|
||||||
|
window.location.href = '/settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Submit ---
|
||||||
|
|
||||||
|
document.getElementById('attach-form').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var mountName = document.getElementById('mount-name').value.trim();
|
||||||
|
var label = document.getElementById('storage-label').value.trim();
|
||||||
|
var setDefault = document.getElementById('set-default').checked;
|
||||||
|
var errEl = document.getElementById('attach-error');
|
||||||
|
|
||||||
|
if (!mountName) {
|
||||||
|
errEl.textContent = 'A csatlakoztatási nevet meg kell adni.';
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
document.getElementById('wizard-configure').style.display = 'none';
|
||||||
|
document.getElementById('wizard-progress').style.display = 'block';
|
||||||
|
document.getElementById('wizard-progress').scrollIntoView({behavior:'smooth'});
|
||||||
|
|
||||||
|
var body = {
|
||||||
|
device_path: selectedDevice,
|
||||||
|
mount_name: mountName,
|
||||||
|
sub_path: selectedSubPath,
|
||||||
|
label: label,
|
||||||
|
set_default: setDefault
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/storage/attach', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
showProgressError(data.error || 'Ismeretlen hiba');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pollTimer = setInterval(pollProgress, 1500);
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
showProgressError('Hálózati hiba: ' + e.message);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Step 4: Progress ---
|
||||||
|
|
||||||
|
var stepOrder = ['validating','mounting','permissions','done'];
|
||||||
|
|
||||||
|
function pollProgress() {
|
||||||
|
fetch('/api/storage/attach/status')
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) return;
|
||||||
|
updateProgressUI(data);
|
||||||
|
if (data.done) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
if (data.step === 'done') {
|
||||||
|
showDone('/mnt/' + document.getElementById('mount-name').value.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgressUI(data) {
|
||||||
|
var currentIdx = stepOrder.indexOf(data.step);
|
||||||
|
stepOrder.forEach(function(s, i) {
|
||||||
|
var el = document.getElementById('pstep-' + s);
|
||||||
|
if (!el) return;
|
||||||
|
var icon = el.querySelector('.disk-step-icon');
|
||||||
|
if (i < currentIdx) {
|
||||||
|
el.className = 'disk-step disk-step-done';
|
||||||
|
icon.textContent = '✅';
|
||||||
|
} else if (i === currentIdx) {
|
||||||
|
el.className = 'disk-step disk-step-active';
|
||||||
|
icon.textContent = data.step === 'error' ? '❌' : '⏳';
|
||||||
|
} else {
|
||||||
|
el.className = 'disk-step';
|
||||||
|
icon.textContent = '○';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var pct = data.pct || 0;
|
||||||
|
document.getElementById('progress-fill').style.width = pct + '%';
|
||||||
|
document.getElementById('progress-percent').textContent = pct + '%';
|
||||||
|
document.getElementById('progress-msg').textContent = data.msg || '';
|
||||||
|
|
||||||
|
if (data.step === 'error' || data.error) {
|
||||||
|
showProgressError(data.error || data.msg || 'Ismeretlen hiba');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProgressError(msg) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
document.getElementById('progress-error').textContent = 'Hiba: ' + msg;
|
||||||
|
document.getElementById('progress-error').style.display = 'block';
|
||||||
|
document.getElementById('wizard-progress').querySelector('h3').textContent = 'Csatolás sikertelen';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDone(mountPath) {
|
||||||
|
document.getElementById('wizard-progress').style.display = 'none';
|
||||||
|
document.getElementById('wizard-done').style.display = 'block';
|
||||||
|
document.getElementById('done-path').textContent = mountPath;
|
||||||
|
document.getElementById('wizard-done').scrollIntoView({behavior:'smooth'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function escapeJS(s) {
|
||||||
|
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(s) {
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload (best-effort)
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (rawMountPath && !document.getElementById('wizard-done').style.display !== 'none') {
|
||||||
|
// Best-effort cleanup via sendBeacon
|
||||||
|
navigator.sendBeacon('/api/storage/attach/cancel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{template "layout_end" .}}
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user