package web import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/agentapi" "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" "gitea.dooplex.hu/admin/felhom-controller/internal/system" ) // DebugCallbacks holds functions that need main.go wiring (modules not directly on Server). type DebugCallbacks struct { 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) // RunIntegrityCheck (R-359/R-397) runs the off-site integrity check SYNCHRONOUSLY and returns what // it did. It is a callback rather than a direct call because the one implementation lives in // main.go, where the manager and the notifier are both in scope — and there must be exactly one: // a hand-run that diverged from the scheduled run is how a guard gets bypassed "because the // operator asked for it", which is the specific hazard this feature is shaped around. // // `force` is the ONLY difference between the two callers. It skips the due-ness question and // nothing else — every guard, above all the single-writer flag, applies identically. RunIntegrityCheck func(force bool) backup.IntegrityResult // RunOffsiteProof (R-87) runs the nightly off-site content proof SYNCHRONOUSLY and returns what it // found. Same function as the scheduled job — there is no second code path, for the reason // runOffsiteProof's own comment gives: a hand-run that could drift from the scheduled one is how // the button ends up proving something the nightly job does not. RunOffsiteProof func() backup.ProofResult } // debugPageHandler renders the debug dashboard page. func (s *Server) debugPageHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("debug", "Debug") s.executeTemplate(w, r, "debug", data) } // handleDebugAPI dispatches /api/debug/* routes. func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) { subpath := strings.TrimPrefix(r.URL.Path, "/api/debug/") switch { // Section 1: Diagnostic dump case subpath == "dump" && r.Method == http.MethodGet: s.debugDump(w, r) // Section 2: Notification & Event testing case subpath == "event/test" && r.Method == http.MethodPost: s.debugTestEvent(w, r) case subpath == "event/history" && r.Method == http.MethodGet: s.debugEventHistory(w, r) // 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) // R-400 — the „Csak cross-drive" button has posted here since it was added and nothing answered. // The capability was never missing: Manager.RunTier2 is live and the app config page already calls // it. Only the debug route was absent, so this is IMPLEMENTED rather than deleted. case subpath == "backup/crossdrive" && r.Method == http.MethodPost: s.debugRunCrossDrive(w, r) // R-705 (v0.279.0): the night's four legs, in order, now. case subpath == "backup/night-chain" && r.Method == http.MethodPost: s.debugRunNightChain(w, r) // R-397 — the button at debug.html:83 has posted here since it was added and NOTHING answered. // Verified 2026-08-30: this dispatch had no such case, so pressing „Restic integritás" did // nothing at all. Seventh instance of built-but-never-wired in this project; filed as R-400 in its // own right rather than disappearing inside this change. case subpath == "backup/integrity" && r.Method == http.MethodPost: s.debugRunIntegrityCheck(w, r) // R-87 — the off-site content proof, by hand. It exists for the same reason the integrity button // does: without it the only way to see this job work is to wait for 05:30, which makes both live // validation and any future diagnosis a next-day exercise. case subpath == "backup/offsite-proof" && r.Method == http.MethodPost: s.debugRunOffsiteProof(w, r) // Section 5: Hub & connectivity case subpath == "hub/push" && r.Method == http.MethodPost: s.debugHubPush(w, r) case subpath == "hub/test-connectivity" && r.Method == http.MethodPost: s.debugHubConnectivity(w, r) case subpath == "hub/preferences-sync" && r.Method == http.MethodPost: s.debugPreferencesSync(w, r) case subpath == "gitea/test-connectivity" && r.Method == http.MethodPost: s.debugGiteaConnectivity(w, r) // Section: Telemetry testing case subpath == "telemetry" && r.Method == http.MethodGet: s.debugTelemetry(w, r) // Section 6: Self-update case subpath == "selfupdate/dry-run" && r.Method == http.MethodPost: s.debugSelfUpdateDryRun(w, r) // Section 7: DR / Setup case subpath == "dr/trigger-setup" && r.Method == http.MethodPost: s.debugTriggerSetupWizard(w, r) // Section 8: Log viewer case subpath == "logs" && r.Method == http.MethodGet: s.debugLogBuffer(w, r) case subpath == "agent-logs" && r.Method == http.MethodGet: s.debugAgentLogs(w, r) // Section 9: App Export/Import case subpath == "appexport/status" && r.Method == http.MethodGet: s.debugAppExportStatus(w, r) case subpath == "appexport/bundles" && r.Method == http.MethodGet: s.debugAppExportBundles(w, r) case subpath == "appexport/cleanup" && r.Method == http.MethodPost: s.debugAppExportCleanup(w, r) default: http.NotFound(w, r) } } // writeDebugJSON writes a standard JSON response for debug endpoints. func writeDebugJSON(w http.ResponseWriter, status int, ok bool, message string, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) resp := map[string]interface{}{ "ok": ok, } if message != "" { if ok { resp["message"] = message } else { resp["error"] = message } } if data != nil { resp["data"] = data } json.NewEncoder(w).Encode(resp) } // ── Section 1: Diagnostic dump ────────────────────────────────────── func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) { dump := make(map[string]interface{}) // Controller info configHash := "" configPath := s.cfg.Paths.DataDir // approximate; configPath isn't on Server if data, err := os.ReadFile(filepath.Join(filepath.Dir(configPath), "controller.yaml")); err == nil { h := sha256.Sum256(data) configHash = hex.EncodeToString(h[:]) } dump["controller"] = map[string]interface{}{ "version": s.version, "uptime_seconds": int(time.Since(s.startTime).Seconds()), "config_hash": configHash, "logging_level": s.cfg.Logging.Level, "pid": os.Getpid(), } // Storage storagePaths := s.settings.GetStoragePaths() storageEntries := make([]map[string]interface{}, 0, len(storagePaths)) for _, sp := range storagePaths { entry := map[string]interface{}{ "path": sp.Path, "label": sp.Label, "disconnected": sp.Disconnected, "decommissioned": sp.Decommissioned, } if !sp.Disconnected && !sp.Decommissioned { if di := system.GetDiskUsage(sp.Path); di != nil { entry["total_gb"] = di.TotalGB entry["used_gb"] = di.UsedGB entry["used_percent"] = di.UsedPercent } } storageEntries = append(storageEntries, entry) } dump["storage"] = storageEntries // Stacks allStacks := s.stackMgr.GetStacks() deployed := 0 running := 0 stopped := 0 stackList := make([]map[string]interface{}, 0) for _, st := range allStacks { if !st.Deployed { continue } deployed++ info := map[string]interface{}{ "name": st.Name, "state": string(st.State), } if st.Meta.DisplayName != "" { info["display_name"] = st.Meta.DisplayName } containerNames := make([]string, 0, len(st.Containers)) for _, c := range st.Containers { containerNames = append(containerNames, c.Name) switch c.State { case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy: running++ default: stopped++ } } info["containers"] = containerNames stackList = append(stackList, info) } dump["stacks"] = map[string]interface{}{ "deployed": deployed, "running": running, "stopped": stopped, "list": stackList, } // Backup if s.backupMgr != nil { backupInfo := map[string]interface{}{ "enabled": true, "running": s.backupMgr.IsRunning(), } dbDump := s.backupMgr.GetStatus() if dbDump != nil { backupInfo["last_db_dump"] = map[string]interface{}{ "time": dbDump.LastRun, "success": dbDump.Success, } } dump["backup"] = backupInfo } else { dump["backup"] = map[string]interface{}{"enabled": false} } // Network (R-66) — the guest's netns view, read through the samba-container door // (stacks/guestnet.go: the controller's OWN netns is the docker bridge — the S-2 wrong // answer). Best-effort per item, the dump's tolerance style: a failed read yields that // item's error string in place and never aborts the dump. lan_address is the SAME live // value the Hálózat card shows, so a support session can cross-check the two. netSnap := s.guestNetSnapshot() network := map[string]interface{}{} if e := netSnap.Errors["interfaces"]; e != "" { network["interfaces"] = "error: " + e } else { ifaces := make([]map[string]interface{}, 0, len(netSnap.Interfaces)) for _, gi := range netSnap.Interfaces { ifaces = append(ifaces, map[string]interface{}{ "name": gi.Name, "up": gi.Up, "addresses": gi.Addresses, }) } network["interfaces"] = ifaces } if e := netSnap.Errors["route"]; e != "" { network["default_route"] = "error: " + e } else { network["default_route"] = map[string]interface{}{ "gateway": netSnap.Gateway, "interface": netSnap.RouteInterface, } } if e := netSnap.Errors["dns"]; e != "" { network["dns_servers"] = "error: " + e } else { network["dns_servers"] = netSnap.DNSServers } network["lan_address"] = netSnap.LANAddress dump["network"] = network // Hub hubInfo := map[string]interface{}{ "url": s.cfg.Hub.URL, "enabled": s.cfg.Hub.Enabled, } if s.hubPushStatusFn != nil { st := s.hubPushStatusFn() hubInfo["last_attempt"] = st.LastAttempt hubInfo["last_success"] = st.LastSuccess hubInfo["last_error"] = st.LastError hubInfo["consecutive_failures"] = st.Consecutive } dump["hub"] = hubInfo // Scheduler if s.scheduler != nil { jobs := s.scheduler.GetJobs() jobList := make([]map[string]interface{}, 0, len(jobs)) for _, j := range jobs { entry := map[string]interface{}{ "name": j.Name, "running": j.Running, } if j.Interval > 0 { entry["type"] = "every" entry["interval"] = j.Interval.String() } else if j.Schedule != "" { entry["type"] = "daily" entry["schedule"] = j.Schedule } if !j.LastRun.IsZero() { entry["last_run"] = j.LastRun } if j.LastErr != nil { entry["last_error"] = j.LastErr.Error() } jobList = append(jobList, entry) } dump["scheduler"] = jobList } // Health healthReport := monitor.RunHealthCheck(s.cfg, s.cpuCollector, storagePaths, s.settings.GetSMBSettings(), s.logger) dump["health"] = map[string]interface{}{ "status": healthReport.Status, "issues": healthReport.Issues, "warnings": healthReport.Warnings, } // Notifications prefs := s.settings.GetNotificationPrefs() dump["notifications"] = map[string]interface{}{ "email": prefs.Email, "enabled_events": prefs.EnabledEvents, "cooldown_hours": prefs.CooldownHours, } // Self-update if s.updater != nil { status := s.updater.GetStatus() dump["self_update"] = map[string]interface{}{ "enabled": true, "auto": s.cfg.SelfUpdate.AutoUpdate, "last_check": status.LastCheck, } } else { dump["self_update"] = map[string]interface{}{"enabled": false} } // Alerts if s.alertManager != nil { dump["alerts"] = s.alertManager.GetAlerts(s.langFor(r)) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(dump) } // ── Section 2: Notification & Event testing ───────────────────────── func (s *Server) debugTestEvent(w http.ResponseWriter, r *http.Request) { var req struct { EventType string `json:"event_type"` Severity string `json:"severity"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil) return } if req.EventType == "" { req.EventType = "test" } if req.Severity == "" { req.Severity = "info" } if s.notifier == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil) return } statusCode, err := s.notifier.PushTestEventSync(req.EventType, req.Severity, fmt.Sprintf("Teszt esemény: %s (%s)", req.EventType, req.Severity)) if err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{ "hub_status": statusCode, }) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Esemény elküldve (HTTP %d)", statusCode), map[string]interface{}{"hub_status": statusCode}) } func (s *Server) debugEventHistory(w http.ResponseWriter, r *http.Request) { if s.notifier == nil { writeDebugJSON(w, http.StatusOK, true, "", []interface{}{}) return } history := s.notifier.GetEventHistory(20) writeDebugJSON(w, http.StatusOK, true, "", history) } // ── Section 3: Backup testing ─────────────────────────────────────── func (s *Server) debugTriggerDBDump(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil) return } s.backupMgr.MarkManualRun() // R-182: a person pressed this, so its digest must not be collapsed go func() { if err := s.backupMgr.RunDBDumps(context.Background()); err != nil { s.logger.Printf("[WARN] Debug DB dump failed: %v", err) } }() writeDebugJSON(w, http.StatusOK, true, "DB dump elindítva", nil) } // debugRunCrossDrive runs the Tier-2 (cross-drive) copy for every deployed HDD app (R-400). // // ASYNCHRONOUS, following debugTriggerDBDump above rather than the integrity button below: a Tier-2 // sweep across every app copies real data and is bounded by disk speed, not by a timeout, so holding // the operator's request open for it would time the browser out and teach nothing. The integrity // button is synchronous because the operator pressed it to learn an ANSWER; this one is pressed to // make something happen, and the log is where its outcome belongs. // // It reports WHICH apps it started for, not just „elindítva". A sweep that quietly matched zero apps // and a sweep that matched eight are different facts, and a message that cannot tell them apart is // how a button reads as working while doing nothing — the class this whole row exists to close. func (s *Server) debugRunCrossDrive(w http.ResponseWriter, r *http.Request) { if s.backupMgr == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Backup manager nincs konfigurálva", nil) return } names := crossDriveTargets(s.stackMgr.GetStacks(), func(name string) bool { return s.backupMgr.Tier2Info(name).IsHDDApp }) if len(names) == 0 { writeDebugJSON(w, http.StatusOK, true, "Nincs olyan telepített alkalmazás, amelynek 2. mentése futtatható lenne", map[string]interface{}{"apps": names, "count": 0}) return } go func(apps []string) { for _, name := range apps { if err := s.backupMgr.RunTier2(name); err != nil { s.logger.Printf("[WARN] [web] debug cross-drive run for %s failed: %v", name, err) continue } s.logger.Printf("[INFO] [web] debug cross-drive run for %s completed", name) } }(names) writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Cross-drive mentés elindítva %d alkalmazásra", len(names)), map[string]interface{}{"apps": names, "count": len(names)}) } // crossDriveTargets picks the apps a Tier-2 sweep should run for. // // A NAMED FUNCTION rather than an inline loop so both of its answers are assertable. The empty answer // and the non-empty answer are the two outcomes a dead button and a working one are told apart by, and // building a deployed HDD-backed stack inside a web test is not practical — so the selection is proven // here and the dispatch is proven at the route. func crossDriveTargets(all []stacks.Stack, isHDDApp func(name string) bool) []string { names := make([]string, 0) for _, st := range all { if !st.Deployed { continue } // Tier 2 is an off-DRIVE copy: an app with no HDD path has no second drive to copy to, and // RunTier2 would return "no source drive" for every one of them. if !isHDDApp(st.Name) { continue } names = append(names, st.Name) } return names } // debugRunOffsiteProof runs the nightly off-site content proof by hand (R-87). // // SYNCHRONOUS, like the integrity button beside it and for the same reason: the operator pressed this // to learn an ANSWER. One app's unit measured 2.3-4.0 s on demo-hp, so there is nothing to wait for. // // DUE-NESS IS NOT BYPASSED, and that is the ONE place this differs from the integrity button. There, // forcing means "check the store again", which is always answerable. Here, due-ness IS the target // selection — an app is due when its newest snapshot has not been proved — so ignoring it would mean // inventing a different way to choose an app, i.e. a second code path, which is exactly what having // one function is meant to prevent. When nothing is due the button says so, honestly. // // Every other guard is intact, including the single-writer flag: a hand-run during a backup SKIPS // exactly as the scheduled one would, because "the operator asked for it" is precisely the reasoning // that would reintroduce the hazard. func (s *Server) debugRunOffsiteProof(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.RunOffsiteProof == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } res := s.debugCallbacks.RunOffsiteProof() data := map[string]interface{}{ "stack": res.Stack, "snapshot": res.SnapshotID, "verdict": res.Verdict(), "reason": string(res.Judgement.Reason), "missing": res.Judgement.Missing, "duration_ms": res.Duration.Milliseconds(), "skipped": res.Skipped, "skip_reason": res.SkipReason, "no_snapshot": res.NoSnapshot, } switch { case res.Skipped: writeDebugJSON(w, http.StatusOK, true, "Kihagyva: "+res.SkipReason, data) case res.NoSnapshot: writeDebugJSON(w, http.StatusOK, true, "Nincs esedékes alkalmazás — minden legújabb mentés már ellenőrizve", data) case res.Err != nil: // NOT a verdict about the backup: the restore did not finish, so nothing was concluded. data["error"] = res.Err.Error() writeDebugJSON(w, http.StatusOK, true, "A visszaállítás nem futott le — a mentésről semmi nem derült ki", data) case res.Judgement.Verdict == backup.UnitProofPass: writeDebugJSON(w, http.StatusOK, true, "A mentés tartalmazza az alkalmazás adatait", data) case res.Judgement.Verdict == backup.UnitProofCannotJudge: writeDebugJSON(w, http.StatusOK, true, "Nem megítélhető — a mentés nem hordoz elég információt", data) default: writeDebugJSON(w, http.StatusOK, false, "A mentés olvasható, de nem tartalmazza az alkalmazás adatait", data) } } // debugRunIntegrityCheck runs the off-site integrity check by hand (R-359/R-397). // // SYNCHRONOUS on purpose, unlike the DB-dump button beside it: the operator pressed this to learn an // ANSWER, and a fire-and-forget that returns „elindítva" would leave them reading logs for the result. // A structure check is seconds-to-minutes; its own timeout bounds it. // // Due-ness is IGNORED — "run it now" is the whole point of a button. Every other guard is intact, // including the single-writer flag, so a hand-run during a backup SKIPS exactly as the scheduled one // would. That is asserted by TestR397_DebugRunStillHonoursTheRunningFlag, because "the operator asked // for it" is precisely the reasoning that would reintroduce the hazard. func (s *Server) debugRunIntegrityCheck(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.RunIntegrityCheck == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } res := s.debugCallbacks.RunIntegrityCheck(true) data := map[string]interface{}{ "ok": res.OK, "skipped": res.Skipped, "skip_reason": res.SkipReason, "unreachable": res.Unreachable, "duration_ms": res.Duration.Milliseconds(), "read_data_subset": res.ReadDataSubset, // R-399: the depth as it is RECORDED, so the JSON says "structure" rather than an empty string // a reader has to interpret. read_data_subset above stays raw for anyone parsing the argv. "depth": backup.IntegrityDepthCode(res.ReadDataSubset), } switch { case res.Skipped: writeDebugJSON(w, http.StatusOK, true, "Kihagyva: "+res.SkipReason, data) case res.Unreachable: // NOT reported as a failure: the store could not be opened, so nothing was concluded about it. writeDebugJSON(w, http.StatusOK, true, "A tároló nem érhető el — az ellenőrzés nem futott le", data) case res.OK: writeDebugJSON(w, http.StatusOK, true, "Az ellenőrzés rendben lezajlott", data) default: writeDebugJSON(w, http.StatusOK, false, "Az ellenőrzés hibát talált a tárolóban", data) } } // ── Section 5: Hub & connectivity ─────────────────────────────────── func (s *Server) debugHubPush(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.TriggerHubReportPush == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } start := time.Now() err := s.debugCallbacks.TriggerHubReportPush() latency := time.Since(start).Milliseconds() if err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency}) return } writeDebugJSON(w, http.StatusOK, true, "Hub jelentés elküldve", 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) return } statusCode, latency, err := s.debugCallbacks.HubConnectivityTest() data := map[string]interface{}{ "status_code": statusCode, "latency_ms": latency, } if err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Hub elérhető (HTTP %d, %dms)", statusCode, latency), data) } func (s *Server) debugPreferencesSync(w http.ResponseWriter, r *http.Request) { if s.notifier == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Notifier nincs konfigurálva", nil) return } prefs := s.settings.GetNotificationPrefs() if err := s.notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents, prefs.CooldownHours); err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil) return } writeDebugJSON(w, http.StatusOK, true, "Preferenciák szinkronizálva", nil) } func (s *Server) debugGiteaConnectivity(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.GiteaConnectivityTest == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } statusCode, latency, err := s.debugCallbacks.GiteaConnectivityTest() data := map[string]interface{}{ "status_code": statusCode, "latency_ms": latency, } if err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), data) return } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Gitea elérhető (HTTP %d, %dms)", statusCode, latency), data) } // ── Section: Telemetry testing ─────────────────────────────────────── func (s *Server) debugTelemetry(w http.ResponseWriter, r *http.Request) { if s.debugCallbacks == nil || s.debugCallbacks.GetTelemetryPreview == nil { writeDebugJSON(w, http.StatusNotImplemented, false, "Nem bekötött", nil) return } start := time.Now() telemetry, err := s.debugCallbacks.GetTelemetryPreview() latency := time.Since(start).Milliseconds() if err != nil { writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), map[string]interface{}{"latency_ms": latency}) return } totalErrors := 0 totalWarnings := 0 for _, app := range telemetry { totalErrors += app.LogErrors totalWarnings += app.LogWarnings } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("Telemetria összegyűjtve: %d app, %d hiba, %d figyelmeztetés (%dms)", len(telemetry), totalErrors, totalWarnings, latency), map[string]interface{}{ "latency_ms": latency, "app_count": len(telemetry), "total_errors": totalErrors, "total_warnings": totalWarnings, "app_telemetry": telemetry, }) } // ── Section 6: Self-update ────────────────────────────────────────── func (s *Server) debugSelfUpdateDryRun(w http.ResponseWriter, r *http.Request) { if s.updater == nil { writeDebugJSON(w, http.StatusBadRequest, false, "Self-update nincs konfigurálva", nil) return } result := s.updater.DryRun() writeDebugJSON(w, http.StatusOK, true, "", result) } // ── Section 7: DR / Setup ─────────────────────────────────────────── func (s *Server) debugTriggerSetupWizard(w http.ResponseWriter, r *http.Request) { var req struct { Confirm string `json:"confirm"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen kérés", nil) return } if req.Confirm != "RESET" { writeDebugJSON(w, http.StatusBadRequest, false, "Érvénytelen megerősítés — írja be: RESET", 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 { writeDebugJSON(w, http.StatusInternalServerError, false, fmt.Sprintf("Marker fájl írási hiba: %v", err), nil) return } writeDebugJSON(w, http.StatusOK, true, "Controller újraindítása setup módba...", nil) // Exit after response is sent so the container restarts into setup mode go func() { time.Sleep(500 * time.Millisecond) os.Exit(0) }() } // ── Section 8: Log viewer ─────────────────────────────────────────── func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) { if s.logBuffer == nil { writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "entries": []interface{}{}, "total": 0, }) return } level := r.URL.Query().Get("level") if level == "" { level = "DEBUG" } limit := 200 if v := r.URL.Query().Get("limit"); v != "" { // fix-6: allow up to the ring capacity (5000) so the viewer can pull the full retained window. if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 { limit = n } } var after time.Time if v := r.URL.Query().Get("after"); v != "" { if t, err := time.Parse(time.RFC3339Nano, v); err == nil { after = t } } entries, total := s.logBuffer.Entries(level, limit, after) writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "entries": entries, "total": total, }) } // debugAgentLogs proxies the host agent's always-DEBUG capture ring (agent ≥ 0.83.0) // for the Debug page's "Ügynök" tab. A pre-0.83 agent has no such route — the typed // 404 (StatusError, the features.go precedent) renders the "available after the // agent's next update" notice instead of an error; nothing else is gated on it. func (s *Server) debugAgentLogs(w http.ResponseWriter, r *http.Request) { fetch := s.agentLogsFn if fetch == nil { client, err := s.agentClient() if err != nil { writeDebugJSON(w, http.StatusOK, false, "Az ügynök nincs konfigurálva ezen a rendszeren.", nil) return } fetch = client.DebugLogs } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() resp, err := fetch(ctx) if err != nil { var se *agentapi.StatusError if errors.As(err, &se) && se.Code == http.StatusNotFound { writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "unsupported": true, "notice": "Az ügynök naplónézete az ügynök következő frissítése után érhető el.", }) return } writeDebugJSON(w, http.StatusOK, false, s.errText(r, err), nil) return } writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "entries": resp.Entries, "total": resp.Total, }) } // ── Section 9: App Export/Import ───────────────────────────────────── func (s *Server) debugAppExportStatus(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { writeDebugJSON(w, http.StatusOK, true, "", map[string]interface{}{ "available": false, }) return } info := s.appExporter.GetDebugInfo() // Scan for bundles drives := s.storageDriveList() bundles := appexport.ScanForBundles(drives) // Scan for stale temp files staleFiles := appexport.ScanForStaleTempFiles(drives) info["bundle_count"] = len(bundles) info["stale_temp_files"] = staleFiles info["stale_temp_count"] = len(staleFiles) info["available"] = true // Export dirs exportDirs := make([]map[string]interface{}, 0, len(drives)) for _, d := range drives { dir := appexport.ExportDir(d.Path) dirInfo := map[string]interface{}{ "path": dir, "label": d.Label, } if stat, err := os.Stat(dir); err == nil { dirInfo["exists"] = true dirInfo["modified"] = stat.ModTime() } else { dirInfo["exists"] = false } exportDirs = append(exportDirs, dirInfo) } info["export_dirs"] = exportDirs writeDebugJSON(w, http.StatusOK, true, "", info) } func (s *Server) debugAppExportBundles(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil) return } drives := s.storageDriveList() bundles := appexport.ScanForBundles(drives) writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("%d csomag található", len(bundles)), map[string]interface{}{"bundles": bundles}) } func (s *Server) debugAppExportCleanup(w http.ResponseWriter, r *http.Request) { if s.appExporter == nil { writeDebugJSON(w, http.StatusBadRequest, false, "App export not available", nil) return } drives := s.storageDriveList() staleFiles := appexport.ScanForStaleTempFiles(drives) if len(staleFiles) == 0 { writeDebugJSON(w, http.StatusOK, true, "Nincs eltávolítandó temp fájl", nil) return } removed := 0 for _, f := range staleFiles { if err := os.Remove(f); err != nil { s.logger.Printf("[WARN] Failed to remove stale temp file %s: %v", f, err) } else { s.logger.Printf("[INFO] Removed stale temp file: %s", f) removed++ } } writeDebugJSON(w, http.StatusOK, true, fmt.Sprintf("%d/%d temp fájl eltávolítva", removed, len(staleFiles)), nil) }