v0.4.7: Protected stack detail pages + backup page caching

Task 1: Protected stacks with .felhom.yml (slug) are now clickable
on both dashboard and stacks pages. "Részletek" button added to
protected stack actions section. Filebrowser .felhom.yml updated
with resources metadata.

Task 2: Backup page now reads from a cached FullBackupStatus that
refreshes every 5 minutes in background + after each backup run.
Page loads instantly instead of blocking on restic/docker subprocesses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 09:02:54 +01:00
parent 79da0b88aa
commit 3be989f665
5 changed files with 122 additions and 35 deletions
+22
View File
@@ -81,6 +81,11 @@ func main() {
var backupMgr *backup.Manager var backupMgr *backup.Manager
if cfg.Backup.Enabled { if cfg.Backup.Enabled {
backupMgr = backup.NewManager(cfg, pinger, logger) backupMgr = backup.NewManager(cfg, pinger, logger)
backupMgr.AfterBackup = func() {
nextDBDump := scheduler.NextDailyRun(cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(cfg.Backup.ResticSchedule)
backupMgr.RefreshCache(nextDBDump, nextBackup)
}
go backupMgr.LoadSnapshotHistory() go backupMgr.LoadSnapshotHistory()
} }
@@ -120,11 +125,28 @@ func main() {
sched.Daily("backup", cfg.Backup.ResticSchedule, func(ctx context.Context) error { sched.Daily("backup", cfg.Backup.ResticSchedule, func(ctx context.Context) error {
return backupMgr.RunBackup(ctx) return backupMgr.RunBackup(ctx)
}) })
// 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
})
} }
sched.Start(ctx) sched.Start(ctx)
defer sched.Stop() defer sched.Stop()
// Initial backup cache population (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)
}()
}
// --- Initialize API router --- // --- Initialize API router ---
apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, logger) apiRouter := api.NewRouter(cfg, stackMgr, syncer, cpuCollector, backupMgr, logger)
+87 -30
View File
@@ -26,6 +26,14 @@ type Manager struct {
snapshotHistory []SnapshotRecord // ring buffer, last 20 entries snapshotHistory []SnapshotRecord // ring buffer, last 20 entries
lastCheckTime time.Time lastCheckTime time.Time
lastCheckOK bool lastCheckOK bool
// Cached status for page rendering (refreshed periodically)
cachedStatus *FullBackupStatus
cacheTime time.Time
// AfterBackup is called after a backup completes to refresh the cache.
// Set by main.go to avoid circular import with scheduler.
AfterBackup func()
} }
// SnapshotRecord combines restic snapshot metadata with our run stats. // SnapshotRecord combines restic snapshot metadata with our run stats.
@@ -254,6 +262,11 @@ func (m *Manager) RunBackup(ctx context.Context) error {
result.SnapshotID, result.FilesNew, result.FilesChanged, result.DataAdded, result.SnapshotID, result.FilesNew, result.FilesChanged, result.DataAdded,
duration.Round(time.Millisecond)) duration.Round(time.Millisecond))
// Refresh cache so the page shows updated data immediately
if m.AfterBackup != nil {
m.AfterBackup()
}
return nil return nil
} }
@@ -351,14 +364,11 @@ func (m *Manager) LoadSnapshotHistory() {
m.logger.Printf("[INFO] Loaded %d historical snapshots", len(m.snapshotHistory)) m.logger.Printf("[INFO] Loaded %d historical snapshots", len(m.snapshotHistory))
} }
// GetFullStatus returns everything the backup page needs. // RefreshCache updates the cached full status. Called by scheduler every 5 minutes
func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupStatus { // and after each backup run.
m.mu.Lock() func (m *Manager) RefreshCache(nextDBDump, nextBackup time.Time) {
status := &FullBackupStatus{ status := &FullBackupStatus{
Enabled: m.cfg.Backup.Enabled, Enabled: m.cfg.Backup.Enabled,
Running: m.running,
LastDBDump: m.lastDBDump,
LastBackup: m.lastBackup,
DBDumpSchedule: m.cfg.Backup.DBDumpSchedule, DBDumpSchedule: m.cfg.Backup.DBDumpSchedule,
ResticSchedule: m.cfg.Backup.ResticSchedule, ResticSchedule: m.cfg.Backup.ResticSchedule,
@@ -368,12 +378,37 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
Retention: m.cfg.Backup.Retention, Retention: m.cfg.Backup.Retention,
RepoPath: m.cfg.Backup.ResticRepo, RepoPath: m.cfg.Backup.ResticRepo,
LastCheckTime: m.lastCheckTime, BackupPaths: []string{
LastCheckOK: m.lastCheckOK, m.cfg.Paths.StacksDir,
m.cfg.Paths.DBDumpDir,
"/opt/docker/felhom-controller/controller.yaml",
},
} }
// Copy snapshot history
// Expensive calls (outside lock)
if stats, err := m.restic.Stats(); err == nil {
status.RepoStats = stats
}
if files, err := ListDumpFiles(m.cfg.Paths.DBDumpDir); err == nil {
status.DumpFiles = files
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if dbs, err := DiscoverDatabases(ctx, m.logger); err == nil {
status.DiscoveredDBs = dbs
}
// Fill in dynamic fields under lock
m.mu.Lock()
status.Running = m.running
status.LastDBDump = m.lastDBDump
status.LastBackup = m.lastBackup
status.LastCheckTime = m.lastCheckTime
status.LastCheckOK = m.lastCheckOK
status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory)) status.SnapshotHistory = make([]SnapshotRecord, len(m.snapshotHistory))
copy(status.SnapshotHistory, m.snapshotHistory) copy(status.SnapshotHistory, m.snapshotHistory)
m.cachedStatus = status
m.cacheTime = time.Now()
m.mu.Unlock() m.mu.Unlock()
// Reverse so newest first // Reverse so newest first
@@ -381,31 +416,53 @@ func (m *Manager) GetFullStatus(nextDBDump, nextBackup time.Time) *FullBackupSta
status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i] status.SnapshotHistory[i], status.SnapshotHistory[j] = status.SnapshotHistory[j], status.SnapshotHistory[i]
} }
// Backup paths m.logger.Printf("[INFO] Backup status cache refreshed")
status.BackupPaths = []string{ }
// 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)
// Reverse so newest first
for i, j := 0, len(m.cachedStatus.SnapshotHistory)-1; i < j; i, j = i+1, j-1 {
m.cachedStatus.SnapshotHistory[i], m.cachedStatus.SnapshotHistory[j] = m.cachedStatus.SnapshotHistory[j], m.cachedStatus.SnapshotHistory[i]
}
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.StacksDir,
m.cfg.Paths.DBDumpDir, m.cfg.Paths.DBDumpDir,
"/opt/docker/felhom-controller/controller.yaml", "/opt/docker/felhom-controller/controller.yaml",
},
} }
// Get repo stats (non-locked)
if stats, err := m.restic.Stats(); err == nil {
status.RepoStats = stats
}
// List dump files from disk
if files, err := ListDumpFiles(m.cfg.Paths.DBDumpDir); err == nil {
status.DumpFiles = files
}
// Discover running DBs
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if dbs, err := DiscoverDatabases(ctx, m.logger); err == nil {
status.DiscoveredDBs = dbs
}
return status
} }
func dbNames(dbs []DiscoveredDB) string { func dbNames(dbs []DiscoveredDB) string {
@@ -124,7 +124,7 @@
<div class="stack-list"> <div class="stack-list">
{{range .Stacks}} {{range .Stacks}}
<div class="stack-card stack-state-{{stateColor .State}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}> <div class="stack-card stack-state-{{stateColor .State}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-info"> <div class="stack-info">
<img class="stack-logo" src="{{logoURL .Meta.Slug}}" <img class="stack-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'"> alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
@@ -17,7 +17,7 @@
<div class="stack-grid"> <div class="stack-grid">
{{range .Stacks}} {{range .Stacks}}
<div class="stack-detail-card stack-state-{{stateColor .State}}" data-filter-state="{{filterCategory .State .Deployed}}"{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}> <div class="stack-detail-card stack-state-{{stateColor .State}}" data-filter-state="{{filterCategory .State .Deployed}}"{{if .Meta.Slug}} data-href="/apps/{{.Meta.Slug}}"{{end}}>
<div class="stack-detail-header"> <div class="stack-detail-header">
<div class="stack-title-row"> <div class="stack-title-row">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}" <img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
@@ -62,6 +62,9 @@
{{if isOperational .State}} {{if isOperational .State}}
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button> <button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">Újraindítás</button>
{{end}} {{end}}
{{if .Meta.Slug}}
<a href="/apps/{{.Meta.Slug}}" class="btn btn-outline">Részletek</a>
{{end}}
{{else if not .Deployed}} {{else if not .Deployed}}
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a> <a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a> <a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">Részletek</a>
+5
View File
@@ -1330,6 +1330,11 @@ slug: filebrowser
description: Fájlkezelő a külső merevlemezhez description: Fájlkezelő a külső merevlemezhez
subdomain: files subdomain: files
category: storage category: storage
resources:
mem_request: "128M"
mem_limit: "256M"
pi_compatible: true
needs_hdd: true
app_info: app_info:
tagline: Web-alapú fájlkezelő a külső merevlemezhez tagline: Web-alapú fájlkezelő a külső merevlemezhez
use_cases: use_cases: