Files
deploy-felhom-compose/TASK.md
T

11 KiB

TASK.md — v0.4.7: Protected Stack Detail Pages + Backup Page Caching

Version bump: v0.4.7 Scope: UX fix + performance fix


Task 1: Protected stacks — enable detail page + click-through

Problem

Protected stacks (filebrowser, traefik, cloudflared, felhom-controller) are excluded from the app detail page in two ways:

  1. No click-through: data-href="/apps/{{.Meta.Slug}}" is gated behind {{if not .Protected}} on both dashboard.html and stacks.html
  2. No "Részletek" button: The protected section in stacks.html only shows "Védett rendszerkomponens" badge + restart button — no "Részletek" link
  3. Detail page works: Manually navigating to /apps/filebrowser renders fine — so the handler and template already support it

Fix — Templates

stacks.html: Add data-href for protected stacks that have a slug, and add "Részletek" button:

Change the card opening div from:

<div class="stack-detail-card ..." {{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>

To:

<div class="stack-detail-card ..." {{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>

This lets any stack with a slug (including protected ones with .felhom.yml) be clickable. Stacks without .felhom.yml (no slug) won't have the click handler — which is correct.

In the protected actions section, add "Részletek" link:

{{if .Protected}}
    <span class="badge badge-protected">Védett rendszerkomponens</span>
    {{if isOperational .State}}
        <button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
    {{end}}
    {{if .Meta.Slug}}
        <a href="/apps/{{.Meta.Slug}}" class="btn btn-outline">Részletek</a>
    {{end}}
{{else if not .Deployed}}

dashboard.html: Same data-href fix for the compact card:

Change from:

<div class="stack-card ..." {{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}>

To:

<div class="stack-card ..." {{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>

Fix — FileBrowser .felhom.yml (resources)

The manually created .felhom.yml on the demo node is missing resources. Update it to include:

resources:
  mem_request: "128M"
  mem_limit: "256M"
  pi_compatible: true
  needs_hdd: true

Also add this to the .felhom.yml created by install_filebrowser() in scripts/docker-setup.sh.

Manual fix for demo node (run after deploy):

cat >> /opt/docker/stacks/filebrowser/.felhom.yml << 'EOF'
resources:
  mem_request: "128M"
  mem_limit: "256M"
  pi_compatible: true
  needs_hdd: true
EOF

Verification

  • Clicking FileBrowser card on Alkalmazások page opens /apps/filebrowser detail page
  • "Részletek" button appears next to "Újraindítás" on FileBrowser
  • Detail page shows memory badges (~128M, HDD szükséges, Pi kompatibilis)
  • App info section shows use cases, first steps, prerequisites
  • Other protected stacks without .felhom.yml (traefik, cloudflared) don't show "Részletek" (no slug)

Task 2: Backup page — cache expensive data

Problem

GetFullStatus() is called synchronously on every page load of /backups and runs three blocking subprocess calls:

  1. m.restic.Stats() — executes restic stats --json + restic snapshots --json (~2-3 seconds)
  2. ListDumpFiles() — directory listing (fast, ~1ms)
  3. DiscoverDatabases()docker ps + docker inspect per container (~0.5s)

Total: 3-4 seconds per page load. This is unacceptable for a dashboard page.

Solution: Background cache with periodic refresh

Add a cached FullBackupStatus to the Manager that refreshes periodically via the scheduler, instead of computing on every page load.

New fields in Manager:

type Manager struct {
    // ... existing fields ...

    mu              sync.Mutex
    lastDBDump      *DBDumpStatus
    lastBackup      *BackupStatus
    running         bool
    snapshotHistory []SnapshotRecord

    // Cached status for page rendering
    cachedStatus    *FullBackupStatus
    cacheTime       time.Time
}

New method: RefreshCache()

// RefreshCache updates the cached full status. Called by scheduler every 5 minutes
// and after each backup run.
func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
    // Same logic as current GetFullStatus() — run restic stats, list dump files, discover DBs
    status := &FullBackupStatus{ ... }

    // ... all the expensive calls ...

    m.mu.Lock()
    m.cachedStatus = status
    m.cacheTime = time.Now()
    m.mu.Unlock()

    m.logger.Printf("[INFO] Backup status cache refreshed")
}

Modified GetFullStatus(): read from cache

// GetFullStatus returns the cached backup status for page rendering.
// Returns instantly — no subprocess calls.
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus {
    m.mu.Lock()
    defer m.mu.Unlock()

    if m.cachedStatus != nil {
        // Update dynamic fields that don't need subprocess calls
        m.cachedStatus.Running = m.running
        m.cachedStatus.NextDBDump = nextDBDump
        m.cachedStatus.NextBackup = nextBackup
        m.cachedStatus.LastDBDump = m.lastDBDump
        m.cachedStatus.LastBackup = m.lastBackup
        // Update snapshot history
        m.cachedStatus.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
        copy(m.cachedStatus.SnapshotHistory, m.snapshotHistory)
        return m.cachedStatus
    }

    // No cache yet — return a minimal status (first page load before cache is populated)
    return &FullBackupStatus{
        Enabled:        m.cfg.Backup.Enabled,
        Running:        m.running,
        LastDBDump:     m.lastDBDump,
        LastBackup:     m.lastBackup,
        DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
        ResticSchedule: m.cfg.Backup.ResticSchedule,
        PruneSchedule:  m.cfg.Backup.PruneSchedule,
        NextDBDump:     nextDBDump,
        NextBackup:     nextBackup,
        Retention:      m.cfg.Backup.Retention,
        RepoPath:       m.cfg.Backup.ResticRepo,
        LastCheckTime:  m.lastCheckTime,
        LastCheckOK:    m.lastCheckOK,
        BackupPaths: []string{
            m.cfg.Paths.StacksDir,
            m.cfg.Paths.DBDumpDir,
            "/opt/docker/felhom-controller/controller.yaml",
        },
    }
}

Scheduler integration (in main.go):

Register a periodic job that refreshes the backup cache:

if cfg.Backup.Enabled && backupMgr != nil {
    // ... existing daily jobs ...

    // Cache refresh: every 5 minutes
    sched.Every("backup-cache", 5*time.Minute, func(ctx context.Context) error {
        nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
        nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
        backupMgr.RefreshCache(nextDBDump, nextBackup)
        return nil
    })
}

Refresh after backup completion:

At the end of RunFullBackup() and RunBackup(), call RefreshCache() so the page shows updated data immediately after a backup:

func (m *Manager) RunFullBackup(ctx context.Context) error {
    // ... existing logic ...

    // Refresh cache after backup completes
    m.RefreshCache(
        scheduler.NextDailyRun(m.cfg.Backup.DBDumpSchedule),
        scheduler.NextDailyRun(m.cfg.Backup.ResticSchedule),
    )
    return nil  // or the backup error
}

Note: RefreshCache() needs to import scheduler.NextDailyRun. To avoid a circular import (backup → scheduler → backup), either:

  • Pass the next run times as parameters (already the pattern used)
  • Make NextDailyRun a standalone utility function that both packages can import
  • Or just call RefreshCache from main.go via a callback

Simplest approach: RefreshCache takes nextDBDump, nextBackup time.Time params (same as GetFullStatus). The scheduler job and the post-backup refresh both compute the times before calling.

Initial cache population on startup:

In main.go, after scheduler starts, trigger initial cache refresh in a goroutine (don't block startup):

if cfg.Backup.Enabled && backupMgr != nil {
    go func() {
        nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
        nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
        backupMgr.RefreshCache(nextDBDump, nextBackup)
    }()
}

Result

  • Page load: instant (reads cached struct)
  • Cache refresh: every 5 minutes in background (user never waits)
  • After manual backup: cache refreshes immediately
  • First page load after startup: may show minimal data for a few seconds until goroutine completes

Cache staleness indicator (optional)

Add CacheTime time.Time to FullBackupStatus. The template can optionally show "Utolsó frissítés: X perccel ezelőtt" at the bottom of the page in a muted font. Not critical, but helpful for debugging.


Implementation order

Step 1: Protected stack detail pages

  1. Fix data-href gating in stacks.html and dashboard.html — use {{if .Meta.Slug}} instead of {{if not .Protected}}
  2. Add "Részletek" button to protected stack section in stacks.html
  3. Update install_filebrowser() in docker-setup.sh — add resources to .felhom.yml

Step 2: Backup page caching

  1. Add cachedStatus, cacheTime fields to Manager
  2. Create RefreshCache() method
  3. Modify GetFullStatus() to read from cache
  4. Register backup-cache scheduler job in main.go
  5. Call RefreshCache() at end of RunFullBackup() and RunBackup()
  6. Add initial cache goroutine in main.go

Step 3: Build, deploy, verify

  1. Build v0.4.7
  2. Deploy to demo node
  3. Update /opt/docker/stacks/filebrowser/.felhom.yml on demo node (add resources)
  4. Verify FileBrowser card is clickable → detail page with memory badges
  5. Verify backup page loads instantly
  6. Trigger manual backup → verify page updates after completion

Step 4: Documentation

  1. Update CONTEXT.md, README
  2. Bump version

Files to modify

internal/backup/backup.go                    — add cachedStatus, RefreshCache(), modify GetFullStatus()
internal/web/templates/stacks.html           — fix data-href gating, add Részletek button
internal/web/templates/dashboard.html        — fix data-href gating
scripts/docker-setup.sh                      — add resources to filebrowser .felhom.yml
cmd/controller/main.go                       — register backup-cache job, initial goroutine

Verification checklist

  • FileBrowser card on Alkalmazások page is clickable → opens /apps/filebrowser
  • FileBrowser has "Részletek" button next to "Újraindítás"
  • FileBrowser detail page shows ~128M / HDD szükséges / Pi kompatibilis badges
  • FileBrowser detail page shows use cases, first steps, prerequisites
  • Traefik/cloudflared cards do NOT show "Részletek" (no .felhom.yml/slug)
  • Felhom-controller card does NOT show "Részletek" (no .felhom.yml)
  • Backup page loads in <500ms (instant, cached)
  • Backup page shows correct data after initial cache population
  • Manual backup → page shows updated data after completion
  • Cache refreshes every 5 minutes (check logs for "[INFO] Backup status cache refreshed")
  • No regressions on dashboard, app detail pages, deploy flow