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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user