Files
deploy-felhom-compose/TASK.md
T

12 KiB

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

Version: 0.11.6 Scope: Go handler + template/CSS changes


Feature: Auto-update FileBrowser mounts when storage paths change

Context

FileBrowser Quantum is the infrastructure file manager deployed at files.<domain>. It's how customers access their files on external drives via the web.

Current problem: FileBrowser's docker-compose.yml has hardcoded volume mounts 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):

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...

Goal: After a new storage path is registered (or removed), the controller automatically regenerates FileBrowser's compose with all registered storage paths as volume mounts, then recreates the container.

New mount layout

Instead of mounting specific subdirectories, mount each registered storage path as a top-level directory:

    volumes:
      - 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)

The user sees in FileBrowser:

/srv/
  hdd_1/
    Dokumentumok/
    media/
    storage/
  hdd_2/
    ...

Each storage path's subdirectories (created during init: storage/, Dokumentumok/, media/) are visible as subdirectories under the mount name.

Implementation

1. New function: syncFileBrowserMounts() in handlers.go

Add a method on *Server that regenerates FileBrowser's compose from the current registered storage paths:

// 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

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:

        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

# 1. Before: check current FileBrowser mounts
docker inspect filebrowser --format '{{range .Mounts}}{{.Source}}→{{.Destination}} {{end}}'

# 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

# 3. Verify FileBrowser was recreated
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/

UI Fix 1: "Nincs csatolva!" badge → "Rendszermeghajtón"

Problem

/mnt/hdd_placeholder shows a red "Nincs csatolva!" badge. It's on the system SSD, which isn't a separate mount point. This looks like an error but it's just informational.

Where

controller/internal/web/templates/settings.html — find the badge rendering for storage paths that aren't mount points. Look for "Nincs csatolva!" text.

Fix

Change from red/danger badge to yellow/warning badge with new text:

Before:

<span class="badge badge-danger">Nincs csatolva!</span>

After:

<span class="badge badge-warn">Rendszermeghajtón</span>

Add badge style if badge-warn doesn't exist in style.css:

.badge-warn {
    background: rgba(250, 204, 21, 0.15);
    color: #facc15;
}

UI Fix 2: Init progress bar — remove disk usage zone gradient

Problem

The storage init progress bar uses the disk-usage bar style which has green→yellow→red gradient zones. At 30% progress it looks alarming. A task progress bar should be a simple single-color fill on neutral background.

Where

controller/internal/web/templates/storage_init.html — find the progress bar. controller/internal/web/templates/style.css — add new class.

Fix

Add a new CSS class for task progress (not disk usage):

.progress-bar-task {
    height: 8px;
    background: var(--border);
    border-radius: 4px;
    overflow: hidden;
    width: 100%;
}

.progress-bar-task .progress-fill {
    height: 100%;
    background: var(--accent);
    border-radius: 4px;
    transition: width 0.3s ease;
}

In storage_init.html, replace the current progress bar element (which reuses the disk usage bar class) with:

<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:

document.getElementById('progress-fill').style.width = data.percent + '%';
document.getElementById('progress-percent').textContent = data.percent + '%';

UI Fix 3: "Alapértelmezett" button → "Legyen alapértelmezett"

Problem

Button text "Alapértelmezett" reads as a status label ("Default"), not an action. Users think the drive IS the default, not that clicking MAKES it the default.

Where

controller/internal/web/templates/settings.html — find the button for setting a non-default storage path as default.

Fix

Change button text from "Alapértelmezett" to "Legyen alapértelmezett".

Verify the distinction is clear:

  • IS default: Green badge "Alapértelmezett" (no button, status indicator)
  • NOT default: Button "Legyen alapértelmezett" (action)

Summary: All changes by file

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

What NOT to change

  • .felhom.yml for FileBrowser — unchanged
  • .env for FileBrowser — unchanged (domain read from it, not written)
  • docker-setup.sh FileBrowser install function — still works for initial install; controller takes over compose management after first storage init
  • Go storage package — no changes