package web import ( "fmt" "net/http" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // Component log-bundle UI (v0.46.0) — the host-detail "Diagnostics" section. The // operator requests the CONTROLLER ring (rides the report ACK, ≤ ~15 min) or the // AGENT ring (rides the heartbeat envelope, ≈ the heartbeat cadence); the box // pushes on its own cycle (pull-only sovereignty posture; the box's own log // records the pull — customer-visible transparency). Bundles expire after 72 h. // logBundleRow is the template's per-bundle/pending row. type logBundleRow struct { ID int Component string State string // pending | available | blocked RequestedAt time.Time CollectedAt time.Time ReceivedAt time.Time SizeBytes int64 Blocked bool BlockedNote string } // logBundleScope resolves the store scope for a component on this host: the agent // channel is per-host; the controller channel is per-customer (the report ACK). func logBundleScope(host *store.Host, component string) string { if component == store.LogBundleComponentController { return host.CustomerID } return host.HostID } // handleRequestLogBundle — POST /hosts/{id}/request-logs (form: component). // Stores the pending pull the respective channel advertises on the box's next cycle. func (s *Server) handleRequestLogBundle(w http.ResponseWriter, r *http.Request, hostID string) { if !s.validateCSRF(r) { http.Error(w, "Invalid CSRF token", http.StatusForbidden) return } host, err := s.store.GetHost(hostID) if err != nil || host == nil { http.NotFound(w, r) return } component := strings.TrimSpace(r.FormValue("component")) if component != store.LogBundleComponentController && component != store.LogBundleComponentAgent { http.Error(w, "Invalid component", http.StatusBadRequest) return } if err := s.store.RequestLogBundle(logBundleScope(host, component), component); err != nil { s.logger.Printf("[ERROR] RequestLogBundle %s/%s: %v", hostID, component, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] %s log bundle requested for host %s — the box delivers on its next cycle", component, hostID) // Direction-2: only the CONTROLLER ring rides the report ACK (the wait channel wakes the // controller). The AGENT ring rides the heartbeat envelope — a separate plane this task does not // touch — so it is deliberately NOT bumped here. if component == store.LogBundleComponentController { s.bumpIntent(host.CustomerID) } http.Redirect(w, r, "/hosts/"+hostID, http.StatusSeeOther) } // handleLogBundleView — GET /hosts/{id}/log-bundles/{bundleID} renders the bundle; // ?download=1 serves it as a plain-text .log file. Scoped to the host's own scopes. func (s *Server) handleLogBundleView(w http.ResponseWriter, r *http.Request, hostID, bundleIDStr string) { host, err := s.store.GetHost(hostID) if err != nil || host == nil { http.NotFound(w, r) return } id, err := strconv.Atoi(bundleIDStr) if err != nil || id <= 0 { http.NotFound(w, r) return } // Try both scopes this host legitimately owns (agent = host_id, controller = customer_id). meta, lines, err := s.store.GetLogBundleContent(id, host.HostID) if err == nil && meta == nil { meta, lines, err = s.store.GetLogBundleContent(id, host.CustomerID) } if err != nil { s.logger.Printf("[ERROR] GetLogBundleContent %d/%s: %v", id, hostID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if meta == nil { http.NotFound(w, r) return } if meta.Blocked { w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "bundle %d (%s) is BLOCKED: %s\nNothing was stored — fix the offending log line box-side and re-request.\n", meta.ID, meta.Component, meta.BlockedReason) return } filename := fmt.Sprintf("%s-%s-%s.log", hostID, meta.Component, meta.CollectedAt.UTC().Format("20060102-150405")) w.Header().Set("Content-Type", "text/plain; charset=utf-8") if r.URL.Query().Get("download") == "1" { w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`) } for _, line := range lines { w.Write([]byte(line)) w.Write([]byte("\n")) } } // hostLogBundleRows builds the host-detail Diagnostics rows: pending requests first // (with the honest per-channel latency hint rendered template-side), then stored // bundles newest-first. Both of this host's scopes are merged. func (s *Server) hostLogBundleRows(host *store.Host) []logBundleRow { var rows []logBundleRow seenPending := map[string]bool{} for _, scope := range []string{host.HostID, host.CustomerID} { reqs, err := s.store.GetLogBundleRequests(scope) if err != nil { continue } for _, req := range reqs { // A host row only owns its own channel scopes. if logBundleScope(host, req.Component) != scope || seenPending[req.Component] { continue } seenPending[req.Component] = true rows = append(rows, logBundleRow{ Component: req.Component, State: "pending", RequestedAt: req.RequestedAt, }) } } for _, scope := range []string{host.HostID, host.CustomerID} { bundles, err := s.store.GetLogBundles(scope) if err != nil { continue } for _, b := range bundles { if logBundleScope(host, b.Component) != scope { continue } state := "available" if b.Blocked { state = "blocked" } rows = append(rows, logBundleRow{ ID: b.ID, Component: b.Component, State: state, CollectedAt: b.CollectedAt, ReceivedAt: b.ReceivedAt, SizeBytes: b.SizeBytes, Blocked: b.Blocked, BlockedNote: b.BlockedReason, }) } } return rows }