FileBrowser Auto-Mount on New Storage + UI Polish (3 fixes)

This commit is contained in:
2026-02-17 12:00:27 +01:00
parent f6444945ca
commit d42a676522
+313 -318
View File
@@ -1,377 +1,372 @@
# TASK: Bugfix — Storage Initialization (FormatAndMount) # TASK: FileBrowser Auto-Mount on New Storage + UI Polish (3 fixes)
**Version:** 0.11.4 → 0.11.5 **Version:** 0.11.6
**Priority:** Fix all 3 bugs + add safety improvements before testing format again. **Scope:** Go handler + template/CSS changes
## Context
Storage initialization wizard (scan → select disk → format → mount → register) works
up to the partitioning step. Three bugs prevent completion. Fix all three in one pass.
**Test environment:** demo-felhom.eu, `/dev/sdb` = 931.5 GB USB HDD (HD710 PRO),
has existing GPT partition table with one partition `sdb1` (no filesystem).
--- ---
## Bug 1: sfdisk fails with "unsupported command" (CURRENT BLOCKER) ## Feature: Auto-update FileBrowser mounts when storage paths change
### Error output ### Context
```
Old situation: Device Start End Sectors Size Type FileBrowser Quantum is the infrastructure file manager deployed at `files.<domain>`.
/host-dev/sdb1 2048 1953523711 1953521664 931.5G Linux filesystem It's how customers access their files on external drives via the web.
>>> Script header accepted.
>>> line 2: unsupported command **Current problem:** FileBrowser's docker-compose.yml has hardcoded volume mounts
Hiba: exit status 1 pointing to `${HDD_PATH}/storage`, `${HDD_PATH}/media`, `${HDD_PATH}/Dokumentumok`.
When a new drive is initialized via the storage wizard, FileBrowser doesn't get access.
The user has to manually edit the compose — which defeats the managed appliance model.
**Current FileBrowser compose** (`/opt/docker/stacks/filebrowser/docker-compose.yml`):
```yaml
services:
filebrowser:
image: gtstef/filebrowser:latest
container_name: filebrowser
restart: unless-stopped
environment:
- TZ=Europe/Budapest
volumes:
- filebrowser_data:/home/filebrowser/data
- ${HDD_PATH}/storage:/srv/storage:ro
- ${HDD_PATH}/media:/srv/media
- ${HDD_PATH}/Dokumentumok:/srv/Dokumentumok
# ...traefik labels etc...
``` ```
### Root cause **Goal:** After a new storage path is registered (or removed), the controller
Two issues in `format_linux.go` line ~10225: automatically regenerates FileBrowser's compose with all registered storage paths
as volume mounts, then recreates the container.
```go ### New mount layout
sfdiskInput := "label: gpt\n,,,L\n"
cmd := exec.Command("sfdisk", HostDevicePath(req.DevicePath))
```
1. **`,,,L` type shorthand fails on GPT** — this sfdisk version doesn't accept `L` as type Instead of mounting specific subdirectories, mount each registered storage path
for GPT disklabel. For GPT, sfdisk needs the full GUID or no type (defaults to Linux filesystem). as a top-level directory:
2. **No `--force` flag** — sdb already has a GPT table with sdb1. sfdisk tries to apply the
script as a delta to the existing layout, not as a fresh layout.
3. **No `wipefs` before sfdisk** — existing partition signatures confuse sfdisk.
### Fix in `controller/internal/storage/format_linux.go`
Find this block (around line 1022210230):
```go
if req.CreatePartition {
send("partitioning", "Partíció létrehozása...", 15)
sfdiskInput := "label: gpt\n,,,L\n"
cmd := exec.Command("sfdisk", HostDevicePath(req.DevicePath))
cmd.Stdin = strings.NewReader(sfdiskInput)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fail("partitioning", "Partícionálás sikertelen: "+string(out), err)
}
```
Replace with:
```go
if req.CreatePartition {
send("partitioning", fmt.Sprintf("wipefs -a %s ...", HostDevicePath(req.DevicePath)), 12)
// Wipe existing partition table and filesystem signatures first
_ = exec.Command("wipefs", "-a", HostDevicePath(req.DevicePath)).Run()
time.Sleep(500 * time.Millisecond)
// Create GPT with single partition spanning whole disk
// ",," = start=default, size=default(fill disk), type=default(Linux filesystem GUID)
// --force: overwrite even if device appears busy
// --wipe always: wipe filesystem signatures from newly created partitions
send("partitioning", fmt.Sprintf("sfdisk --force --wipe always %s ...", HostDevicePath(req.DevicePath)), 15)
sfdiskInput := "label: gpt\n,,\n"
cmd := exec.Command("sfdisk", "--force", "--wipe", "always", HostDevicePath(req.DevicePath))
cmd.Stdin = strings.NewReader(sfdiskInput)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fail("partitioning", "Partícionálás sikertelen: "+string(out), err)
}
```
---
## Bug 2: `mount mountPath` will fail (NEXT BLOCKER after Bug 1)
### Current code (around line 1028810290)
```go
if out, err := exec.Command("mount", mountPath).CombinedOutput(); err != nil {
return "", fail("mounting", "Csatlakoztatás sikertelen: "+string(out), err)
}
```
### Root cause
`mount /mnt/hdd_1` works by looking up `/mnt/hdd_1` in the process's `/etc/fstab` to find
which device to mount. But inside the container, `/etc/fstab` is Docker's auto-generated fstab
(not the host's). The UUID entry was written to `/host-fstab` (the host's real fstab).
So `mount /mnt/hdd_1` will fail with "can't find /mnt/hdd_1 in /etc/fstab" or similar.
### Fix in `controller/internal/storage/format_linux.go`
Find this line (around line 10288):
```go
if out, err := exec.Command("mount", mountPath).CombinedOutput(); err != nil {
```
Replace with:
```go
// Mount by device path explicitly — container's /etc/fstab != host fstab,
// so "mount /mnt/hdd_1" (fstab lookup) won't work.
send("mounting", fmt.Sprintf("mount -t ext4 %s %s ...", HostDevicePath(partDev), mountPath), 70)
if out, err := exec.Command("mount", "-t", "ext4", "-o", "defaults,noatime",
HostDevicePath(partDev), mountPath).CombinedOutput(); err != nil {
```
The fstab entry in `/host-fstab` still ensures persistence across host reboots.
This explicit mount handles the immediate "mount it right now" operation.
---
## Bug 3: Mount namespace isolation — mount won't be visible on host (RESTART BLOCKER)
### Root cause
Even with `privileged: true`, `mount` inside a container operates in the container's
mount namespace. The host kernel does NOT see the mount. Consequences:
- After controller container restart, the mount is gone
- Other containers can't access `/mnt/hdd_1`
- The bind mount `- /mnt:/mnt:rw` shares existing host mounts INTO the container,
but new mounts created inside the container don't propagate BACK to the host
### Fix: Change `/mnt` volume to use `rshared` mount propagation
#### 3a. `controller/docker-compose.yml`
Find this line:
```yaml ```yaml
# All external storage — /mnt/* for multi-storage + restore volumes:
- /mnt:/mnt:rw - filebrowser_data:/home/filebrowser/data
# Auto-generated storage mounts:
- /mnt/hdd_1:/srv/hdd_1 # "Külső HDD 1TB"
- /mnt/hdd_2:/srv/hdd_2 # (if second drive added later)
``` ```
Replace with: The user sees in FileBrowser:
```
```yaml /srv/
# All external storage — rshared propagation so mounts created inside hdd_1/
# the container (disk init) propagate to the host and vice versa Dokumentumok/
- type: bind media/
source: /mnt storage/
target: /mnt hdd_2/
bind: ...
propagation: rshared
``` ```
**Important:** This uses Docker Compose long-form volume syntax. The rest of the volumes Each storage path's subdirectories (created during init: `storage/`, `Dokumentumok/`, `media/`)
can stay in short form. Only `/mnt` needs propagation. are visible as subdirectories under the mount name.
#### 3b. `scripts/docker-setup.sh` — Add mount propagation setup ### Implementation
Find the section where the script does final setup steps (after Docker installation, #### 1. New function: `syncFileBrowserMounts()` in `handlers.go`
before or after compose generation). Add:
Add a method on `*Server` that regenerates FileBrowser's compose from the current
registered storage paths:
```go
// syncFileBrowserMounts regenerates FileBrowser's docker-compose.yml
// with volume mounts for all registered storage paths, then recreates the container.
func (s *Server) syncFileBrowserMounts() {
composePath := "/opt/docker/stacks/filebrowser/docker-compose.yml"
// Check if FileBrowser stack exists
if _, err := os.Stat(composePath); os.IsNotExist(err) {
s.logger.Printf("[WARN] FileBrowser stack not found at %s — skipping mount sync", composePath)
return
}
// Get all active storage paths
paths := s.settings.GetStoragePaths() // all, not just schedulable
// Read domain from .env
envPath := "/opt/docker/stacks/filebrowser/.env"
domain := ""
if data, err := os.ReadFile(envPath); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "DOMAIN=") {
domain = strings.TrimPrefix(line, "DOMAIN=")
break
}
}
}
if domain == "" {
s.logger.Printf("[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync")
return
}
// Build volume mount lines
var storageMounts []string
for _, sp := range paths {
mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
// Mount each storage path as /srv/<name>
line := fmt.Sprintf(" - %s:/srv/%s", sp.Path, mountName)
storageMounts = append(storageMounts, line)
}
// Generate compose from template
compose := generateFileBrowserCompose(domain, storageMounts)
// Write compose
if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
s.logger.Printf("[ERROR] Failed to write FileBrowser compose: %v", err)
return
}
// Recreate container
cmd := exec.Command("docker", "compose", "up", "-d", "--remove-orphans")
cmd.Dir = filepath.Dir(composePath)
if out, err := cmd.CombinedOutput(); err != nil {
s.logger.Printf("[ERROR] Failed to recreate FileBrowser: %s — %v", string(out), err)
} else {
s.logger.Printf("[INFO] FileBrowser mounts synced — %d storage path(s)", len(paths))
}
}
```
#### 2. Compose template function
```go
func generateFileBrowserCompose(domain string, storageMounts []string) string {
storageSection := ""
if len(storageMounts) > 0 {
storageSection = "\n # Storage paths (auto-generated by felhom-controller)\n" +
strings.Join(storageMounts, "\n")
}
return fmt.Sprintf(`# FileBrowser Quantum — Infrastructure file manager
# Domain: files.%s
# Deployed by docker-setup.sh — managed by felhom-controller
# WARNING: Volume mounts are auto-generated. Manual edits will be overwritten.
services:
filebrowser:
image: gtstef/filebrowser:latest
container_name: filebrowser
restart: unless-stopped
environment:
- TZ=Europe/Budapest
volumes:
- filebrowser_data:/home/filebrowser/data%s
networks:
- traefik-public
deploy:
resources:
limits:
memory: 256M
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels:
- "traefik.enable=true"
- "traefik.http.routers.filebrowser.rule=Host(` + "`" + `files.%s` + "`" + `)"
- "traefik.http.routers.filebrowser.entrypoints=websecure"
- "traefik.http.routers.filebrowser.tls=true"
- "traefik.http.services.filebrowser.loadbalancer.server.port=80"
- "traefik.docker.network=traefik-public"
volumes:
filebrowser_data:
networks:
traefik-public:
external: true
`, domain, storageSection, domain)
}
```
**Note on Go string templating:** The backticks in the Traefik label make raw string
literals tricky. You can either:
- Use `fmt.Sprintf` with concatenation for the backtick part (shown above)
- Or use `text/template` with a separate template string
- Either approach is fine — choose whichever is cleaner in the actual code
#### 3. Call syncFileBrowserMounts() after storage changes
In `storageInitAPIHandler` (handlers.go), after `AddStoragePath` succeeds,
add the sync call inside the goroutine:
```go
if err := s.settings.AddStoragePath(sp); err != nil {
s.logger.Printf("[WARN] Failed to register storage path after init: %v", err)
} else {
s.logger.Printf("[INFO] Storage path registered: %s (%s)", mountPath, label)
// Sync FileBrowser mounts with new storage path
s.syncFileBrowserMounts()
}
```
Also call it in any handler that removes or modifies storage paths:
- `removeStoragePath` handler (if exists)
- `setDefaultStoragePath` handler — this one only changes the default flag,
does NOT need a FileBrowser sync (mounts don't change)
#### 4. Handle edge case: FileBrowser not deployed
The `syncFileBrowserMounts` function already checks if the compose file exists.
If FileBrowser isn't deployed yet, it logs a warning and returns. This is correct —
the user might initialize storage before deploying FileBrowser.
When FileBrowser IS later deployed (via catalog or docker-setup.sh), it uses `${HDD_PATH}`
from `.env`. The controller could also call `syncFileBrowserMounts()` after deploying
FileBrowser, but that's a separate enhancement. For now, the sync runs on storage changes.
#### 5. Also preserve .felhom.yml and .env
`syncFileBrowserMounts()` only writes `docker-compose.yml`. The `.felhom.yml` (metadata)
and `.env` files should NOT be touched — they're managed separately.
The generated compose does NOT use `${HDD_PATH}` or `${DOMAIN}` env vars — it bakes in
the actual values (domain from .env, paths from settings). This is intentional: it makes
the compose self-contained and debuggable. No hidden env var expansion.
### Test plan
```bash ```bash
# Enable shared mount propagation on /mnt (required for controller disk init) # 1. Before: check current FileBrowser mounts
# This allows mounts created inside the controller container to propagate to the host docker inspect filebrowser --format '{{range .Mounts}}{{.Source}}→{{.Destination}} {{end}}'
log_info "Configuring mount propagation for /mnt..."
mount --make-rshared /mnt 2>/dev/null || mount --make-shared /mnt 2>/dev/null || true
```
**Also** add a comment near the controller compose generation (if any) explaining this requirement. # 2. Initialize a new drive (or use already-initialized hdd_1)
# After init completes, check compose was rewritten:
cat /opt/docker/stacks/filebrowser/docker-compose.yml
# Should show: - /mnt/hdd_1:/srv/hdd_1
If `docker-setup.sh` doesn't generate the controller compose, just add the `mount --make-rshared` # 3. Verify FileBrowser was recreated
to the node preparation section. It's idempotent and safe to run multiple times. docker inspect filebrowser --format '{{range .Mounts}}{{.Source}}→{{.Destination}} {{end}}'
# Should include /mnt/hdd_1 → /srv/hdd_1
--- # 4. Open files.<domain> — navigate to /srv/hdd_1/
# Should see: Dokumentumok/, media/, storage/
## Safety improvement 1: Post-mount verification
### What
After mount succeeds (exit code 0), verify the mount is actually visible.
### Where
In `format_linux.go`, right after the mount command succeeds and BEFORE the
`send("mounting", "Csatlakoztatva..."` line, add:
```go
// Verify mount actually worked (don't just trust exit code)
verifyOut, verifyErr := exec.Command("findmnt", "-n", "-o", "SOURCE", "--target", mountPath).Output()
if verifyErr != nil || strings.TrimSpace(string(verifyOut)) == "" {
return "", fail("mounting", "A csatlakoztatás nem ellenőrizhető: a mount parancs sikerült, de a meghajtó nem látható a rendszerben", fmt.Errorf("mount point %s not found after mount", mountPath))
}
``` ```
--- ---
## Safety improvement 2: Use ASCII mount name for ext4 filesystem label ## UI Fix 1: "Nincs csatolva!" badge → "Rendszermeghajtón"
### What ### Problem
The current code uses `req.Label` (user-provided display label like "Külső HDD 1TB") for the `/mnt/hdd_placeholder` shows a red "Nincs csatolva!" badge. It's on the system SSD,
ext4 `-L` label. ext4 labels are limited to 16 BYTES. Hungarian UTF-8 chars (ű, ó, é) are which isn't a separate mount point. This looks like an error but it's just informational.
2 bytes each, so "Külső HDD 1TB" could exceed the limit or get truncated mid-character.
### Where ### Where
In `format_linux.go`, find the label preparation block (around line 1024910254): `controller/internal/web/templates/settings.html` — find the badge rendering for
storage paths that aren't mount points. Look for "Nincs csatolva!" text.
```go ### Fix
label := req.Label Change from red/danger badge to yellow/warning badge with new text:
if label == "" {
label = req.MountName **Before:**
} ```html
if len(label) > 16 { <span class="badge badge-danger">Nincs csatolva!</span>
label = label[:16]
}
``` ```
Replace with: **After:**
```html
```go <span class="badge badge-warn">Rendszermeghajtón</span>
// Use ASCII-safe mount name for ext4 filesystem label (16-byte limit).
// The display label (req.Label) stays in settings.json for the UI.
fsLabel := req.MountName
if len(fsLabel) > 16 {
fsLabel = fsLabel[:16]
}
``` ```
Then update the mkfs.ext4 call right below to use `fsLabel` instead of `label`: Add badge style if `badge-warn` doesn't exist in `style.css`:
```css
```go .badge-warn {
mkfsCmd := exec.Command("mkfs.ext4", "-L", fsLabel, "-F", HostDevicePath(partDev)) background: rgba(250, 204, 21, 0.15);
color: #facc15;
}
``` ```
--- ---
## Safety improvement 3: Smart partition handling (skip repartition when unnecessary) ## UI Fix 2: Init progress bar — remove disk usage zone gradient
### What ### Problem
The scan shows sdb has 1 partition (sdb1) with no filesystem. The JS always sends The storage init progress bar uses the disk-usage bar style which has
`CreatePartition: true` (because `disk.CreatePartition` is undefined on the `BlockDevice` green→yellow→red gradient zones. At 30% progress it looks alarming. A task
struct, so `undefined !== false` evaluates to `true` in JS). progress bar should be a simple single-color fill on neutral background.
For a disk that already has exactly one partition with no filesystem, we should skip
the destructive repartition step and just format the existing partition directly.
### Where ### Where
In `handlers.go`, in `storageInitAPIHandler`, AFTER building `fmtReq` (around line 1417514180) `controller/internal/web/templates/storage_init.html` — find the progress bar.
and BEFORE the `go func()` goroutine, add: `controller/internal/web/templates/style.css` — add new class.
```go ### Fix
// Smart partition: if device is a whole disk with exactly 1 partition Add a new CSS class for task progress (not disk usage):
// with no filesystem, skip repartitioning — just format existing partition
if fmtReq.CreatePartition { ```css
result, scanErr := storage.ScanDisks() .progress-bar-task {
if scanErr == nil { height: 8px;
for _, disk := range result.AvailableDisks { background: var(--border);
if disk.Path == req.DevicePath && len(disk.Partitions) == 1 && disk.Partitions[0].FSType == "" { border-radius: 4px;
s.logger.Printf("[INFO] Disk %s has 1 empty partition (%s) — skipping repartition", overflow: hidden;
req.DevicePath, disk.Partitions[0].Path) width: 100%;
fmtReq.DevicePath = disk.Partitions[0].Path // e.g., "/dev/sdb1" }
fmtReq.CreatePartition = false
break .progress-bar-task .progress-fill {
} height: 100%;
} background: var(--accent);
} border-radius: 4px;
} transition: width 0.3s ease;
}
``` ```
This way, for demo sdb (which has sdb1 with no FS), it will: In `storage_init.html`, replace the current progress bar element (which reuses the
1. Set DevicePath to `/dev/sdb1` disk usage bar class) with:
2. Set CreatePartition to `false`
3. Skip wipefs + sfdisk entirely
4. Go straight to `mkfs.ext4 /host-dev/sdb1`
**Note:** The wipefs+sfdisk fix (Bug 1) is still needed as fallback for truly ```html
unpartitioned disks or disks with multiple/incompatible partitions. <div class="progress-bar-task">
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
</div>
<span id="progress-percent" style="margin-left: 0.5rem; font-size: 0.9rem; color: var(--text-dim)">0%</span>
```
Update the JS `pollProgress` function to set the width and text:
```js
document.getElementById('progress-fill').style.width = data.percent + '%';
document.getElementById('progress-percent').textContent = data.percent + '%';
```
--- ---
## Safety improvement 4: Descriptive progress messages ## UI Fix 3: "Alapértelmezett" button → "Legyen alapértelmezett"
### What ### Problem
Include executed command details in progress messages for remote debugging. Button text "Alapértelmezett" reads as a status label ("Default"), not an action.
The progress messages show in the UI and get logged by the handler. Users think the drive IS the default, not that clicking MAKES it the default.
### Where ### Where
Throughout `format_linux.go`, update the `send()` calls to include command info. `controller/internal/web/templates/settings.html` — find the button for setting
Examples already shown in the Bug 1 and Bug 2 fixes above. Also update: a non-default storage path as default.
For the mkfs step: ### Fix
```go Change button text from "Alapértelmezett" to "Legyen alapértelmezett".
send("formatting", fmt.Sprintf("mkfs.ext4 -L %s -F %s ...", fsLabel, HostDevicePath(partDev)), 30)
```
For the blkid step (around line 10274): Verify the distinction is clear:
```go - **IS default:** Green badge "Alapértelmezett" (no button, status indicator)
send("mounting", fmt.Sprintf("UUID lekérése: blkid %s ...", HostDevicePath(partDev)), 65) - **NOT default:** Button "Legyen alapértelmezett" (action)
```
--- ---
## Summary: All changes by file ## Summary: All changes by file
### `controller/internal/storage/format_linux.go` (5 changes) | File | Change | Type |
|------|--------|------|
| `handlers.go` | New `syncFileBrowserMounts()` + `generateFileBrowserCompose()` | Feature |
| `handlers.go` | Call `syncFileBrowserMounts()` after `AddStoragePath` | Feature |
| `settings.html` | Red "Nincs csatolva!" → yellow "Rendszermeghajtón" | UI fix |
| `settings.html` | "Alapértelmezett" button → "Legyen alapértelmezett" | UI fix |
| `storage_init.html` | Progress bar: zone gradient → simple fill bar | UI fix |
| `style.css` | Add `.badge-warn` and `.progress-bar-task` classes | UI fix |
1. Partition block: Add `wipefs -a`; change sfdisk input `",,,L"``",,"`; ### What NOT to change
add `--force --wipe always` flags - `.felhom.yml` for FileBrowser — unchanged
2. Mount block: Change `mount mountPath``mount -t ext4 -o defaults,noatime HostDevicePath(partDev) mountPath` - `.env` for FileBrowser — unchanged (domain read from it, not written)
3. After mount: Add `findmnt` verification - `docker-setup.sh` FileBrowser install function — still works for initial install;
4. Label: Use `req.MountName` (ASCII) instead of `req.Label` (UTF-8) for `mkfs.ext4 -L` controller takes over compose management after first storage init
5. Progress messages: Include command details in `send()` calls - Go storage package — no changes
### `controller/docker-compose.yml` (1 change)
6. Change `/mnt:/mnt:rw` to long-form syntax with `propagation: rshared`
### `controller/internal/web/handlers.go` (1 change)
7. In `storageInitAPIHandler`: Add smart partition detection before launching goroutine
### `scripts/docker-setup.sh` (1 change)
8. Add `mount --make-rshared /mnt` to node preparation section
---
## Build & deploy procedure
```bash
# 1. On the host FIRST (before restarting controller):
sudo mount --make-rshared /mnt
# 2. Build new image with fixes (normal build process)
# 3. Deploy
cd /opt/docker/felhom-controller
sudo docker compose up -d
# 4. Verify container sees /host-dev
docker exec felhom-controller ls /host-dev/sd*
# 5. Verify rshared propagation is active
docker inspect felhom-controller --format '{{range .Mounts}}{{if eq .Destination "/mnt"}}Propagation={{.Propagation}}{{end}}{{end}}'
# Should show: Propagation=rshared
# 6. Test storage init wizard:
# - Scan → sdb appears
# - Select sdb → configure hdd_1 → type FORMÁZÁS
# - Watch progress panel — should show command details
# - Should complete successfully
# 7. Verify mount on HOST (proves propagation):
findmnt /mnt/hdd_1
# Should show /dev/sdb1 mounted at /mnt/hdd_1
# 8. Verify fstab entry:
grep hdd_1 /etc/fstab
# Should show UUID=... /mnt/hdd_1 ext4 defaults,nofail,noatime 0 2
# 9. Verify storage registered in settings:
# Visit Settings page → Adattárolók → /mnt/hdd_1 should appear
# 10. Restart controller — verify mount survives:
docker restart felhom-controller
docker exec felhom-controller ls /mnt/hdd_1/
# Should show: storage/ Dokumentumok/
```
---
## What NOT to change
- **Dockerfile** — packages already correct (fdisk, e2fsprogs, util-linux, rsync, parted)
- **scan_linux.go** — scan works correctly after v0.11.1 fixes
- **safety_linux.go / safety.go** — system disk detection works
- **Template/JS** — wizard UI works fine; `CreatePartition` default-true is handled in handler