bdbe170a54
New storage watchdog monitors registered storage paths every 5s. On disconnect (3 consecutive probe failures), auto-stops affected apps, lazy-unmounts stale VFS entries, fires alerts/notifications/hub report. On reconnect (UUID detected), auto-remounts via fstab, cleans stale restic locks, offers app restart. Safe disconnect UI for USB drives: confirmation dialog, stop apps, sync, unmount. Disconnected state visible across all pages (dashboard, settings, backups, monitoring) with hatched red bars and badges. Backup guards skip disconnected drives. 22 files changed (1 new: monitor/watchdog.go), ~1500 lines added. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
271 lines
9.0 KiB
Go
271 lines
9.0 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/notify"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/selfupdate"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
|
)
|
|
|
|
type Server struct {
|
|
cfg *config.Config
|
|
stackMgr *stacks.Manager
|
|
cpuCollector *system.CPUCollector
|
|
backupMgr *backup.Manager
|
|
crossDriveRunner *backup.CrossDriveRunner
|
|
scheduler *scheduler.Scheduler
|
|
settings *settings.Settings
|
|
alertManager *AlertManager
|
|
notifier *notify.Notifier
|
|
updater *selfupdate.Updater
|
|
logger *log.Logger
|
|
version string
|
|
tmpl *template.Template
|
|
|
|
sessions map[string]*session
|
|
sessionsMu sync.RWMutex
|
|
done chan struct{}
|
|
|
|
// Disk operation state (format/migrate jobs)
|
|
diskJobMu sync.Mutex
|
|
diskJob *activeDiskJob
|
|
|
|
// Active raw mount for the attach wizard (empty when not in use)
|
|
activeRawMount string
|
|
|
|
// DR restore mode state
|
|
restoreMu sync.RWMutex
|
|
restorePlan *backup.RestorePlan
|
|
|
|
// Storage watchdog (set after construction to break init ordering)
|
|
storageWatchdog *monitor.StorageWatchdog
|
|
}
|
|
|
|
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, crossDrive *backup.CrossDriveRunner, sched *scheduler.Scheduler, sett *settings.Settings, alertMgr *AlertManager, notif *notify.Notifier, updater *selfupdate.Updater, logger *log.Logger, version string) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
stackMgr: stackMgr,
|
|
cpuCollector: cpuCollector,
|
|
backupMgr: backupMgr,
|
|
crossDriveRunner: crossDrive,
|
|
scheduler: sched,
|
|
settings: sett,
|
|
alertManager: alertMgr,
|
|
notifier: notif,
|
|
updater: updater,
|
|
logger: logger,
|
|
version: version,
|
|
sessions: make(map[string]*session),
|
|
done: make(chan struct{}),
|
|
}
|
|
s.loadTemplates()
|
|
go s.cleanupSessions()
|
|
|
|
// Log auth source on startup
|
|
if sett != nil && sett.GetPasswordHash() != "" {
|
|
logger.Printf("[INFO] Auth: using password from settings.json")
|
|
} else if cfg.Web.PasswordHash != "" {
|
|
logger.Printf("[INFO] Auth: using password from controller.yaml")
|
|
} else {
|
|
logger.Printf("[INFO] Auth: no password configured — dashboard is open")
|
|
}
|
|
|
|
// Sync FileBrowser config on startup to ensure mounts and sources are current
|
|
go s.syncFileBrowserMounts()
|
|
|
|
return s
|
|
}
|
|
|
|
func (s *Server) loadTemplates() {
|
|
s.tmpl = template.Must(
|
|
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
|
|
)
|
|
}
|
|
|
|
// SetRestoreState puts the server into DR restore mode with the given plan.
|
|
func (s *Server) SetRestoreState(plan *backup.RestorePlan) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
s.restorePlan = plan
|
|
}
|
|
|
|
// SetStorageWatchdog sets the storage watchdog for disconnect/reconnect operations.
|
|
func (s *Server) SetStorageWatchdog(w *monitor.StorageWatchdog) {
|
|
s.storageWatchdog = w
|
|
}
|
|
|
|
// InRestoreMode returns true if the server is in DR restore mode.
|
|
func (s *Server) InRestoreMode() bool {
|
|
s.restoreMu.RLock()
|
|
defer s.restoreMu.RUnlock()
|
|
return s.restorePlan != nil
|
|
}
|
|
|
|
// ServeHTTP handles all non-API web requests.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
|
|
// DR restore mode: intercept all routes except restore page, static, and restore API
|
|
if s.InRestoreMode() {
|
|
switch {
|
|
case path == "/restore":
|
|
s.restorePageHandler(w, r)
|
|
return
|
|
case path == "/api/restore/status":
|
|
s.apiRestoreStatus(w, r)
|
|
return
|
|
case path == "/api/restore/all" && r.Method == http.MethodPost:
|
|
s.apiRestoreAll(w, r)
|
|
return
|
|
case path == "/api/restore/skip" && r.Method == http.MethodPost:
|
|
s.apiRestoreSkip(w, r)
|
|
return
|
|
case strings.HasPrefix(path, "/static/"):
|
|
// Allow static assets through
|
|
default:
|
|
// Redirect everything else to the restore page
|
|
http.Redirect(w, r, "/restore", http.StatusFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case path == "/" || path == "/dashboard":
|
|
s.dashboardHandler(w, r)
|
|
case path == "/stacks":
|
|
s.stacksHandler(w, r)
|
|
case path == "/backups":
|
|
s.backupsHandler(w, r)
|
|
case path == "/monitoring":
|
|
s.monitoringHandler(w, r)
|
|
case path == "/settings":
|
|
s.settingsHandler(w, r)
|
|
case path == "/settings/password" && r.Method == http.MethodPost:
|
|
s.settingsPasswordHandler(w, r)
|
|
case path == "/settings/notifications" && r.Method == http.MethodPost:
|
|
s.settingsNotificationsHandler(w, r)
|
|
case path == "/settings/notifications/test" && r.Method == http.MethodPost:
|
|
s.settingsNotificationsTestHandler(w, r)
|
|
case path == "/settings/storage/add" && r.Method == http.MethodPost:
|
|
s.settingsStorageAddHandler(w, r)
|
|
case path == "/settings/storage/remove" && r.Method == http.MethodPost:
|
|
s.settingsStorageRemoveHandler(w, r)
|
|
case path == "/settings/storage/default" && r.Method == http.MethodPost:
|
|
s.settingsStorageDefaultHandler(w, r)
|
|
case path == "/settings/storage/schedulable" && r.Method == http.MethodPost:
|
|
s.settingsStorageSchedulableHandler(w, r)
|
|
case path == "/settings/storage/label" && r.Method == http.MethodPost:
|
|
s.settingsStorageLabelHandler(w, r)
|
|
case strings.HasPrefix(path, "/settings/cross-backup/") && r.Method == http.MethodPost:
|
|
name := strings.TrimPrefix(path, "/settings/cross-backup/")
|
|
s.settingsCrossBackupHandler(w, r, name)
|
|
case path == "/backup/restore" && r.Method == http.MethodPost:
|
|
s.backupRestoreHandler(w, r)
|
|
case path == "/settings/storage/init":
|
|
s.storageInitHandler(w, r)
|
|
case path == "/settings/storage/attach":
|
|
s.storageAttachHandler(w, r)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/migrate"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/migrate")
|
|
s.migratePageHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/logs")
|
|
s.logsHandler(w, r, name)
|
|
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
|
|
name := strings.TrimPrefix(path, "/stacks/")
|
|
name = strings.TrimSuffix(name, "/deploy")
|
|
s.deployHandler(w, r, name)
|
|
case path == "/static/style.css":
|
|
s.serveCSSHandler(w, r)
|
|
case path == "/static/chart.min.js":
|
|
s.serveChartJSHandler(w, r)
|
|
case path == "/static/felhom-logo.svg":
|
|
s.serveLogoHandler(w, r)
|
|
case strings.HasPrefix(path, "/static/assets/"):
|
|
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
|
case strings.HasPrefix(path, "/apps/"):
|
|
slug := strings.TrimPrefix(path, "/apps/")
|
|
s.appDetailHandler(w, r, slug)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
|
|
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
|
s.storageAPIHandler(w, r)
|
|
}
|
|
|
|
// primaryHDDPath returns the default storage path, or the legacy config value.
|
|
func (s *Server) primaryHDDPath() string {
|
|
if p := s.settings.GetDefaultStoragePath(); p != "" {
|
|
return p
|
|
}
|
|
return s.cfg.Paths.HDDPath
|
|
}
|
|
|
|
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
|
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
// --- Static file / asset serving ---
|
|
|
|
func (s *Server) serveCSSHandler(w http.ResponseWriter, r *http.Request) {
|
|
data, err := templateFS.ReadFile("templates/style.css")
|
|
if err != nil {
|
|
http.Error(w, "CSS not found", 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) serveChartJSHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Write(chartJS)
|
|
}
|
|
|
|
func (s *Server) serveLogoHandler(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, felhomLogoSVG)
|
|
}
|
|
|
|
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
|
|
const assetsDir = "/usr/share/felhom/assets"
|
|
|
|
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
|
|
filename = filepath.Base(filename)
|
|
path := filepath.Join(assetsDir, filename)
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
http.ServeFile(w, r, path)
|
|
}
|