7c05b59708
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
874 lines
31 KiB
Go
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(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)
|
|
}
|