0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix
This commit is contained in:
@@ -1,372 +1,621 @@
|
|||||||
# TASK: FileBrowser Auto-Mount on New Storage + UI Polish (3 fixes)
|
# 0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Title Fix
|
||||||
|
|
||||||
**Version:** 0.11.6
|
## Summary
|
||||||
**Scope:** Go handler + template/CSS changes
|
|
||||||
|
Three changes in this release:
|
||||||
|
1. **Stale data cleanup** — After migration, option to delete data from the previous storage location
|
||||||
|
2. **FileBrowser sync after migration** — Trigger `syncFileBrowserMounts()` after successful migration
|
||||||
|
3. **UI title fix** — Deploy page shows "Beállítások" instead of "Telepítés" for already-deployed apps
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature: Auto-update FileBrowser mounts when storage paths change
|
## 1. Stale Data Cleanup
|
||||||
|
|
||||||
### Context
|
### Concept
|
||||||
|
|
||||||
FileBrowser Quantum is the infrastructure file manager deployed at `files.<domain>`.
|
After migrating an app (e.g. Immich from `hdd_placeholder` → `hdd_1`), the old data remains as a safety backup. We need to:
|
||||||
It's how customers access their files on external drives via the web.
|
- Detect stale data on non-active storage paths
|
||||||
|
- Show it on the deploy (settings) page
|
||||||
|
- Allow deletion with proper warnings
|
||||||
|
- Also offer deletion right after migration completes
|
||||||
|
|
||||||
**Current problem:** FileBrowser's docker-compose.yml has hardcoded volume mounts
|
### Files Modified
|
||||||
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`):
|
| File | Change |
|
||||||
```yaml
|
|------|--------|
|
||||||
services:
|
| `internal/web/handlers.go` | Add `findStaleStorageData()`, `staleDataCleanupHandler()` |
|
||||||
filebrowser:
|
| `internal/web/server.go` | Register new route |
|
||||||
image: gtstef/filebrowser:latest
|
| `internal/web/templates/deploy.html` | Show stale data card with delete button |
|
||||||
container_name: filebrowser
|
| `internal/web/templates/migrate.html` | Add delete button to migration-done card |
|
||||||
restart: unless-stopped
|
| `internal/api/router.go` | Add `DELETE /api/stacks/{name}/stale-data` route |
|
||||||
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
|
## 2. FileBrowser Sync After Migration
|
||||||
|
|
||||||
Instead of mounting specific subdirectories, mount each registered storage path
|
### Files Modified
|
||||||
as a top-level directory:
|
|
||||||
|
|
||||||
```yaml
|
| File | Change |
|
||||||
volumes:
|
|------|--------|
|
||||||
- filebrowser_data:/home/filebrowser/data
|
| `internal/web/handlers.go` | Add `syncFileBrowserMounts()` call after successful migration |
|
||||||
# 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/`)
|
## 3. UI Title Fix
|
||||||
are visible as subdirectories under the mount name.
|
|
||||||
|
|
||||||
### Implementation
|
### Files Modified
|
||||||
|
|
||||||
#### 1. New function: `syncFileBrowserMounts()` in `handlers.go`
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `internal/web/handlers.go` | Dynamic page title based on `alreadyDeployed` |
|
||||||
|
| `internal/web/templates/deploy.html` | Dynamic `<h2>` title |
|
||||||
|
|
||||||
Add a method on `*Server` that regenerates FileBrowser's compose from the current
|
---
|
||||||
registered storage paths:
|
|
||||||
|
## Detailed Changes
|
||||||
|
|
||||||
|
### `internal/web/handlers.go`
|
||||||
|
|
||||||
|
#### Change 1: Dynamic page title (Task 3)
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// syncFileBrowserMounts regenerates FileBrowser's docker-compose.yml
|
// BEFORE (line ~13105):
|
||||||
// with volume mounts for all registered storage paths, then recreates the container.
|
data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
|
||||||
func (s *Server) syncFileBrowserMounts() {
|
|
||||||
composePath := "/opt/docker/stacks/filebrowser/docker-compose.yml"
|
|
||||||
|
|
||||||
// Check if FileBrowser stack exists
|
// AFTER:
|
||||||
if _, err := os.Stat(composePath); os.IsNotExist(err) {
|
pageTitle := meta.DisplayName + " — Telepítés"
|
||||||
s.logger.Printf("[WARN] FileBrowser stack not found at %s — skipping mount sync", composePath)
|
if alreadyDeployed {
|
||||||
return
|
pageTitle = meta.DisplayName + " — Beállítások"
|
||||||
|
}
|
||||||
|
data := s.baseData("deploy", pageTitle)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 2: Add stale data to deploy page context (Task 1)
|
||||||
|
|
||||||
|
In `deployHandler`, after the existing `storageInfo` block (after line ~13135), add:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Stale data from previous migrations (only for deployed apps with HDD data)
|
||||||
|
if alreadyDeployed {
|
||||||
|
staleData := s.findStaleStorageData(name)
|
||||||
|
if len(staleData) > 0 {
|
||||||
|
data["StaleData"] = staleData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 3: Add `findStaleStorageData()` function (Task 1)
|
||||||
|
|
||||||
|
Add after `storageInfoForStack()`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// StaleStorageData describes leftover data on a non-active storage path.
|
||||||
|
type StaleStorageData struct {
|
||||||
|
Path string // e.g., "/mnt/hdd_placeholder"
|
||||||
|
Label string // e.g., "Külső tárhely (hdd_placeholder)"
|
||||||
|
Mounts []string // host-side paths with data
|
||||||
|
SizeHuman string // e.g., "48 MB"
|
||||||
|
SizeBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// findStaleStorageData detects leftover app data on non-active storage paths.
|
||||||
|
// This happens after migration: the old data stays on the previous storage path.
|
||||||
|
func (s *Server) findStaleStorageData(stackName string) []StaleStorageData {
|
||||||
|
appCfg := s.stackMgr.LoadAppConfigByName(stackName)
|
||||||
|
if appCfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currentHDDPath := appCfg.Env["HDD_PATH"]
|
||||||
|
if currentHDDPath == "" {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all active storage paths
|
stack, ok := s.stackMgr.GetStack(stackName)
|
||||||
paths := s.settings.GetStoragePaths() // all, not just schedulable
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Read domain from .env
|
var result []StaleStorageData
|
||||||
envPath := "/opt/docker/stacks/filebrowser/.env"
|
|
||||||
domain := ""
|
// Check all registered storage paths except the current one
|
||||||
if data, err := os.ReadFile(envPath); err == nil {
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
if sp.Path == currentHDDPath {
|
||||||
if strings.HasPrefix(line, "DOMAIN=") {
|
continue
|
||||||
domain = strings.TrimPrefix(line, "DOMAIN=")
|
}
|
||||||
break
|
|
||||||
|
// Use ParseComposeHDDMounts to find what dirs WOULD exist on this path
|
||||||
|
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, sp.Path)
|
||||||
|
if len(mounts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check which mounts actually have data
|
||||||
|
var existingMounts []string
|
||||||
|
var totalSize int64
|
||||||
|
for _, m := range mounts {
|
||||||
|
info, err := os.Stat(m)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size := dirSizeInt64(m)
|
||||||
|
if size > 0 {
|
||||||
|
existingMounts = append(existingMounts, m)
|
||||||
|
totalSize += size
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if domain == "" {
|
|
||||||
s.logger.Printf("[WARN] Cannot read DOMAIN from FileBrowser .env — skipping mount sync")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build volume mount lines
|
if len(existingMounts) == 0 {
|
||||||
var storageMounts []string
|
continue
|
||||||
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:
|
label := sp.Label
|
||||||
- `removeStoragePath` handler (if exists)
|
if label == "" {
|
||||||
- `setDefaultStoragePath` handler — this one only changes the default flag,
|
label = settings.InferStorageLabel(sp.Path)
|
||||||
does NOT need a FileBrowser sync (mounts don't change)
|
}
|
||||||
|
|
||||||
#### 4. Handle edge case: FileBrowser not deployed
|
result = append(result, StaleStorageData{
|
||||||
|
Path: sp.Path,
|
||||||
|
Label: label,
|
||||||
|
Mounts: existingMounts,
|
||||||
|
SizeHuman: dirSizeBytesHuman(totalSize),
|
||||||
|
SizeBytes: totalSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
The `syncFileBrowserMounts` function already checks if the compose file exists.
|
return result
|
||||||
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
|
|
||||||
# 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:**
|
|
||||||
```html
|
|
||||||
<span class="badge badge-danger">Nincs csatolva!</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
```html
|
|
||||||
<span class="badge badge-warn">Rendszermeghajtón</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
Add badge style if `badge-warn` doesn't exist in `style.css`:
|
|
||||||
```css
|
|
||||||
.badge-warn {
|
|
||||||
background: rgba(250, 204, 21, 0.15);
|
|
||||||
color: #facc15;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
#### Change 4: Add stale data cleanup API handler (Task 1)
|
||||||
|
|
||||||
## UI Fix 2: Init progress bar — remove disk usage zone gradient
|
Add to storage API handler switch in `storageAPIHandler()`:
|
||||||
|
|
||||||
### Problem
|
```go
|
||||||
The storage init progress bar uses the disk-usage bar style which has
|
case path == "/api/storage/stale-cleanup" && r.Method == http.MethodPost:
|
||||||
green→yellow→red gradient zones. At 30% progress it looks alarming. A task
|
s.staleDataCleanupHandler(w, r)
|
||||||
progress bar should be a simple single-color fill on neutral background.
|
```
|
||||||
|
|
||||||
### Where
|
Add the handler function:
|
||||||
`controller/internal/web/templates/storage_init.html` — find the progress bar.
|
|
||||||
`controller/internal/web/templates/style.css` — add new class.
|
|
||||||
|
|
||||||
### Fix
|
```go
|
||||||
Add a new CSS class for task progress (not disk usage):
|
// staleDataCleanupHandler handles POST /api/storage/stale-cleanup.
|
||||||
|
// Deletes leftover app data from a previous storage path after migration.
|
||||||
|
func (s *Server) staleDataCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
StackName string `json:"stack_name"`
|
||||||
|
StalePath string `json:"stale_path"` // the old storage root, e.g., "/mnt/hdd_placeholder"
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
jsonError(w, "Érvénytelen kérés", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
```css
|
if req.StackName == "" || req.StalePath == "" {
|
||||||
.progress-bar-task {
|
jsonError(w, "Hiányos paraméterek", http.StatusBadRequest)
|
||||||
height: 8px;
|
return
|
||||||
background: var(--border);
|
}
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar-task .progress-fill {
|
// Verify the app exists and is deployed
|
||||||
height: 100%;
|
stack, ok := s.stackMgr.GetStack(req.StackName)
|
||||||
background: var(--accent);
|
if !ok {
|
||||||
border-radius: 4px;
|
jsonError(w, "Alkalmazás nem található: "+req.StackName, http.StatusNotFound)
|
||||||
transition: width 0.3s ease;
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg := s.stackMgr.LoadAppConfigByName(req.StackName)
|
||||||
|
if appCfg == nil || !appCfg.Deployed {
|
||||||
|
jsonError(w, "Az alkalmazás nincs telepítve", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHDDPath := appCfg.Env["HDD_PATH"]
|
||||||
|
if currentHDDPath == "" {
|
||||||
|
jsonError(w, "Az alkalmazásnak nincs HDD_PATH beállítva", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: StalePath must NOT be the current HDD_PATH
|
||||||
|
if req.StalePath == currentHDDPath {
|
||||||
|
jsonError(w, "Az aktív tárhely adatai nem törölhetők! Ez az alkalmazás aktuális adattárolója.", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: StalePath must be a registered storage path
|
||||||
|
found := false
|
||||||
|
for _, sp := range s.settings.GetStoragePaths() {
|
||||||
|
if sp.Path == req.StalePath {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonError(w, "A megadott útvonal nem regisztrált adattároló", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find mounts to delete
|
||||||
|
mounts := stacks.ParseComposeHDDMounts(stack.ComposePath, req.StalePath)
|
||||||
|
if len(mounts) == 0 {
|
||||||
|
jsonError(w, "Nem találhatók törlendő adatok", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected paths check
|
||||||
|
protected := protectedHDDPaths(req.StalePath)
|
||||||
|
|
||||||
|
var deleted []string
|
||||||
|
var errors []string
|
||||||
|
var totalFreed int64
|
||||||
|
|
||||||
|
for _, mountPath := range mounts {
|
||||||
|
cleanPath := filepath.Clean(mountPath)
|
||||||
|
|
||||||
|
// Safety: never delete protected top-level dirs
|
||||||
|
if protected != nil && protected[cleanPath] {
|
||||||
|
s.logger.Printf("[WARN] Refusing to delete protected HDD path: %s", cleanPath)
|
||||||
|
errors = append(errors, fmt.Sprintf("Védett útvonal, nem törölhető: %s", cleanPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it actually exists and has data
|
||||||
|
info, err := os.Stat(cleanPath)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
size := dirSizeInt64(cleanPath)
|
||||||
|
|
||||||
|
if err := os.RemoveAll(cleanPath); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] Failed to remove stale data %s: %v", cleanPath, err)
|
||||||
|
errors = append(errors, fmt.Sprintf("Törlés sikertelen: %s — %v", cleanPath, err))
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[INFO] Removed stale data: %s (%s) for stack %s", cleanPath, dirSizeBytesHuman(size), req.StackName)
|
||||||
|
deleted = append(deleted, cleanPath)
|
||||||
|
totalFreed += size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(deleted) == 0 && len(errors) > 0 {
|
||||||
|
jsonError(w, "Törlés sikertelen: "+strings.Join(errors, "; "), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(w, map[string]interface{}{
|
||||||
|
"ok": true,
|
||||||
|
"deleted": deleted,
|
||||||
|
"freed_human": dirSizeBytesHuman(totalFreed),
|
||||||
|
"errors": errors,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
In `storage_init.html`, replace the current progress bar element (which reuses the
|
Note: `protectedHDDPaths` is in `internal/stacks/delete.go` — you may need to either export it or duplicate the logic. Since it's a simple function, the cleanest approach is to either:
|
||||||
disk usage bar class) with:
|
- Export it from stacks package (`ProtectedHDDPaths`)
|
||||||
|
- Or inline the same logic in handlers.go
|
||||||
|
|
||||||
|
For simplicity, since the web package already imports stacks, export it:
|
||||||
|
|
||||||
|
In `internal/stacks/delete.go`:
|
||||||
|
```go
|
||||||
|
// BEFORE:
|
||||||
|
func protectedHDDPaths(hddPath string) map[string]bool {
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
func ProtectedHDDPaths(hddPath string) map[string]bool {
|
||||||
|
```
|
||||||
|
|
||||||
|
And update the existing call in `DeleteStack`:
|
||||||
|
```go
|
||||||
|
// BEFORE:
|
||||||
|
protected := protectedHDDPaths(hddPath)
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
protected := ProtectedHDDPaths(hddPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in handlers.go, the call becomes:
|
||||||
|
```go
|
||||||
|
protected := stacks.ProtectedHDDPaths(req.StalePath)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 5: Sync FileBrowser after migration (Task 2)
|
||||||
|
|
||||||
|
In `storageMigrateAPIHandler`, in the goroutine where migration runs, add FileBrowser sync after success:
|
||||||
|
|
||||||
|
```go
|
||||||
|
go func() {
|
||||||
|
progressCh := make(chan storage.MigrateProgress, 64)
|
||||||
|
go func() {
|
||||||
|
for p := range progressCh {
|
||||||
|
job.appendMigProg(p)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := storage.MigrateAppData(migrReq, stopFn, startFn, updateFn, progressCh); err != nil {
|
||||||
|
s.logger.Printf("[ERROR] Migration failed: stack=%s: %v", req.StackName, err)
|
||||||
|
} else {
|
||||||
|
s.logger.Printf("[INFO] Migration complete: stack=%s → %s", req.StackName, req.TargetPath)
|
||||||
|
// Sync FileBrowser mounts (storage paths may now have new app data)
|
||||||
|
go s.syncFileBrowserMounts()
|
||||||
|
}
|
||||||
|
close(progressCh)
|
||||||
|
}()
|
||||||
|
```
|
||||||
|
|
||||||
|
### `internal/web/templates/deploy.html`
|
||||||
|
|
||||||
|
#### Change 1: Dynamic title (Task 3)
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<div class="progress-bar-task">
|
<!-- BEFORE (line 15717): -->
|
||||||
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
|
||||||
|
|
||||||
|
<!-- AFTER: -->
|
||||||
|
<h2>{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}Beállítások{{else}}Telepítés{{end}}</h2>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 2: Stale data card (Task 1)
|
||||||
|
|
||||||
|
Add after the StorageInfo block (after the `{{end}}` that closes `{{if .StorageInfo}}`) and before the closing `{{end}}` of `{{if .AlreadyDeployed}}`:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{{if .StaleData}}
|
||||||
|
<div class="deploy-stale-data">
|
||||||
|
<h4>🗑️ Korábbi adatok</h4>
|
||||||
|
<p class="form-hint" style="margin-bottom:1rem">
|
||||||
|
Az alkalmazás adatainak másolata megtalálható egy másik tárolón is.
|
||||||
|
Ez általában áthelyezés után marad hátra.
|
||||||
|
</p>
|
||||||
|
{{range .StaleData}}
|
||||||
|
<div class="stale-data-item">
|
||||||
|
<div class="settings-grid" style="margin-bottom:.75rem">
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Tárhely</span>
|
||||||
|
<span class="settings-value">{{.Label}} <span class="mono" style="color:var(--text-secondary)">({{.Path}})</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Méret</span>
|
||||||
|
<span class="settings-value mono">{{.SizeHuman}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<span class="settings-label">Mappák</span>
|
||||||
|
<span class="settings-value mono" style="font-size:.85rem">{{range .Mounts}}{{.}}<br>{{end}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteStaleData('{{$.Meta.Slug}}', '{{.Path}}', this)">
|
||||||
|
🗑️ Korábbi adatok törlése
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Change 3: Add stale data delete JS function
|
||||||
|
|
||||||
|
Add to the `<script>` section at the bottom of deploy.html:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function deleteStaleData(stackName, stalePath, btn) {
|
||||||
|
if (!confirm('Biztosan törölni szeretnéd a korábbi adatokat?\n\nTárhely: ' + stalePath + '\n\n⚠️ Ez a művelet visszavonhatatlan!\nElőtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Second confirmation
|
||||||
|
if (!confirm('UTOLSÓ FIGYELMEZTETÉS!\n\nA törlés visszavonhatatlan. Biztosan folytatod?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Törlés folyamatban...';
|
||||||
|
|
||||||
|
fetch('/api/storage/stale-cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🗑️ Korábbi adatok törlése';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var msg = '✅ Korábbi adatok törölve!\n\nFelszabadított hely: ' + (data.freed_human || '?');
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
msg += '\n\n⚠️ Néhány hiba történt:\n' + data.errors.join('\n');
|
||||||
|
}
|
||||||
|
alert(msg);
|
||||||
|
// Remove the stale data card from DOM
|
||||||
|
var item = btn.closest('.stale-data-item');
|
||||||
|
if (item) item.remove();
|
||||||
|
// If no more stale items, remove the whole section
|
||||||
|
var container = document.querySelector('.deploy-stale-data');
|
||||||
|
if (container && container.querySelectorAll('.stale-data-item').length === 0) {
|
||||||
|
container.remove();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
alert('Hálózati hiba: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🗑️ Korábbi adatok törlése';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `internal/web/templates/migrate.html`
|
||||||
|
|
||||||
|
#### Change: Add delete old data button to migration-done card (Task 1)
|
||||||
|
|
||||||
|
Replace the `migrate-done-card` div:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="settings-card" id="migrate-done-card" style="display:none">
|
||||||
|
<h3>✅ Adatáthelyezés kész!</h3>
|
||||||
|
<p style="margin-top:.75rem;color:var(--text-secondary)">
|
||||||
|
Az alkalmazás az új tárolóról fut.<br>
|
||||||
|
A régi adatok a korábbi helyen megmaradtak biztonsági másolatként.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-warning" style="margin-top:1rem">
|
||||||
|
<strong>Javasolt lépések:</strong>
|
||||||
|
<ol style="margin:.5rem 0 0 1rem;padding:0">
|
||||||
|
<li>Ellenőrizd, hogy az alkalmazás megfelelően működik</li>
|
||||||
|
<li>Győződj meg róla, hogy minden adat megtalálható</li>
|
||||||
|
<li>Ha minden rendben, törölheted a korábbi adatokat</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:1.5rem;display:flex;gap:.75rem;flex-wrap:wrap">
|
||||||
|
<a href="/stacks/{{.Meta.Slug}}/deploy" class="btn btn-primary">Alkalmazások megtekintése</a>
|
||||||
|
<button id="migrate-delete-old-btn" class="btn btn-outline btn-danger" onclick="deleteOldMigrationData()" style="display:none">
|
||||||
|
🗑️ Korábbi adatok törlése
|
||||||
|
</button>
|
||||||
|
<a href="/settings" class="btn btn-outline">Beállítások</a>
|
||||||
|
</div>
|
||||||
</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:
|
Add to the `<script>` section, update `showMigDone()`:
|
||||||
```js
|
|
||||||
document.getElementById('progress-fill').style.width = data.percent + '%';
|
```javascript
|
||||||
document.getElementById('progress-percent').textContent = data.percent + '%';
|
function showMigDone() {
|
||||||
|
document.getElementById('migrate-progress-card').style.display = 'none';
|
||||||
|
document.getElementById('migrate-done-card').style.display = 'block';
|
||||||
|
document.getElementById('migrate-done-card').scrollIntoView({behavior:'smooth'});
|
||||||
|
// Show the delete button (old data is at the source path)
|
||||||
|
document.getElementById('migrate-delete-old-btn').style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteOldMigrationData() {
|
||||||
|
var oldPath = '{{.CurrentHDDPath}}';
|
||||||
|
if (!confirm('Biztosan törölni szeretnéd a korábbi adatokat?\n\nTárhely: ' + oldPath + '\n\n⚠️ Ez a művelet visszavonhatatlan!\nElőtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!confirm('UTOLSÓ FIGYELMEZTETÉS!\n\nA törlés visszavonhatatlan. Biztosan folytatod?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var btn = document.getElementById('migrate-delete-old-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Törlés folyamatban...';
|
||||||
|
|
||||||
|
fetch('/api/storage/stale-cleanup', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({stack_name: stackName, stale_path: oldPath})
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.ok) {
|
||||||
|
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🗑️ Korábbi adatok törlése';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.textContent = '✅ Korábbi adatok törölve (' + (data.freed_human || '') + ')';
|
||||||
|
btn.classList.remove('btn-danger');
|
||||||
|
btn.classList.add('btn-outline');
|
||||||
|
btn.onclick = null;
|
||||||
|
})
|
||||||
|
.catch(function(e) {
|
||||||
|
alert('Hálózati hiba: ' + e.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🗑️ Korábbi adatok törlése';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `internal/web/templates/style.css`
|
||||||
|
|
||||||
|
Add stale data styling:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Stale data cleanup */
|
||||||
|
.deploy-stale-data {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--orange);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.deploy-stale-data h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
.stale-data-item {
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 165, 0, 0.05);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.stale-data-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--red);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--red);
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `internal/stacks/delete.go` — Export `protectedHDDPaths`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// BEFORE:
|
||||||
|
func protectedHDDPaths(hddPath string) map[string]bool {
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
func ProtectedHDDPaths(hddPath string) map[string]bool {
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the call in `DeleteStack`:
|
||||||
|
```go
|
||||||
|
// BEFORE:
|
||||||
|
protected := protectedHDDPaths(hddPath)
|
||||||
|
|
||||||
|
// AFTER:
|
||||||
|
protected := ProtectedHDDPaths(hddPath)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## UI Fix 3: "Alapértelmezett" button → "Legyen alapértelmezett"
|
## Changelog entry
|
||||||
|
|
||||||
### Problem
|
```
|
||||||
Button text "Alapértelmezett" reads as a status label ("Default"), not an action.
|
- **0.11.7 — Stale Data Cleanup + FileBrowser Sync + UI Polish:**
|
||||||
Users think the drive IS the default, not that clicking MAKES it the default.
|
- **Feature: Stale data cleanup** — After app data migration, the deploy/settings page now shows leftover data on previous storage paths with size info and a delete button. Two-step confirmation required before deletion. Protected paths (top-level storage, media, Dokumentumok, appdata) cannot be deleted. Also available immediately after migration on the migration-done page.
|
||||||
|
- **Fix: FileBrowser sync after migration** — `syncFileBrowserMounts()` now called after successful data migration, ensuring FileBrowser mounts reflect the current storage layout.
|
||||||
### Where
|
- **Fix: Deploy page title** — Already-deployed apps now show "Beállítások" (Settings) instead of "Telepítés" (Deploy) in both the page title and the `<h2>` heading.
|
||||||
`controller/internal/web/templates/settings.html` — find the button for setting
|
- **Internal: Exported `ProtectedHDDPaths()`** from stacks package for reuse in web handlers.
|
||||||
a non-default storage path as default.
|
- **Files modified (8):** `handlers.go`, `server.go`, `delete.go`, `deploy.html`, `migrate.html`, `style.css`, `router.go` (if adding route there, but actually it's in storageAPIHandler)
|
||||||
|
```
|
||||||
### 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
|
## Testing Checklist
|
||||||
|
|
||||||
| File | Change | Type |
|
1. **Stale data detection:**
|
||||||
|------|--------|------|
|
- Deploy immich on hdd_placeholder
|
||||||
| `handlers.go` | New `syncFileBrowserMounts()` + `generateFileBrowserCompose()` | Feature |
|
- Migrate to hdd_1
|
||||||
| `handlers.go` | Call `syncFileBrowserMounts()` after `AddStoragePath` | Feature |
|
- Visit immich deploy/settings page → should show "Korábbi adatok" card with hdd_placeholder data size
|
||||||
| `settings.html` | Red "Nincs csatolva!" → yellow "Rendszermeghajtón" | UI fix |
|
- Delete the stale data → confirm both prompts → data removed, card disappears
|
||||||
| `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
|
2. **Migration-done page cleanup:**
|
||||||
- `.felhom.yml` for FileBrowser — unchanged
|
- Start a new migration
|
||||||
- `.env` for FileBrowser — unchanged (domain read from it, not written)
|
- After completion, "Korábbi adatok törlése" button should appear
|
||||||
- `docker-setup.sh` FileBrowser install function — still works for initial install;
|
- Click → confirm → old data deleted
|
||||||
controller takes over compose management after first storage init
|
|
||||||
- Go storage package — no changes
|
3. **FileBrowser sync:**
|
||||||
|
- After migration, check `docker logs felhom-controller | grep FileBrowser`
|
||||||
|
- Verify FileBrowser compose was regenerated
|
||||||
|
|
||||||
|
4. **Title fix:**
|
||||||
|
- Visit a non-deployed app → title shows "Telepítés"
|
||||||
|
- Visit a deployed app → title shows "Beállítások"
|
||||||
|
|
||||||
|
5. **Safety:**
|
||||||
|
- Try to delete active HDD path via API → should be rejected (403)
|
||||||
|
- Try to delete unregistered path → should be rejected (400)
|
||||||
|
- Protected paths (storage root, media root) should never be deleted
|
||||||
Reference in New Issue
Block a user