Files
felhom-controller/controller/internal/web/handler_debug.go
T
admin 303129e3af
gates / gates (push) Successful in 12s
v0.231.0: the off-site proof gets a by-hand trigger, like its integrity sibling (R-87)
Without it the only way to see the job work is to wait for 05:30, which makes live
validation and any future diagnosis a next-day exercise. Same function as the scheduled
job - no second code path.

ONE deliberate difference from the integrity button: due-ness is NOT bypassed. 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 second way to choose an app, exactly what having one
function prevents. When nothing is due the button says so, honestly.

Every other guard intact, including the single-writer flag: a hand-run during a backup
SKIPS exactly as the scheduled one would.

POST /api/debug/backup/offsite-proof, button beside "Restic integritas" on the debug page.
debug_route_gate pairs the two, so a button with no dispatch (R-400's shape) cannot ship.
2026-08-31 21:09:09 +02:00

874 lines
31 KiB
Go

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-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()
}
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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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, err.Error(), 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)
}