v0.116.0: observability pass — always-on debug ring + leveled sweep + agent tab + self-log pull — MinAgent: 0.81.0

Capture layer: LogBuffer always exists; logger = MultiWriter(LevelFilterWriter
(stdout, logging.level), ring) so DEBUG detail exists remotely without a config
flip while docker logs keep respecting the level. New internal/logx leveled
helpers. Report ACK gains controller_log_requested (additive); next report
ships controller_log_tail (128KB, consume-once, app-tail wire byte-compatible).
Debug page: Vezérlő|Ügynök tabs; agent tab proxies agent /debug/logs with the
pre-0.83 notice on typed 404. Sweep: netstorage_job phases, netprobe, handler
validation refusals + orphan WARN, SupportsWithSource gate line, agentapi
per-call DEBUG, migrate phase lines, tier2/offbox unswallowed persists.
Red-proofs: filter-disabled, drain-removed, dropped-phase-line all FAIL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 16:45:57 +02:00
parent 647116480a
commit 26a43708b7
25 changed files with 797 additions and 31 deletions
@@ -58,6 +58,10 @@ func (s *Server) agentClient() (*agentapi.Client, error) {
s.agentCliOnce.Do(func() {
s.agentCli, s.agentCliErr = agentapi.New(
s.cfg.LocalAPI.Endpoint, s.cfg.LocalAPI.Token, s.cfg.LocalAPI.Fingerprint)
if s.agentCliErr == nil {
// v0.116.0: per-call DEBUG traces into the capture ring (method/path/status/duration).
s.agentCli.SetLogger(s.logger)
}
})
return s.agentCli, s.agentCliErr
}
@@ -0,0 +1,82 @@
package web
import (
"bytes"
"io"
"log"
"strings"
"testing"
"time"
)
// S1 capture-at-info (controller half): with the v0.116.0 writer layout —
// MultiWriter(LevelFilterWriter(stdout, "info"), LogBuffer) — a [DEBUG] line
// reaches the RING and is ABSENT from stdout, while [INFO] reaches both.
// Companion red-proof: gate the ring behind the same filter (the pre-fix shape,
// where the ring only existed at logging.level=debug) → the ring assertion fails.
func TestCaptureAtInfo_RingHoldsDebugStdoutDoesNot(t *testing.T) {
var stdout bytes.Buffer
lb := NewLogBuffer(50)
logger := log.New(io.MultiWriter(NewLevelFilterWriter(&stdout, "info"), lb), "", log.LstdFlags)
logger.Printf("[DEBUG] [web] netstorage add \"vids\" phase agent_add -> verifying")
logger.Printf("[INFO] [web] network storage added + verified: vids")
entries, total := lb.Entries("DEBUG", 0, time.Time{})
if total != 2 || len(entries) != 2 {
t.Fatalf("ring holds %d entries (returned %d), want 2 — DEBUG must be captured at logging.level=info", total, len(entries))
}
if entries[0].Level != "DEBUG" || !strings.Contains(entries[0].Message, "phase agent_add") {
t.Errorf("ring entry 0 = %+v, want the DEBUG phase line", entries[0])
}
if strings.Contains(stdout.String(), "phase agent_add") {
t.Errorf("stdout carries the DEBUG line at level info:\n%s", stdout.String())
}
if !strings.Contains(stdout.String(), "added + verified") {
t.Errorf("stdout missing the INFO line:\n%s", stdout.String())
}
}
// The filter respects higher minimums too (warn drops INFO) and passes untagged lines at info.
func TestLevelFilterWriter_Thresholds(t *testing.T) {
cases := []struct {
min string
line string
wants bool
}{
{"info", "[DEBUG] x", false},
{"info", "[INFO] x", true},
{"info", "untagged line", true}, // parses as INFO
{"warn", "[INFO] x", false},
{"warn", "[ERROR] x", true},
{"debug", "[DEBUG] x", true},
}
for _, c := range cases {
var out bytes.Buffer
logger := log.New(NewLevelFilterWriter(&out, c.min), "", log.LstdFlags)
logger.Printf("%s", c.line)
got := strings.Contains(out.String(), c.line)
if got != c.wants {
t.Errorf("min=%s line=%q passed=%v want %v", c.min, c.line, got, c.wants)
}
}
}
// Lines renders chronological plain-text lines and honors the byte budget by
// keeping the NEWEST lines (the controller_log_tail 128 KB cap).
func TestLogBufferLines_ByteBudgetKeepsNewest(t *testing.T) {
lb := NewLogBuffer(10)
logger := log.New(lb, "", log.LstdFlags)
for i := 0; i < 5; i++ {
logger.Printf("[INFO] line-%d %s", i, strings.Repeat("x", 80))
}
all := lb.Lines(0)
if len(all) != 5 || !strings.Contains(all[4], "line-4") {
t.Fatalf("uncapped lines wrong: %d %v", len(all), all)
}
budget := len(all[3]) + len(all[4]) + 2
capped := lb.Lines(budget)
if len(capped) >= 5 || !strings.Contains(capped[len(capped)-1], "line-4") {
t.Errorf("byte cap kept %d lines, newest=%q — must drop oldest first", len(capped), capped[len(capped)-1])
}
}
+39
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
@@ -13,6 +14,7 @@ import (
"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/report"
@@ -79,6 +81,8 @@ func (s *Server) handleDebugAPI(w http.ResponseWriter, r *http.Request) {
// 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:
@@ -528,6 +532,41 @@ func (s *Server) debugLogBuffer(w http.ResponseWriter, r *http.Request) {
})
}
// 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) {
+34
View File
@@ -0,0 +1,34 @@
package web
import "io"
// LevelFilterWriter drops log lines BELOW a minimum level from the wrapped writer.
// It is the stdout half of the v0.116.0 capture layer: the logger now always fans
// out to (stdout-filter, LogBuffer ring) — the ring captures EVERYTHING (DEBUG
// included) for remote diagnostics, while docker logs keep respecting
// logging.level exactly as before. Lines with no recognizable [LEVEL] tag parse
// as INFO (parseLine), so untagged output keeps flowing at the info level.
type LevelFilterWriter struct {
w io.Writer
min int
}
// NewLevelFilterWriter wraps w so only lines at/above minLevel pass ("debug"
// passes everything; unknown falls back to debug = pass-through).
func NewLevelFilterWriter(w io.Writer, minLevel string) *LevelFilterWriter {
return &LevelFilterWriter{w: w, min: levelPriority(minLevel)}
}
// Write parses the line's level tag and forwards it only when it clears the
// minimum. It always reports the full length as written (a dropped line is a
// success, not an error — log.Logger must never see a short write).
func (f *LevelFilterWriter) Write(p []byte) (int, error) {
entry := parseLine(string(p))
if levelPriority(entry.Level) < f.min {
return len(p), nil
}
if _, err := f.w.Write(p); err != nil {
return 0, err
}
return len(p), nil
}
+41
View File
@@ -103,6 +103,47 @@ func (lb *LogBuffer) Entries(minLevel string, limit int, after time.Time) ([]Log
return result, total
}
// Lines renders the held entries as plain text lines (chronological), dropping
// from the HEAD (oldest) to honor maxBytes so the newest lines survive — the
// report-channel controller_log_tail budget (maxBytes ≤ 0 → no byte cap).
// Format mirrors the agent ring's: "<RFC3339> [<LEVEL>] <message>".
func (lb *LogBuffer) Lines(maxBytes int) []string {
lb.mu.RLock()
total := lb.size
start := 0
if !lb.full {
total = lb.pos
} else {
start = lb.pos
}
entries := make([]LogEntry, 0, total)
for i := 0; i < total; i++ {
entries = append(entries, lb.entries[(start+i)%lb.size])
}
lb.mu.RUnlock()
lines := make([]string, len(entries))
for i, e := range entries {
src := ""
if e.Source != "" {
src = e.Source + ": "
}
lines[i] = e.Timestamp.Format(time.RFC3339) + " [" + e.Level + "] " + src + e.Message
}
if maxBytes <= 0 {
return lines
}
budget := 0
keepFrom := len(lines)
for i := len(lines) - 1; i >= 0; i-- {
budget += len(lines[i]) + 1 // +1 for the newline it represents
if budget > maxBytes {
break
}
keepFrom = i
}
return lines[keepFrom:]
}
// parseLine parses a single log line into a LogEntry.
func parseLine(line string) LogEntry {
entry := LogEntry{
+10 -2
View File
@@ -9,6 +9,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -79,20 +80,24 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
}
name := strings.TrimSpace(req.Name)
if !mountNameRe.MatchString(name) {
logx.Debugf(s.logger, "[web] netstorage add refused by validation: name %q", name)
writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen név (csak betűk, számok, _ és -)", nil)
return
}
proto := strings.ToLower(strings.TrimSpace(req.Protocol))
if proto != "nfs" && proto != "smb" {
logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: protocol %q", name, proto)
writeDiskJSON(w, http.StatusBadRequest, false, "protokoll: nfs vagy smb", nil)
return
}
server, export := strings.TrimSpace(req.Server), strings.TrimSpace(req.Export)
if server == "" || export == "" {
logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: empty server/export", name)
writeDiskJSON(w, http.StatusBadRequest, false, "a szerver és a megosztás kötelező", nil)
return
}
if proto == "smb" && (req.Username == "" || req.Password == "") {
logx.Debugf(s.logger, "[web] netstorage add %q refused by validation: smb credentials missing", name)
writeDiskJSON(w, http.StatusBadRequest, false, "SMB-hez felhasználónév és jelszó szükséges", nil)
return
}
@@ -114,8 +119,8 @@ func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request) {
// instead of failing mid-pipeline in `verifying` with a misleading rollback. Runs BEFORE the
// single-flight claim (a refused add must not consume the slot). SupportUnknown passes: a down
// agent speaks through the existing agent-error paths, never as "too old".
support := s.netFeatures.Supports(r.Context(), agent, agentapi.FeatureNetstorageVerify)
s.logger.Printf("[DEBUG] [web] netstorage add %q capability gate: %s=%s", name, agentapi.FeatureNetstorageVerify, support)
support, supSource := s.netFeatures.SupportsWithSource(r.Context(), agent, agentapi.FeatureNetstorageVerify)
s.logger.Printf("[DEBUG] [web] netstorage add %q capability gate: %s=%s (source=%s)", name, agentapi.FeatureNetstorageVerify, support, supSource)
if support == agentapi.SupportNo {
s.logger.Printf("[WARN] [web] netstorage add %q refused: agent predates %s (probe 404)", name, agentapi.FeatureNetstorageVerify)
writeDiskJSON(w, http.StatusPreconditionFailed, false, netAddOutdatedMsg, map[string]any{"code": "agent_outdated"})
@@ -199,6 +204,9 @@ func (s *Server) networkStorageItems(ctx context.Context) []networkStorageItem {
}
}
sort.Strings(orphans)
if len(orphans) > 0 {
logx.Warnf(s.logger, "[web] netstorage: %d orphan agent-side share(s) with no registry entry: %v", len(orphans), orphans)
}
for _, name := range orphans {
m := live[name]
items = append(items, networkStorageItem{
+33 -5
View File
@@ -9,6 +9,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
@@ -129,7 +130,19 @@ func (s *Server) netProbe(ctx context.Context, dir string) probeOutcome {
if s.netProbeFn != nil {
return s.netProbeFn(ctx, dir)
}
return runNetProbe(ctx, dir)
logx.Debugf(s.logger, "[web] netprobe exec start (uid-1000 re-exec) dir=%s", dir)
o := runNetProbe(ctx, dir)
logx.Debugf(s.logger, "[web] netprobe result: ok=%v category=%s detail=%s",
o.OK, o.Category, firstLine(o.Detail))
return o
}
// firstLine bounds a raw detail to its first line for a log field.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// startNetAdd claims the single-flight slot and launches the detached orchestration. false = an add
@@ -153,8 +166,13 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
defer s.netAdd.release()
ctx, cancel := context.WithTimeout(context.Background(), netAddDeadline)
defer cancel()
start := time.Now()
logx.Infof(s.logger, "[web] netstorage add %q started (%s %s:%s, mapped_uid=%d)",
req.Name, req.Protocol, req.Server, req.Export, req.MappedUID)
setPhase := func(p string) {
logx.Debugf(s.logger, "[web] netstorage add %q phase %s -> %s (%dms elapsed)",
req.Name, job.Phase, p, time.Since(start).Milliseconds())
job.Phase = p
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
@@ -166,15 +184,19 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
job.Detail = detail
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
s.logger.Printf("[WARN] [web] netstorage add %q failed: category=%s detail=%s", req.Name, category, detail)
logx.Warnf(s.logger, "[web] netstorage add %q failed: category=%s detail=%s (%dms)",
req.Name, category, detail, time.Since(start).Milliseconds())
}
rollback := func(why string) {
logx.Debugf(s.logger, "[web] netstorage add %q rollback started (%s)", req.Name, why)
rctx, rcancel := context.WithTimeout(context.Background(), netRollbackBudget)
defer rcancel()
if err := agent.RemoveNetStorage(rctx, req.Name); err != nil {
// Best-effort by design: the agent may have auto-rolled-back already (double-remove is
// harmless) — but log it, a REAL leftover shows up as an orphan row in the list.
s.logger.Printf("[WARN] [web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err)
logx.Warnf(s.logger, "[web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err)
} else {
logx.Infof(s.logger, "[web] netstorage add %q rolled back (%s)", req.Name, why)
}
}
@@ -192,9 +214,13 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
// Phase 2 — poll the agent's verify slot. On a pre-verify agent (no job started) skip straight
// to the probe: the mount-trigger check then happens implicitly through the probe's write.
logx.Debugf(s.logger, "[web] netstorage add %q agent add accepted (verify=%s job_id=%s guest_path=%s)",
req.Name, res.Verify, res.JobID, res.GuestPath)
if res.Verify == "started" {
setPhase(netAddPhaseVerifying)
verdict, verr := s.pollAgentVerify(ctx, agent, res.JobID)
logx.Debugf(s.logger, "[web] netstorage add %q agent verify verdict: phase=%s code=%s (err=%v)",
req.Name, verdict.Phase, verdict.Code, verr)
switch {
case verr != nil:
rollback("verify poll failed")
@@ -218,6 +244,8 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
// Phase 3 — the in-guest uid-1000 write probe (the squash trap).
setPhase(netAddPhaseProbing)
outcome := s.netProbe(ctx, res.GuestPath)
logx.Debugf(s.logger, "[web] netstorage add %q probe verdict: ok=%v category=%s warn=%q",
req.Name, outcome.OK, outcome.Category, outcome.Warn)
if !outcome.OK {
rollback("probe failed")
fail(outcome.Category, outcome.Detail)
@@ -250,8 +278,8 @@ func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, la
job.Warn = outcome.Warn
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
s.logger.Printf("[INFO] [web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q)",
req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn)
logx.Infof(s.logger, "[web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q) in %dms",
req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn, time.Since(start).Milliseconds())
}
// pollAgentVerify polls the agent's verify slot until it leaves `running` (or ctx expires).
@@ -0,0 +1,121 @@
package web
import (
"context"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)
// S6 old-agent degradation: the Debug page agent tab against a pre-0.83 agent (typed
// 404 — the StatusError precedent) renders the Hungarian notice, ok:true, no error
// spam. Companion red-proof: match the error by string instead of errors.As →
// unsupported turns into an error payload → the notice assertion fails.
func TestDebugAgentLogs_Pre083AgentRendersNotice(t *testing.T) {
s := testServer(t)
s.agentLogsFn = func(context.Context) (agentapi.AgentLogsResponse, error) {
return agentapi.AgentLogsResponse{}, &agentapi.StatusError{Path: "/debug/logs", Code: http.StatusNotFound}
}
w := httptest.NewRecorder()
s.debugAgentLogs(w, httptest.NewRequest("GET", "/api/debug/agent-logs", nil))
var resp struct {
OK bool `json:"ok"`
Data struct {
Unsupported bool `json:"unsupported"`
Notice string `json:"notice"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v (%s)", err, w.Body.String())
}
if !resp.OK || !resp.Data.Unsupported {
t.Fatalf("resp = %+v, want ok+unsupported (not an error)", resp)
}
if !strings.Contains(resp.Data.Notice, "az ügynök következő frissítése után") {
t.Errorf("notice = %q, want the next-update text", resp.Data.Notice)
}
}
// A working ≥0.83 agent's entries are proxied through verbatim.
func TestDebugAgentLogs_ProxiesEntries(t *testing.T) {
s := testServer(t)
s.agentLogsFn = func(context.Context) (agentapi.AgentLogsResponse, error) {
return agentapi.AgentLogsResponse{
Entries: []agentapi.AgentLogEntry{{Level: "DEBUG", Message: "netverify: job started"}},
Total: 1,
}, nil
}
w := httptest.NewRecorder()
s.debugAgentLogs(w, httptest.NewRequest("GET", "/api/debug/agent-logs", nil))
body := w.Body.String()
if !strings.Contains(body, "netverify: job started") || !strings.Contains(body, `"total":1`) {
t.Errorf("proxy body missing agent entries: %s", body)
}
}
// A non-404 agent error (down, 5xx) is an honest error payload — never the notice.
func TestDebugAgentLogs_OtherErrorIsError(t *testing.T) {
s := testServer(t)
s.agentLogsFn = func(context.Context) (agentapi.AgentLogsResponse, error) {
return agentapi.AgentLogsResponse{}, &agentapi.StatusError{Path: "/debug/logs", Code: 502}
}
w := httptest.NewRecorder()
s.debugAgentLogs(w, httptest.NewRequest("GET", "/api/debug/agent-logs", nil))
if strings.Contains(w.Body.String(), "unsupported") || !strings.Contains(w.Body.String(), `"ok":false`) {
t.Errorf("non-404 must be an error, not the notice: %s", w.Body.String())
}
}
// S7 sweep smoke (controller half): a full fake NAS add at logging.level=info must
// leave the EXPECTED ORDERED phase lines in the debug ring — the test that encodes
// "an operator can reconstruct the NAS flow from the debug view". Companion
// red-proof: drop one asserted phase line (e.g. the probe verdict Debug) → FAIL
// naming the missing marker.
func TestNetAdd_LogSequenceReconstructsFlow(t *testing.T) {
s := testServer(t)
lb := NewLogBuffer(200)
// The production writer layout at logging.level=info: ring captures everything.
s.logger = log.New(lb, "", log.LstdFlags)
agent := &fakeNetAgent{addRes: okAddRes("media"), verify: agentapi.NetVerifyStatus{Phase: "done", JobID: "job-1"}}
s.netProbeFn = func(context.Context, string) probeOutcome { return probeOutcome{OK: true} }
if !s.startNetAdd(agent, netAddReq("media"), "NAS media") {
t.Fatal("startNetAdd refused")
}
job := waitNetAdd(t, s)
if job.Phase != netAddPhaseDone {
t.Fatalf("phase = %s, want done", job.Phase)
}
lines := lb.Lines(0)
sequence := []string{
`netstorage add "media" started`,
"agent add accepted",
"phase agent_add -> verifying",
"agent verify verdict: phase=done",
"phase verifying -> probing",
"probe verdict: ok=true",
"phase probing -> registering",
"network storage added + verified",
}
pos := -1
for _, marker := range sequence {
idx := -1
for i := pos + 1; i < len(lines); i++ {
if strings.Contains(lines[i], marker) {
idx = i
break
}
}
if idx < 0 {
t.Fatalf("phase line %q missing (or out of order) — flow not reconstructable.\nring:\n%s",
marker, strings.Join(lines, "\n"))
}
pos = idx
}
}
+3
View File
@@ -74,6 +74,9 @@ type Server struct {
netAgentFn func() (netAgent, error)
netProbeFn func(ctx context.Context, dir string) probeOutcome
netListFn func(ctx context.Context) ([]agentapi.NetworkMountStatus, error)
// agentLogsFn is the Debug-page agent-tab seam (v0.116.0). nil → the shared
// agentClient().DebugLogs; tests inject (incl. the pre-0.83 typed-404 path).
agentLogsFn func(ctx context.Context) (agentapi.AgentLogsResponse, error)
// netFeatures caches the agent-capability probe (agentapi features.go) for the coupled NAS add
// semantics — the add gate + the settings-page banner read it. Zero value ready.
netFeatures agentapi.SupportCache
+46 -1
View File
@@ -212,6 +212,10 @@
<span class="section-toggle"></span>
</div>
<div class="card-body debug-section-body" style="display:none">
<div class="debug-log-filters" style="margin-bottom:.5rem">
<button class="btn btn-xs debug-log-source active" id="log-tab-controller" onclick="setLogSource('controller',this)">Vezérlő</button>
<button class="btn btn-xs debug-log-source" id="log-tab-agent" onclick="setLogSource('agent',this)">Ügynök</button>
</div>
<div class="debug-log-controls">
<div class="debug-log-filters">
<button class="btn btn-xs debug-log-filter active" data-level="DEBUG" onclick="setLogLevel('DEBUG',this)">DEBUG</button>
@@ -583,15 +587,24 @@ function triggerDR() {
});
}
// ── Section 8: Log viewer ──
// ── Section 8: Log viewer (Vezérlő | Ügynök tabs — v0.116.0) ──
var currentLogLevel = 'DEBUG';
var currentLogSource = 'controller';
var lastLogTimestamp = '';
var LOG_LEVEL_PRIORITY = {DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3};
function setLogLevel(level, btn) {
currentLogLevel = level;
document.querySelectorAll('.debug-log-filter').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
refreshLogs();
}
function setLogSource(source, btn) {
currentLogSource = source;
document.querySelectorAll('.debug-log-source').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
clearLogDisplay();
refreshLogs();
}
function initLogViewer() {
refreshLogs();
if (document.getElementById('log-auto-refresh').checked) {
@@ -606,6 +619,7 @@ function toggleLogAutoRefresh() {
}
}
function refreshLogs(append) {
if (currentLogSource === 'agent') { refreshAgentLogs(); return; }
var url = '/api/debug/logs?level=' + currentLogLevel + '&limit=500';
if (append && lastLogTimestamp) url += '&after=' + encodeURIComponent(lastLogTimestamp);
fetch(url, {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
@@ -630,6 +644,37 @@ function refreshLogs(append) {
document.getElementById('log-count').textContent = viewer.children.length + ' / ' + total + ' bejegyzés';
}).catch(function(){});
}
// The agent tab: full refresh each poll (the agent ring is small), client-side level
// filter. A pre-0.83 agent (unsupported) renders the notice instead of an error.
function refreshAgentLogs() {
fetch('/api/debug/agent-logs', {headers: csrfHeaders()}).then(function(r){return r.json()}).then(function(data) {
var viewer = document.getElementById('log-viewer');
viewer.innerHTML = '';
if (!data.ok) {
viewer.innerHTML = '<div class="debug-log-entry debug-log-warn">' + escapeHtml(data.error || 'Hiba') + '</div>';
document.getElementById('log-count').textContent = '';
return;
}
if (data.data && data.data.unsupported) {
viewer.innerHTML = '<div class="debug-log-entry">' + escapeHtml(data.data.notice) + '</div>';
document.getElementById('log-count').textContent = '';
return;
}
var entries = (data.data && data.data.entries) || [];
var total = (data.data && data.data.total) || 0;
var min = LOG_LEVEL_PRIORITY[currentLogLevel] || 0;
entries.forEach(function(e) {
if ((LOG_LEVEL_PRIORITY[e.level] || 0) < min) return;
var div = document.createElement('div');
div.className = 'debug-log-entry debug-log-' + e.level.toLowerCase();
var ts = e.timestamp ? fmtTime(e.timestamp) : '';
div.innerHTML = '<span class="text-muted">' + ts + '</span> <span class="debug-log-level">[' + e.level + ']</span> ' + escapeHtml(e.message);
viewer.appendChild(div);
});
viewer.scrollTop = viewer.scrollHeight;
document.getElementById('log-count').textContent = viewer.children.length + ' / ' + total + ' bejegyzés';
}).catch(function(){});
}
function clearLogDisplay() {
document.getElementById('log-viewer').innerHTML = '';
document.getElementById('log-count').textContent = '';