0d52a42c17
gates / gates (push) Successful in 12s
Nothing ever verified that the off-site copies are still readable. The whole-guest tier has verify jobs; the tier holding the customer's documents and photos had none -- the complete set of restic verbs this controller used contained no `check`. We would have found out at restore time, with a customer waiting. On 2026-08-21 a deliberately damaged pack was caught at once by plain `restic check`; we had never run it. R-397: NotifyIntegrityOK/NotifyIntegrityFailed existed with no caller, the hub allowlists both event types and carries the Hungarian text for both, the settings checkbox exists, and the debug button posts to /api/debug/backup/ integrity. Everything was built except the part that runs. SIXTH instance of that shape in this project. THE HAZARD SHAPES THE WHOLE DESIGN. resticStep self-heals a crash lock by running `unlock --remove-all` and retrying, and its own comment records why that is safe: every caller holds the in-process single-flight mutex, so any lock it meets is stale. A check that did not take that flag could meet a LIVE prune's lock from this same box, remove it, and retry over the top of it. So the check TAKES THE FLAG and SKIPS rather than waits -- waiting would pin the nightly backup behind it, and a skip costs nothing because due-ness makes tomorrow try again. TestR359_SkipsWhenRunningFlagHeld asserts the NON-EFFECTS: restic never invoked, `unlock` never in any argv. Its red-proof prints the real thing -- restic running `check` while the flag was held. DUE-NESS, NOT A WEEKDAY. Daily job, weekly behaviour: "is the last successful check older than 7 days?" not "is it Sunday?". R-341 is exactly the other shape, a dated check quietly missed and never caught up. No Weekly primitive added. THREE OUTCOMES, NOT TWO. Skipped, Unreachable and failed are different facts. "I could not look" is not "I looked and it is broken" -- R-339 already owns reachability, and a second alarm for the same fact trains the operator to discount the one alarm that means the backups are damaged. A timeout is unreachable, never damage. A failure advances due-ness (a broken store must not be re-checked nightly); a skip and an unreachable store do not. Success is severity `info`, which severityNotifies DROPS -- it mails NOBODY, by design. A weekly success e-mail is how people stop reading their alerts. The customer gets a SENTENCE; restic's words go to the log, truncated (R-379: 615 bytes of raw database text reached a customer once). read-data-subset ships OFF and a malformed value is refused at read time rather than handed to restic, where one typo would fail the whole check. Published on OffboxReportStatus, NOT on report.BackupReport's IntegrityOK -- those were retired by R-331 YESTERDAY and TestBackupReport_DeadFieldsStayZero still passes unmodified. Also: the monitoring page stopped promising a Sunday job that never existed, and the debug button got its dispatch case. PART 0 WAS NOT BUILT, AND R-398 WAS MY OWN MISTAKE. The seam it asked for already exists: offboxRunner/SetOffboxRunner/m.runner() has been injectable since the off-site tier shipped, and other tests drive restic-backed paths through it. A resticStepFn seam would have been WORSE here -- it would replace the `unlock --remove-all` escalation and hide it from the assertions that must see it. R-358's AST ordering test is converted to a real execution test instead, which immediately surfaced something the AST walk could not: unlockStale legitimately runs before the restore. Four red-proofs, each printing the pre-fix behaviour. Green gate: 28 packages, rc 0. All 12 controller gates OK.
747 lines
24 KiB
Go
747 lines
24 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/monitor"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"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
|
|
}
|
|
|
|
// 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-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)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
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)
|
|
}
|