slice 8C Phase B.2 + C.1/C.2: retire disk subsystem + rewire disk mgmt to agent
Retired (~12.3k LOC): internal/storage/* (scan/format/attach/migrate/safety), backup restic/crossdrive/restore_drives/disk_layout/local_infra/restore_scan/ paths + restore_app, report/infra_backup*/infra_pull, setup/scanner, monitor/watchdog+pinger, web/storage_handlers+handler_restore. Surgically split backup.Manager to app-data only (DB dumps + volume tars + app restore; dropped restic + cross-drive + snapshot history). Fixed router/main/web wiring. Added agent-backed disk API (web/agent_disk_handlers.go): /api/disks list/ assign/eject/format proxying agentapi; data-bearing format refusal -> HTTP 409 'operator authorization required'. report/config_pull.go keeps the setup fresh-install config download. go build + go test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
|
||||
)
|
||||
|
||||
// Agent-backed disk management (slice 8C, Phase B.2).
|
||||
//
|
||||
// Disk EXECUTION (scan/format/mount/migrate) lives in the host agent now; the
|
||||
// controller is Docker-only and holds no Proxmox/disk credentials. These handlers
|
||||
// are THIN proxies: they build an agentapi.Client from cfg.LocalAPI and forward
|
||||
// list/assign/eject/format to the agent's GET/POST /disks endpoints, returning the
|
||||
// agent's view as JSON. A data-bearing format is refused by the agent (operator
|
||||
// authorization required) and surfaced here as HTTP 409.
|
||||
|
||||
// ServeDiskAPI dispatches /api/disks and /api/disks/* routes.
|
||||
// Wired in main.go behind RequireAuth + CsrfProtect.
|
||||
func (s *Server) ServeDiskAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] ServeDiskAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == "/api/disks" && r.Method == http.MethodGet:
|
||||
s.agentDisksListHandler(w, r)
|
||||
case r.URL.Path == "/api/disks/assign" && r.Method == http.MethodPost:
|
||||
s.agentDiskAssignHandler(w, r)
|
||||
case r.URL.Path == "/api/disks/eject" && r.Method == http.MethodPost:
|
||||
s.agentDiskEjectHandler(w, r)
|
||||
case r.URL.Path == "/api/disks/format" && r.Method == http.MethodPost:
|
||||
s.agentDiskFormatHandler(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// agentClient builds a pinned client for the host agent's per-guest local API.
|
||||
// Returns a clear error if the local API is not configured (unprovisioned guest).
|
||||
func (s *Server) agentClient() (*agentapi.Client, error) {
|
||||
if s.cfg.LocalAPI.Endpoint == "" {
|
||||
return nil, errors.New("agent not configured")
|
||||
}
|
||||
return agentapi.New(s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint)
|
||||
}
|
||||
|
||||
// writeDiskJSON writes the standard {ok,data,error} envelope used by the disk API.
|
||||
func writeDiskJSON(w http.ResponseWriter, status int, ok bool, errMsg string, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
resp := map[string]interface{}{"ok": ok}
|
||||
if errMsg != "" {
|
||||
resp["error"] = errMsg
|
||||
}
|
||||
if data != nil {
|
||||
resp["data"] = data
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// agentDisksListHandler proxies GET /api/disks → agent GET /disks.
|
||||
func (s *Server) agentDisksListHandler(w http.ResponseWriter, r *http.Request) {
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.Disks(r.Context())
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk list via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
||||
}
|
||||
|
||||
// agentDiskAssignHandler proxies POST /api/disks/assign → agent POST /disks/assign.
|
||||
func (s *Server) agentDiskAssignHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
UUID string `json:"uuid"`
|
||||
Where string `json:"where"`
|
||||
FSType string `json:"fstype"`
|
||||
Options string `json:"options"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if req.UUID == "" || req.Where == "" {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "uuid and where are required", nil)
|
||||
return
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
if err := client.AssignDisk(r.Context(), req.UUID, req.Where, req.FSType, req.Options); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk assign via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]interface{}{
|
||||
"uuid": req.UUID, "where": req.Where,
|
||||
})
|
||||
}
|
||||
|
||||
// agentDiskEjectHandler proxies POST /api/disks/eject → agent POST /disks/eject.
|
||||
func (s *Server) agentDiskEjectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Where string `json:"where"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if req.Where == "" {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "where is required", nil)
|
||||
return
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.EjectDisk(r.Context(), req.Where)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk eject via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
||||
}
|
||||
|
||||
// agentDiskFormatHandler proxies POST /api/disks/format → agent POST /disks/format.
|
||||
// A data-bearing format refusal (ErrFormatRefused) is surfaced as HTTP 409 so the UI
|
||||
// can show "operator authorization required".
|
||||
func (s *Server) agentDiskFormatHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Device string `json:"device"`
|
||||
FSType string `json:"fstype"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "invalid request body", nil)
|
||||
return
|
||||
}
|
||||
if req.Device == "" {
|
||||
writeDiskJSON(w, http.StatusBadRequest, false, "device is required", nil)
|
||||
return
|
||||
}
|
||||
client, err := s.agentClient()
|
||||
if err != nil {
|
||||
writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
resp, err := client.FormatDisk(r.Context(), req.Device, req.FSType)
|
||||
if errors.Is(err, agentapi.ErrFormatRefused) {
|
||||
s.logger.Printf("[WARN] [web] disk format refused by agent (data-bearing): %s", req.Device)
|
||||
writeDiskJSON(w, http.StatusConflict, false, "operator authorization required", resp)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] disk format via agent failed: %v", err)
|
||||
writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", resp)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
@@ -23,13 +22,11 @@ import (
|
||||
|
||||
// DebugCallbacks holds functions that need main.go wiring (modules not directly on Server).
|
||||
type DebugCallbacks struct {
|
||||
TriggerHubReportPush func() error
|
||||
TriggerHubInfraPush func() error
|
||||
TriggerLocalInfraWrite func() error
|
||||
TriggerSetupMode func() error
|
||||
HubConnectivityTest func() (statusCode int, latencyMs int64, err error)
|
||||
GiteaConnectivityTest func() (statusCode int, latencyMs int64, err error)
|
||||
GetTelemetryPreview func() ([]report.AppTelemetry, error)
|
||||
TriggerHubReportPush func() error
|
||||
TriggerSetupMode func() error
|
||||
HubConnectivityTest func() (statusCode int, latencyMs int64, err error)
|
||||
GiteaConnectivityTest func() (statusCode int, latencyMs int64, err error)
|
||||
GetTelemetryPreview func() ([]report.AppTelemetry, error)
|
||||
}
|
||||
|
||||
// debugPageHandler renders the debug dashboard page.
|
||||
@@ -53,29 +50,13 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
|
||||
case subpath == "event/history" && r.Method == http.MethodGet:
|
||||
s.debugEventHistory(w, r)
|
||||
|
||||
// Section 3: Backup testing
|
||||
// Section 3: Backup testing (app-data only; disk-tier moved to host agent)
|
||||
case subpath == "backup/dbdump" && r.Method == http.MethodPost:
|
||||
s.debugTriggerDBDump(w, r)
|
||||
case subpath == "backup/crossdrive" && r.Method == http.MethodPost:
|
||||
s.debugTriggerCrossDrive(w, r)
|
||||
case subpath == "backup/integrity" && r.Method == http.MethodPost:
|
||||
s.debugTriggerIntegrity(w, r)
|
||||
case subpath == "backup/infra" && r.Method == http.MethodPost:
|
||||
s.debugTriggerInfraBackup(w, r)
|
||||
|
||||
// Section 4: Storage simulation
|
||||
case subpath == "storage/simulate-disconnect" && r.Method == http.MethodPost:
|
||||
s.debugSimulateDisconnect(w, r)
|
||||
case subpath == "storage/simulate-reconnect" && r.Method == http.MethodPost:
|
||||
s.debugSimulateReconnect(w, r)
|
||||
case subpath == "storage/watchdog-status" && r.Method == http.MethodGet:
|
||||
s.debugWatchdogStatus(w, r)
|
||||
|
||||
// Section 5: Hub & connectivity
|
||||
case subpath == "hub/push" && r.Method == http.MethodPost:
|
||||
s.debugHubPush(w, r)
|
||||
case subpath == "hub/infra-push" && r.Method == http.MethodPost:
|
||||
s.debugHubInfraPush(w, r)
|
||||
case subpath == "hub/test-connectivity" && r.Method == http.MethodPost:
|
||||
s.debugHubConnectivity(w, r)
|
||||
case subpath == "hub/preferences-sync" && r.Method == http.MethodPost:
|
||||
@@ -94,8 +75,6 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// Section 7: DR / Setup
|
||||
case subpath == "dr/trigger-setup" && r.Method == http.MethodPost:
|
||||
s.debugTriggerSetupWizard(w, r)
|
||||
case subpath == "dr/infra-status" && r.Method == http.MethodGet:
|
||||
s.debugInfraBackupStatus(w, r)
|
||||
|
||||
// Section 8: Log viewer
|
||||
case subpath == "logs" && r.Method == http.MethodGet:
|
||||
@@ -219,23 +198,13 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
|
||||
"enabled": true,
|
||||
"running": s.backupMgr.IsRunning(),
|
||||
}
|
||||
dbDump, backupSt := s.backupMgr.GetStatus()
|
||||
dbDump := s.backupMgr.GetStatus()
|
||||
if dbDump != nil {
|
||||
backupInfo["last_db_dump"] = map[string]interface{}{
|
||||
"time": dbDump.LastRun,
|
||||
"success": dbDump.Success,
|
||||
}
|
||||
}
|
||||
if backupSt != nil {
|
||||
backupInfo["last_backup"] = map[string]interface{}{
|
||||
"time": backupSt.LastRun,
|
||||
"success": backupSt.Success,
|
||||
}
|
||||
if backupSt.RepoStats != nil {
|
||||
backupInfo["repo_size"] = backupSt.RepoStats.TotalSize
|
||||
backupInfo["snapshot_count"] = backupSt.RepoStats.SnapshotCount
|
||||
}
|
||||
}
|
||||
dump["backup"] = backupInfo
|
||||
} else {
|
||||
dump["backup"] = map[string]interface{}{"enabled": false}
|
||||
@@ -378,98 +347,6 @@ func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) {
|
||||
writeDebugJSON(w, http.StatusOK, true, "DB dump elindítva", nil)
|
||||
}
|
||||
|
||||
func (s *Server) debugTriggerCrossDrive(w http.ResponseWriter, r *http.Request) {
|
||||
if s.crossDriveRunner == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Cross-drive runner nincs konfigurálva", nil)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := s.crossDriveRunner.RunAllConfigured(context.Background()); err != nil {
|
||||
s.logger.Printf("[WARN] Debug cross-drive failed: %v", err)
|
||||
}
|
||||
}()
|
||||
writeDebugJSON(w, http.StatusOK, true, "Cross-drive mentés elindítva", nil)
|
||||
}
|
||||
|
||||
func (s *Server) debugTriggerIntegrity(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := s.backupMgr.RunIntegrityCheck(context.Background()); err != nil {
|
||||
s.logger.Printf("[WARN] Debug integrity check failed: %v", err)
|
||||
}
|
||||
}()
|
||||
writeDebugJSON(w, http.StatusOK, true, "Integritás ellenőrzés elindítva", nil)
|
||||
}
|
||||
|
||||
func (s *Server) debugTriggerInfraBackup(w http.ResponseWriter, r *http.Request) {
|
||||
if s.debugCallbacks == nil || s.debugCallbacks.TriggerLocalInfraWrite == nil {
|
||||
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if err := s.debugCallbacks.TriggerLocalInfraWrite(); err != nil {
|
||||
s.logger.Printf("[WARN] Debug infra backup failed: %v", err)
|
||||
}
|
||||
}()
|
||||
writeDebugJSON(w, http.StatusOK, true, "Infra mentés elindítva", nil)
|
||||
}
|
||||
|
||||
// ── Section 4: Storage simulation ───────────────────────────────────
|
||||
|
||||
func (s *Server) debugSimulateDisconnect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Path == "" {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés: path szükséges", nil)
|
||||
return
|
||||
}
|
||||
if s.storageWatchdog == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Storage watchdog nincs konfigurálva", nil)
|
||||
return
|
||||
}
|
||||
stopped, err := s.storageWatchdog.SimulateDisconnect(r.Context(), req.Path)
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
fmt.Sprintf("Leválasztás szimulálva: %s (%d app leállítva)", req.Path, len(stopped)),
|
||||
map[string]interface{}{"stopped_stacks": stopped})
|
||||
}
|
||||
|
||||
func (s *Server) debugSimulateReconnect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Path == "" {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés: path szükséges", nil)
|
||||
return
|
||||
}
|
||||
if s.storageWatchdog == nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, "Storage watchdog nincs konfigurálva", nil)
|
||||
return
|
||||
}
|
||||
if err := s.storageWatchdog.SimulateReconnect(r.Context(), req.Path); err != nil {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true,
|
||||
fmt.Sprintf("Visszacsatlakozás szimulálva: %s", req.Path), nil)
|
||||
}
|
||||
|
||||
func (s *Server) debugWatchdogStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.storageWatchdog == nil {
|
||||
writeDebugJSON(w, http.StatusOK, true, "", []interface{}{})
|
||||
return
|
||||
}
|
||||
status := s.storageWatchdog.GetDebugStatus()
|
||||
writeDebugJSON(w, http.StatusOK, true, "", status)
|
||||
}
|
||||
|
||||
// ── Section 5: Hub & connectivity ───────────────────────────────────
|
||||
|
||||
func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -488,22 +365,6 @@ func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) {
|
||||
map[string]interface{}{"latency_ms": latency})
|
||||
}
|
||||
|
||||
func (s *Server) debugHubInfraPush(w http.ResponseWriter, r *http.Request) {
|
||||
if s.debugCallbacks == nil || s.debugCallbacks.TriggerHubInfraPush == nil {
|
||||
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
err := s.debugCallbacks.TriggerHubInfraPush()
|
||||
latency := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
writeDebugJSON(w, http.StatusOK, false, err.Error(), map[string]interface{}{"latency_ms": latency})
|
||||
return
|
||||
}
|
||||
writeDebugJSON(w, http.StatusOK, true, "Infra backup elküldve a Hubra",
|
||||
map[string]interface{}{"latency_ms": latency})
|
||||
}
|
||||
|
||||
func (s *Server) debugHubConnectivity(w http.ResponseWriter, r *http.Request) {
|
||||
if s.debugCallbacks == nil || s.debugCallbacks.HubConnectivityTest == nil {
|
||||
writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil)
|
||||
@@ -613,13 +474,6 @@ func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-check: verify infra backup exists on at least one drive
|
||||
if !s.hasInfraBackupOnDrive() {
|
||||
writeDebugJSON(w, http.StatusBadRequest, false,
|
||||
"Nincs infra backup egyetlen meghajtón sem! Először készítsen infra backupot.", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Write marker file
|
||||
markerPath := filepath.Join(s.cfg.Paths.DataDir, ".needs-setup")
|
||||
if err := os.WriteFile(markerPath, []byte("debug-triggered\n"), 0644); err != nil {
|
||||
@@ -637,67 +491,6 @@ func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) debugInfraBackupStatus(w http.ResponseWriter, r *http.Request) {
|
||||
storagePaths := s.settings.GetStoragePaths()
|
||||
drives := make([]map[string]interface{}, 0, len(storagePaths))
|
||||
|
||||
for _, sp := range storagePaths {
|
||||
if sp.Decommissioned || sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
driveInfo := map[string]interface{}{
|
||||
"path": sp.Path,
|
||||
"label": sp.Label,
|
||||
"has_backup": false,
|
||||
}
|
||||
|
||||
infraDir := backup.InfraBackupDir(sp.Path)
|
||||
info, err := os.Stat(infraDir)
|
||||
if err == nil && info.IsDir() {
|
||||
driveInfo["has_backup"] = true
|
||||
driveInfo["last_modified"] = info.ModTime()
|
||||
|
||||
// List files
|
||||
entries, _ := os.ReadDir(infraDir)
|
||||
files := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
driveInfo["files"] = files
|
||||
}
|
||||
|
||||
drives = append(drives, driveInfo)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"drives": drives,
|
||||
}
|
||||
if s.hubPushStatusFn != nil {
|
||||
st := s.hubPushStatusFn()
|
||||
data["hub_infra_push"] = map[string]interface{}{
|
||||
"last_attempt": st.LastAttempt,
|
||||
"last_success": st.LastSuccess,
|
||||
"last_error": st.LastError,
|
||||
}
|
||||
}
|
||||
|
||||
writeDebugJSON(w, http.StatusOK, true, "", data)
|
||||
}
|
||||
|
||||
// hasInfraBackupOnDrive checks if any connected storage drive has an infra backup.
|
||||
func (s *Server) hasInfraBackupOnDrive() bool {
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Decommissioned || sp.Disconnected {
|
||||
continue
|
||||
}
|
||||
infraDir := backup.InfraBackupDir(sp.Path)
|
||||
if info, err := os.Stat(infraDir); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Section 8: Log viewer ───────────────────────────────────────────
|
||||
|
||||
func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -9,6 +9,23 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
|
||||
)
|
||||
|
||||
// jsonResponse writes a JSON body with a 200 status.
|
||||
// (Shared JSON helper previously defined in the now-removed storage_handlers.go.)
|
||||
func jsonResponse(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// jsonError writes a JSON error response with the given status code.
|
||||
func jsonError(w http.ResponseWriter, msg string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeExportAPI dispatches /api/export/* endpoints.
|
||||
func (s *Server) ServeExportAPI(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
)
|
||||
|
||||
// restorePageHandler renders the full-page DR restore UI.
|
||||
func (s *Server) restorePageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: rendering restore page")
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
if plan == nil {
|
||||
s.restoreMu.RUnlock()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: no restore plan, redirecting to /")
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
// Snapshot all needed fields under lock before rendering
|
||||
customerID := plan.CustomerID
|
||||
timestamp := plan.Timestamp
|
||||
apps := plan.GetApps()
|
||||
drives := make([]backup.DriveInfo, len(plan.Drives))
|
||||
copy(drives, plan.Drives)
|
||||
status := plan.GetStatus()
|
||||
s.restoreMu.RUnlock()
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] restorePageHandler: customer=%s apps=%d drives=%d status=%s", customerID, len(apps), len(drives), status)
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Title": "Katasztrófa utáni visszaállítás",
|
||||
"CustomerName": s.cfg.Customer.Name,
|
||||
"Domain": s.cfg.Customer.Domain,
|
||||
"Version": s.version,
|
||||
"CustomerID": customerID,
|
||||
"Timestamp": timestamp,
|
||||
"Apps": apps,
|
||||
"Drives": drives,
|
||||
"PlanStatus": status,
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "restore", data)
|
||||
}
|
||||
|
||||
// apiRestoreStatus returns the current restore plan status as JSON.
|
||||
func (s *Server) apiRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreStatus: status poll from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
if plan == nil {
|
||||
s.restoreMu.RUnlock()
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snapshot := plan.Snapshot()
|
||||
s.restoreMu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
json.NewEncoder(w).Encode(snapshot)
|
||||
}
|
||||
|
||||
// apiRestoreAll starts restoring all pending apps sequentially.
|
||||
func (s *Server) apiRestoreAll(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore-all requested from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
s.restoreMu.RUnlock()
|
||||
if plan == nil {
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !plan.TryStartRestore() {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreAll: restore already in progress, rejecting")
|
||||
}
|
||||
jsonError(w, "restore already in progress", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] Restore-all initiated from %s", r.RemoteAddr)
|
||||
go s.executeAllRestores()
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Visszaállítás elindítva",
|
||||
})
|
||||
}
|
||||
|
||||
// apiRestoreSkip exits restore mode without restoring.
|
||||
func (s *Server) apiRestoreSkip(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] apiRestoreSkip: skip requested from %s", r.RemoteAddr)
|
||||
}
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
s.restoreMu.RUnlock()
|
||||
if plan == nil {
|
||||
jsonError(w, "not in restore mode", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Println("[INFO] [web] User skipped DR restore — entering normal mode")
|
||||
s.clearRestoreMode()
|
||||
|
||||
jsonResponse(w, map[string]interface{}{
|
||||
"ok": true,
|
||||
"message": "Visszaállítás kihagyva",
|
||||
})
|
||||
}
|
||||
|
||||
// executeAllRestores runs the restore for each pending app sequentially.
|
||||
func (s *Server) executeAllRestores() {
|
||||
s.logger.Println("[INFO] [web] Starting DR restore for all apps")
|
||||
restoreStart := time.Now()
|
||||
|
||||
s.restoreMu.RLock()
|
||||
plan := s.restorePlan
|
||||
s.restoreMu.RUnlock()
|
||||
if plan == nil {
|
||||
s.logger.Println("[WARN] [web] Restore plan cleared before execution could start")
|
||||
return
|
||||
}
|
||||
|
||||
// Count pending apps and push DR start event
|
||||
pendingCount := 0
|
||||
for _, app := range plan.Apps {
|
||||
if app.Status == "pending" {
|
||||
pendingCount++
|
||||
}
|
||||
}
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: %d pending apps to restore", pendingCount)
|
||||
}
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyDRStarted(pendingCount)
|
||||
}
|
||||
|
||||
successCount, failCount := 0, 0
|
||||
for i := range plan.Apps {
|
||||
app := &plan.Apps[i]
|
||||
if app.Status != "pending" {
|
||||
continue
|
||||
}
|
||||
|
||||
plan.UpdateApp(app.Name, "restoring", "")
|
||||
s.logger.Printf("[INFO] [web] Restoring app %s (%s)", app.Name, app.DisplayName)
|
||||
appStart := time.Now()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
err := backup.RestoreAppFromBackup(ctx, app, s.cfg.Paths.StacksDir, s.logger)
|
||||
cancel()
|
||||
|
||||
if err != nil {
|
||||
plan.UpdateApp(app.Name, "failed", err.Error())
|
||||
s.logger.Printf("[ERROR] [web] Restore failed for %s: %v", app.Name, err)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s failed after %s", app.Name, time.Since(appStart))
|
||||
}
|
||||
failCount++
|
||||
} else {
|
||||
plan.UpdateApp(app.Name, "done", "")
|
||||
s.logger.Printf("[INFO] [web] Restore completed for %s", app.Name)
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: app=%s completed in %s", app.Name, time.Since(appStart))
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
|
||||
plan.SetStatus("done")
|
||||
s.logger.Println("[INFO] [web] All app restores completed")
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] executeAllRestores: total=%d success=%d fail=%d elapsed=%s", pendingCount, successCount, failCount, time.Since(restoreStart))
|
||||
}
|
||||
|
||||
// Push DR completion event
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyDRCompleted(successCount, failCount)
|
||||
}
|
||||
|
||||
// Re-scan stacks so dashboard picks up restored apps
|
||||
if s.stackMgr != nil {
|
||||
if err := s.stackMgr.ScanStacks(); err != nil {
|
||||
s.logger.Printf("[WARN] [web] Post-restore stack scan failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clearRestoreMode exits restore mode and returns to normal operation.
|
||||
func (s *Server) clearRestoreMode() {
|
||||
s.restoreMu.Lock()
|
||||
defer s.restoreMu.Unlock()
|
||||
s.restorePlan = nil
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
||||
// protectedStackSubdomains maps programmatically managed protected stacks
|
||||
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
|
||||
var protectedStackSubdomains = map[string]string{
|
||||
@@ -29,8 +28,8 @@ var protectedStackSubdomains = map[string]string{
|
||||
|
||||
// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
|
||||
type StorageBarInfo struct {
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
Label string // e.g., "USB HDD 1TB", "SYS Storage 350G"
|
||||
Path string // e.g., "/mnt/hdd_1"
|
||||
TotalGB float64
|
||||
UsedGB float64
|
||||
Percent float64
|
||||
@@ -148,34 +147,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["BackupEnabled"] = s.cfg.Backup.Enabled
|
||||
if s.backupMgr != nil {
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||||
data["DBDumpStatus"] = fullStatus.LastDBDump
|
||||
data["BackupStatus"] = fullStatus.LastBackup
|
||||
data["BackupRunning"] = fullStatus.Running
|
||||
data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
|
||||
|
||||
// Cross-drive summary for dashboard Tier 2 status line
|
||||
crossConfigs := s.settings.GetAllCrossDriveConfigs()
|
||||
crossDriveTotal := 0
|
||||
crossDriveConfigured := 0
|
||||
crossDriveFailed := 0
|
||||
for _, st := range deployedStacks {
|
||||
if st.Protected {
|
||||
continue
|
||||
}
|
||||
crossDriveTotal++
|
||||
cfg, hasCfg := crossConfigs[st.Name]
|
||||
if hasCfg && cfg != nil && cfg.Enabled {
|
||||
crossDriveConfigured++
|
||||
if cfg.LastStatus == "error" {
|
||||
crossDriveFailed++
|
||||
}
|
||||
}
|
||||
}
|
||||
data["CrossDriveTotal"] = crossDriveTotal
|
||||
data["CrossDriveConfigured"] = crossDriveConfigured
|
||||
data["CrossDriveFailed"] = crossDriveFailed
|
||||
}
|
||||
|
||||
// Build subdomain map for "Megnyitás" buttons
|
||||
@@ -350,53 +325,10 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
|
||||
}
|
||||
}
|
||||
|
||||
// Storage info for already-deployed apps with HDD data
|
||||
// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
|
||||
// backup) has moved to the host agent (slice 8C); the deploy page no longer
|
||||
// renders those sections.
|
||||
if alreadyDeployed {
|
||||
storageInfo := s.storageInfoForStack(name)
|
||||
if storageInfo != nil {
|
||||
data["StorageInfo"] = storageInfo
|
||||
data["OtherStoragePaths"] = s.otherStoragePathsForStack(name)
|
||||
}
|
||||
// Stale data from previous migrations (only for deployed apps with HDD data)
|
||||
staleData := s.findStaleStorageData(name)
|
||||
if len(staleData) > 0 {
|
||||
data["StaleData"] = staleData
|
||||
}
|
||||
|
||||
// Cross-drive backup config for this app
|
||||
crossCfg := s.settings.GetCrossDriveConfig(name)
|
||||
data["CrossDriveConfig"] = crossCfg
|
||||
|
||||
// Other storage paths for destination dropdown (exclude the app's current storage path)
|
||||
currentPath := ""
|
||||
if storageInfo != nil {
|
||||
currentPath = storageInfo.Path
|
||||
}
|
||||
var destPaths []DeployStoragePath
|
||||
for _, sp := range s.settings.GetStoragePaths() {
|
||||
if sp.Path == currentPath {
|
||||
continue // skip the app's current storage — must be a DIFFERENT physical device
|
||||
}
|
||||
dp := DeployStoragePath{StoragePath: sp}
|
||||
if di := system.GetDiskUsage(sp.Path); di != nil {
|
||||
dp.FreeHuman = formatFreeSpace(di.AvailGB)
|
||||
if di.TotalGB > 0 {
|
||||
dp.FreePercent = di.AvailGB / di.TotalGB * 100
|
||||
}
|
||||
}
|
||||
destPaths = append(destPaths, dp)
|
||||
}
|
||||
data["BackupDestPaths"] = destPaths
|
||||
|
||||
// Destination health warning (tiered validation)
|
||||
if crossCfg != nil && crossCfg.Enabled && crossCfg.DestinationPath != "" {
|
||||
health := system.CheckBackupDestination(crossCfg.DestinationPath)
|
||||
if health.Warning != "" {
|
||||
data["BackupDestWarning"] = health.Warning
|
||||
data["BackupDestWarningSeverity"] = health.Severity
|
||||
}
|
||||
}
|
||||
|
||||
// App-to-app integrations
|
||||
if meta.HasIntegrations() && s.integrationMgr != nil {
|
||||
data["HasIntegrations"] = true
|
||||
@@ -581,8 +513,7 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if s.backupMgr != nil {
|
||||
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
|
||||
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
|
||||
fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
|
||||
|
||||
// Pass flash messages from query params (set by redirect handlers)
|
||||
if flash := r.URL.Query().Get("flash"); flash != "" {
|
||||
@@ -608,143 +539,18 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Build cross-drive summary
|
||||
crossConfigs := s.settings.GetAllCrossDriveConfigs()
|
||||
|
||||
// Build label lookup for dest paths
|
||||
destLabels := make(map[string]string)
|
||||
for _, sp := range storagePaths {
|
||||
destLabels[sp.Path] = sp.Label
|
||||
}
|
||||
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
cfg, hasCfg := crossConfigs[app.StackName]
|
||||
if !hasCfg || cfg == nil {
|
||||
fullStatus.UnconfiguredApps = append(fullStatus.UnconfiguredApps, backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
item := backup.CrossDriveSummaryItem{
|
||||
StackName: app.StackName,
|
||||
DisplayName: app.DisplayName,
|
||||
Method: cfg.Method,
|
||||
DestPath: cfg.DestinationPath,
|
||||
DestLabel: destLabels[cfg.DestinationPath],
|
||||
Schedule: cfg.Schedule,
|
||||
LastStatus: cfg.LastStatus,
|
||||
SizeHuman: cfg.LastSizeHuman,
|
||||
}
|
||||
switch cfg.Method {
|
||||
case "rsync":
|
||||
item.MethodLabel = "rsync"
|
||||
case "restic":
|
||||
item.MethodLabel = "restic"
|
||||
default:
|
||||
item.MethodLabel = cfg.Method
|
||||
}
|
||||
switch cfg.Schedule {
|
||||
case "daily":
|
||||
item.ScheduleLabel = "Naponta"
|
||||
case "weekly":
|
||||
item.ScheduleLabel = "Hetente"
|
||||
default:
|
||||
item.ScheduleLabel = "Kézi"
|
||||
}
|
||||
if cfg.LastRun != "" {
|
||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||
item.LastRunShort = t.In(getTimezone()).Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
fullStatus.CrossDriveSummary = append(fullStatus.CrossDriveSummary, item)
|
||||
|
||||
// Destination health warning (tiered validation)
|
||||
if cfg.Enabled && cfg.DestinationPath != "" {
|
||||
health := system.CheckBackupDestination(cfg.DestinationPath)
|
||||
if health.Warning != "" {
|
||||
prefix := "⚠️"
|
||||
if health.Severity == "critical" {
|
||||
prefix = "🔴"
|
||||
}
|
||||
fullStatus.CrossDriveWarnings = append(fullStatus.CrossDriveWarnings,
|
||||
fmt.Sprintf("%s %s: %s", prefix, app.DisplayName, health.Warning))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build unified per-app backup rows for the new UI
|
||||
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus, crossConfigs, destLabels)
|
||||
|
||||
// Top-level warning: no user data backed up at all
|
||||
hasAnyCrossDrive := false
|
||||
hasAnyHDDApp := false
|
||||
for _, app := range fullStatus.AppDataInfo {
|
||||
if app.HasHDDData {
|
||||
hasAnyHDDApp = true
|
||||
if cfg, ok := crossConfigs[app.StackName]; ok && cfg != nil && cfg.Enabled {
|
||||
hasAnyCrossDrive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasAnyHDDApp && !hasAnyCrossDrive {
|
||||
data["NoUserDataBackupWarning"] = true
|
||||
}
|
||||
// Build unified per-app backup rows for the app-data backup UI.
|
||||
// Disk-tier (cross-drive / restic) backup has moved to the host agent.
|
||||
data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)
|
||||
|
||||
data["Backup"] = fullStatus
|
||||
|
||||
// Restic password for display
|
||||
if pw, err := s.backupMgr.GetResticPassword(); err == nil {
|
||||
data["ResticPassword"] = pw
|
||||
}
|
||||
|
||||
// Részletek section: DB dump total size
|
||||
// DB dump total size
|
||||
var dbDumpTotalBytes int64
|
||||
for _, f := range fullStatus.DumpFiles {
|
||||
dbDumpTotalBytes += f.Size
|
||||
}
|
||||
data["DBDumpTotalBytes"] = dbDumpTotalBytes
|
||||
|
||||
// Részletek section: enrich per-drive repo stats with storage labels
|
||||
for i := range fullStatus.PerDriveRepoStats {
|
||||
for _, sp := range storagePaths {
|
||||
if strings.HasPrefix(fullStatus.PerDriveRepoStats[i].DrivePath, sp.Path) ||
|
||||
fullStatus.PerDriveRepoStats[i].DrivePath == sp.Path {
|
||||
fullStatus.PerDriveRepoStats[i].DriveLabel = sp.Label
|
||||
break
|
||||
}
|
||||
}
|
||||
if fullStatus.PerDriveRepoStats[i].DriveLabel == "" {
|
||||
fullStatus.PerDriveRepoStats[i].DriveLabel = filepath.Base(fullStatus.PerDriveRepoStats[i].DrivePath)
|
||||
}
|
||||
}
|
||||
data["PerDriveRepoStats"] = fullStatus.PerDriveRepoStats
|
||||
|
||||
// Részletek section: group Tier 2 items by destination drive
|
||||
tier2GroupMap := make(map[string]*Tier2DriveGroup)
|
||||
for _, item := range fullStatus.CrossDriveSummary {
|
||||
if item.DestPath == "" {
|
||||
continue
|
||||
}
|
||||
grp, exists := tier2GroupMap[item.DestPath]
|
||||
if !exists {
|
||||
grp = &Tier2DriveGroup{
|
||||
DestPath: item.DestPath,
|
||||
DestLabel: item.DestLabel,
|
||||
}
|
||||
if grp.DestLabel == "" {
|
||||
grp.DestLabel = filepath.Base(item.DestPath)
|
||||
}
|
||||
tier2GroupMap[item.DestPath] = grp
|
||||
}
|
||||
grp.Items = append(grp.Items, item)
|
||||
}
|
||||
var tier2Groups []Tier2DriveGroup
|
||||
for _, grp := range tier2GroupMap {
|
||||
tier2Groups = append(tier2Groups, *grp)
|
||||
}
|
||||
data["Tier2DriveGroups"] = tier2Groups
|
||||
} else {
|
||||
data["Backup"] = nil
|
||||
}
|
||||
@@ -752,13 +558,6 @@ func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.executeTemplate(w, r, "backups", data)
|
||||
}
|
||||
|
||||
// Tier2DriveGroup holds grouped Tier 2 cross-drive backup items for one destination drive.
|
||||
type Tier2DriveGroup struct {
|
||||
DestPath string
|
||||
DestLabel string
|
||||
Items []backup.CrossDriveSummaryItem
|
||||
}
|
||||
|
||||
// AppBackupRow holds per-tier backup information for one app on the backup page.
|
||||
type AppBackupRow struct {
|
||||
StackName string
|
||||
@@ -804,13 +603,9 @@ type AppBackupRow struct {
|
||||
}
|
||||
|
||||
// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
|
||||
func (s *Server) buildAppBackupRows(
|
||||
status *backup.FullBackupStatus,
|
||||
crossConfigs map[string]*settings.CrossDriveBackup,
|
||||
destLabels map[string]string,
|
||||
) []AppBackupRow {
|
||||
loc := getTimezone()
|
||||
|
||||
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
|
||||
// reflects only the app-data backup (DB dumps + Docker-volume tars).
|
||||
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
|
||||
// Build DB stack lookup
|
||||
dbStacks := make(map[string]bool)
|
||||
for _, db := range status.DiscoveredDBs {
|
||||
@@ -820,17 +615,6 @@ func (s *Server) buildAppBackupRows(
|
||||
dbStacks[f.StackName] = true
|
||||
}
|
||||
|
||||
// Tier 1 timestamps (shared across all apps — single nightly job)
|
||||
tier1LastRun := ""
|
||||
tier1LastStatus := ""
|
||||
if status.LastBackup != nil {
|
||||
tier1LastRun = status.LastBackup.LastRun.In(loc).Format("01-02 15:04")
|
||||
if status.LastBackup.Success {
|
||||
tier1LastStatus = "ok"
|
||||
} else {
|
||||
tier1LastStatus = "error"
|
||||
}
|
||||
}
|
||||
tier1DBStatus := ""
|
||||
if status.LastDBDump != nil {
|
||||
if status.LastDBDump.Success {
|
||||
@@ -884,115 +668,18 @@ func (s *Server) buildAppBackupRows(
|
||||
HasDB: hasDB,
|
||||
HasVolumeData: app.HasVolumeData,
|
||||
DriveDisconnected: driveDisconnected,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
BackupContents: contents,
|
||||
|
||||
Tier1LastRun: tier1LastRun,
|
||||
Tier1LastStatus: tier1LastStatus,
|
||||
Tier1DBStatus: tier1DBStatus,
|
||||
StorageLabel: app.StorageLabel,
|
||||
HDDSizeHuman: app.HDDSizeHuman,
|
||||
BackupContents: contents,
|
||||
Tier1DBStatus: tier1DBStatus,
|
||||
}
|
||||
|
||||
// Status dot — start as yellow (1 tier only)
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Csak helyi mentés (1 szint)"
|
||||
|
||||
cfg, hasCfg := crossConfigs[app.StackName]
|
||||
|
||||
if !hasCfg || cfg == nil || !cfg.Enabled {
|
||||
// Only Tier 1 — no second copy
|
||||
row.Tier2Configured = false
|
||||
} else {
|
||||
row.Tier2Configured = true
|
||||
row.Tier2Dest = destLabels[cfg.DestinationPath]
|
||||
if row.Tier2Dest == "" {
|
||||
row.Tier2Dest = cfg.DestinationPath
|
||||
}
|
||||
switch cfg.Schedule {
|
||||
case "daily":
|
||||
row.Tier2Schedule = "Naponta"
|
||||
case "weekly":
|
||||
row.Tier2Schedule = "Hetente"
|
||||
default:
|
||||
row.Tier2Schedule = cfg.Schedule
|
||||
}
|
||||
if cfg.LastRun != "" {
|
||||
if t, err := time.Parse(time.RFC3339, cfg.LastRun); err == nil {
|
||||
row.Tier2LastRun = t.In(loc).Format("01-02 15:04")
|
||||
}
|
||||
}
|
||||
row.Tier2LastStatus = cfg.LastStatus
|
||||
row.Tier2LastError = cfg.LastError
|
||||
row.Tier2SizeHuman = cfg.LastSizeHuman
|
||||
switch cfg.LastStatus {
|
||||
case "ok":
|
||||
row.Tier2StatusBadge = "Sikeres"
|
||||
row.Status = "green"
|
||||
row.StatusText = "Mentés rendben"
|
||||
case "error":
|
||||
row.Tier2StatusBadge = "Hiba"
|
||||
// Status stays yellow
|
||||
row.StatusText = "Utolsó mentés sikertelen"
|
||||
case "running":
|
||||
row.Tier2StatusBadge = "Fut..."
|
||||
default:
|
||||
row.Tier2StatusBadge = "—"
|
||||
// Tier2 configured but never run — stay yellow
|
||||
}
|
||||
|
||||
// Check if Tier2 destination drive is disconnected
|
||||
if cfg.DestinationPath != "" {
|
||||
for dp := range disconnectedPaths {
|
||||
if cfg.DestinationPath == dp || strings.HasPrefix(cfg.DestinationPath, dp+"/") {
|
||||
row.Tier2DestDisconnected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also treat as disconnected if dest was removed from storage entirely
|
||||
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
|
||||
if !s.settings.IsStoragePathKnown(cfg.DestinationPath) {
|
||||
row.Tier2DestDisconnected = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if Tier2 destination drive is inactive (not schedulable)
|
||||
if cfg.DestinationPath != "" && !row.Tier2DestDisconnected {
|
||||
if !s.settings.IsStoragePathSchedulable(cfg.DestinationPath) {
|
||||
row.Tier2DestInactive = true
|
||||
}
|
||||
}
|
||||
|
||||
if row.Tier2DestDisconnected {
|
||||
// Disconnected destination — treat as paused, not failed
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "2. mentés szünetel — cél meghajtó leválasztva"
|
||||
} else if row.Tier2DestInactive {
|
||||
// Inactive destination — treat as paused
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "2. mentés szünetel — cél meghajtó inaktív"
|
||||
} else if cfg.DestinationPath != "" && s.crossDriveRunner != nil {
|
||||
// Destination health check — can downgrade green to yellow/red
|
||||
if err := s.crossDriveRunner.ValidateDestination(cfg.DestinationPath); err != nil {
|
||||
if strings.Contains(err.Error(), "does not exist") || strings.Contains(err.Error(), "not writable") {
|
||||
row.Status = "red"
|
||||
row.StatusText = "Mentési cél nem elérhető"
|
||||
} else if row.Status != "red" {
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Figyelmeztetés"
|
||||
}
|
||||
row.Warnings = append(row.Warnings, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DB dump failure warning (affects Tier 1 quality)
|
||||
// Status dot — app-data backup status
|
||||
row.Status = "green"
|
||||
row.StatusText = "Alkalmazás-adat mentés rendben"
|
||||
if hasDB && tier1DBStatus == "error" {
|
||||
if row.Status != "red" {
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Adatbázis mentés sikertelen"
|
||||
}
|
||||
row.Status = "yellow"
|
||||
row.StatusText = "Adatbázis mentés sikertelen"
|
||||
}
|
||||
|
||||
rows = append(rows, row)
|
||||
@@ -1000,79 +687,6 @@ func (s *Server) buildAppBackupRows(
|
||||
return rows
|
||||
}
|
||||
|
||||
// settingsCrossBackupHandler handles POST /settings/cross-backup/{name}
|
||||
// Saves or updates the cross-drive backup configuration for an app.
|
||||
func (s *Server) settingsCrossBackupHandler(w http.ResponseWriter, r *http.Request, name string) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] settingsCrossBackupHandler: stack=%s from %s", name, r.RemoteAddr)
|
||||
}
|
||||
|
||||
enabled := r.FormValue("cross_drive_enabled") == "on"
|
||||
|
||||
// Preserve existing runtime status fields and config when disabling
|
||||
existing := s.settings.GetCrossDriveConfig(name)
|
||||
|
||||
var destPath, schedule string
|
||||
if enabled {
|
||||
destPath = r.FormValue("cross_drive_dest")
|
||||
schedule = r.FormValue("cross_drive_schedule")
|
||||
if schedule != "daily" && schedule != "weekly" {
|
||||
schedule = "daily"
|
||||
}
|
||||
} else if existing != nil {
|
||||
// Preserve existing settings when disabling
|
||||
destPath = existing.DestinationPath
|
||||
schedule = existing.Schedule
|
||||
}
|
||||
|
||||
// Validate destination path against registered storage paths (H11 fix — matches API handler).
|
||||
if enabled && destPath != "" {
|
||||
registeredPaths := s.settings.GetStoragePaths()
|
||||
validDest := false
|
||||
for _, sp := range registeredPaths {
|
||||
if destPath == sp.Path {
|
||||
validDest = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !validDest {
|
||||
s.logger.Printf("[WARN] [web] Cross-drive backup: rejected invalid dest path %q for %s", destPath, name)
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error="+url.QueryEscape("Érvénytelen célútvonal: "+destPath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var cfg *settings.CrossDriveBackup
|
||||
if destPath != "" || existing != nil {
|
||||
cfg = &settings.CrossDriveBackup{
|
||||
Enabled: enabled,
|
||||
Method: "rsync",
|
||||
DestinationPath: destPath,
|
||||
Schedule: schedule,
|
||||
}
|
||||
if existing != nil {
|
||||
cfg.LastRun = existing.LastRun
|
||||
cfg.LastStatus = existing.LastStatus
|
||||
cfg.LastError = existing.LastError
|
||||
cfg.LastDuration = existing.LastDuration
|
||||
cfg.LastSizeHuman = existing.LastSizeHuman
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.settings.SetCrossDriveConfig(name, cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Failed to save cross-drive config for %s: %v", name, err)
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash_error=Hiba+a+ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+ment%C3%A9sakor", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
s.logger.Printf("[INFO] [web] Cross-drive backup config saved for %s: dest=%s schedule=%s enabled=%v",
|
||||
name, destPath, schedule, enabled)
|
||||
|
||||
http.Redirect(w, r, "/stacks/"+name+"/deploy?flash=Ment%C3%A9si+be%C3%A1ll%C3%ADt%C3%A1s+mentve.", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
|
||||
@@ -1096,12 +710,7 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
|
||||
|
||||
start := time.Now()
|
||||
var err error
|
||||
if snapshotID == "tier2-rsync" {
|
||||
err = s.backupMgr.RestoreAppFromTier2(stackName)
|
||||
} else {
|
||||
err = s.backupMgr.RestoreApp(stackName, snapshotID)
|
||||
}
|
||||
err := s.backupMgr.RestoreApp(stackName, snapshotID)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
|
||||
if s.isDebug() {
|
||||
|
||||
@@ -17,31 +17,28 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/integrations"
|
||||
"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/storage"
|
||||
"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
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
tmpl *template.Template
|
||||
cfg *config.Config
|
||||
stackMgr *stacks.Manager
|
||||
cpuCollector *system.CPUCollector
|
||||
backupMgr *backup.Manager
|
||||
scheduler *scheduler.Scheduler
|
||||
settings *settings.Settings
|
||||
alertManager *AlertManager
|
||||
notifier *notify.Notifier
|
||||
updater *selfupdate.Updater
|
||||
logger *log.Logger
|
||||
version string
|
||||
encKey []byte // AES-256 key for decrypting app.yaml values
|
||||
tmpl *template.Template
|
||||
|
||||
sessions map[string]*session
|
||||
sessionsMu sync.RWMutex
|
||||
@@ -50,26 +47,9 @@ type Server struct {
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
// 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
|
||||
|
||||
// Guard for FileBrowser sync — prevents concurrent file writes (H5 fix)
|
||||
fileBrowserMu sync.Mutex
|
||||
|
||||
// Drive migration
|
||||
driveMigrator *storage.DriveMigrator
|
||||
|
||||
// DR restore mode state
|
||||
restoreMu sync.RWMutex
|
||||
restorePlan *backup.RestorePlan
|
||||
|
||||
// Storage watchdog (set after construction to break init ordering)
|
||||
storageWatchdog *monitor.StorageWatchdog
|
||||
|
||||
// Hub push status callback — set via SetHubPushStatus for monitoring page
|
||||
hubPushStatusFn func() HubPushStatusData
|
||||
|
||||
@@ -88,29 +68,28 @@ type Server struct {
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
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 {
|
||||
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, 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),
|
||||
loginAttempts: make(map[string]*loginAttempt),
|
||||
done: make(chan struct{}),
|
||||
cfg: cfg,
|
||||
stackMgr: stackMgr,
|
||||
cpuCollector: cpuCollector,
|
||||
backupMgr: backupMgr,
|
||||
scheduler: sched,
|
||||
settings: sett,
|
||||
alertManager: alertMgr,
|
||||
notifier: notif,
|
||||
updater: updater,
|
||||
logger: logger,
|
||||
version: version,
|
||||
sessions: make(map[string]*session),
|
||||
loginAttempts: make(map[string]*loginAttempt),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
if cfg.Logging.Level == "debug" {
|
||||
logger.Printf("[DEBUG] [web] NewServer: initializing web server v%s", version)
|
||||
logger.Printf("[DEBUG] [web] NewServer: backup=%v crossDrive=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
||||
backupMgr != nil, crossDrive != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
||||
logger.Printf("[DEBUG] [web] NewServer: backup=%v scheduler=%v alertMgr=%v notifier=%v updater=%v",
|
||||
backupMgr != nil, sched != nil, alertMgr != nil, notif != nil, updater != nil)
|
||||
}
|
||||
|
||||
s.loadTemplates()
|
||||
@@ -155,23 +134,6 @@ func (s *Server) loadTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// SetDriveMigrator sets the drive migration engine for full drive migration.
|
||||
func (s *Server) SetDriveMigrator(dm *storage.DriveMigrator) {
|
||||
s.driveMigrator = dm
|
||||
}
|
||||
|
||||
// HubPushStatusData holds hub push status for the monitoring page.
|
||||
type HubPushStatusData struct {
|
||||
LastAttempt time.Time
|
||||
@@ -230,13 +192,6 @@ func (s *Server) ServeDebugAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleDebugAPI(w, r)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -245,30 +200,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -296,25 +227,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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 path == "/settings/storage/migrate-drive":
|
||||
s.migrateDrivePageHandler(w, r)
|
||||
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/export"):
|
||||
name := strings.TrimPrefix(path, "/stacks/")
|
||||
name = strings.TrimSuffix(name, "/export")
|
||||
s.exportPageHandler(w, r, name)
|
||||
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")
|
||||
@@ -440,14 +358,6 @@ func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
|
||||
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.isDebug() {
|
||||
s.logger.Printf("[DEBUG] [web] ServeStorageAPI: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
|
||||
}
|
||||
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 != "" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user