hub v0.43.0: remote app-log diagnostics — copyable issues + context + on-demand log tails + range/dismissal fixes

- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app)
- api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow)
- web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event
- tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 16:03:32 +02:00
parent 0cc70a7a5a
commit c084046af0
15 changed files with 1180 additions and 44 deletions
+30
View File
@@ -304,6 +304,29 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
}
}
// On-demand log tails (v0.43.0) — the controller ships these on the cycle after the ACK
// requested them. Storing one clears its pending request (consume-once, in SaveAppLogTail)
// so the next ACK stops advertising it. Backward-compatible: old controllers never send this.
var tailPayload struct {
LogTails []struct {
App string `json:"app"`
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
} `json:"log_tails"`
}
if err := json.Unmarshal(body, &tailPayload); err == nil {
for _, lt := range tailPayload.LogTails {
if lt.App == "" {
continue
}
if err := h.store.SaveAppLogTail(payload.CustomerID, lt.App, lt.CollectedAt, lt.Lines); err != nil {
h.logger.Printf("[WARN] Failed to save log tail %s/%s: %v", payload.CustomerID, lt.App, err)
} else {
h.logger.Printf("[INFO] Log tail received for %s/%s (%d lines)", payload.CustomerID, lt.App, len(lt.Lines))
}
}
}
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
var drPayload struct {
@@ -342,6 +365,13 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
resp["escrow"] = es
}
// v0.43.0 — pending log-tail requests (same additive ACK-flag pattern as escrow): the
// controller collects the named apps' tails and ships them on its NEXT report; the field
// is omitted when nothing is pending. The hub never connects into the box.
if apps, err := h.store.GetPendingLogTailRequests(payload.CustomerID); err == nil && len(apps) > 0 {
resp["log_tail_requests"] = apps
}
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
// else global default) and the latest available version. The controller compares its current
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
+91
View File
@@ -0,0 +1,91 @@
package api
import (
"encoding/json"
"net/http"
"testing"
"time"
)
var longAgoAPI = time.Now().Add(-365 * 24 * time.Hour)
// Part D (API half) — the full pull-based round-trip on /report:
// 1. no pending request → the ACK omits log_tail_requests entirely
// 2. operator requests a tail → the ACK advertises it
// 3. the controller's next report carries log_tails → stored + request cleared
// 4. the FOLLOWING report's ACK omits the field again (consume-once)
func TestReportACK_LogTailRoundTrip(t *testing.T) {
h, st, _ := newTestHandler(t)
reportBody := `{"customer_id":"cust-a"}`
// 1. baseline ACK — no field
rr := do(h, http.MethodPost, "/report", globalKey, reportBody)
if rr.Code != http.StatusOK {
t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String())
}
var ack map[string]json.RawMessage
json.Unmarshal(rr.Body.Bytes(), &ack)
if _, present := ack["log_tail_requests"]; present {
t.Fatalf("baseline ACK must omit log_tail_requests: %s", rr.Body.String())
}
// 2. operator requests a tail → advertised
if err := st.RequestLogTail("cust-a", "gokapi"); err != nil {
t.Fatalf("RequestLogTail: %v", err)
}
rr = do(h, http.MethodPost, "/report", globalKey, reportBody)
ack = map[string]json.RawMessage{}
json.Unmarshal(rr.Body.Bytes(), &ack)
var apps []string
json.Unmarshal(ack["log_tail_requests"], &apps)
if len(apps) != 1 || apps[0] != "gokapi" {
t.Fatalf("ACK log_tail_requests = %v, want [gokapi] (%s)", apps, rr.Body.String())
}
// 3. the controller ships the tail on its next report → stored + consumed
tailReport := `{"customer_id":"cust-a","log_tails":[{"app":"gokapi","collected_at":"2026-07-10T10:00:00Z","lines":["l1","l2 password=[REDACTED]","l3"]}]}`
rr = do(h, http.MethodPost, "/report", globalKey, tailReport)
if rr.Code != http.StatusOK {
t.Fatalf("tail report POST = %d", rr.Code)
}
tails, err := st.GetCustomerLogTails("cust-a")
if err != nil || len(tails) != 1 {
t.Fatalf("stored tails = %d/%v, want 1", len(tails), err)
}
if len(tails[0].Lines) != 3 || tails[0].Lines[1] != "l2 password=[REDACTED]" {
t.Fatalf("tail lines not stored ordered/verbatim: %#v", tails[0].Lines)
}
// 4. consume-once: the FOLLOWING ACK omits the field
rr = do(h, http.MethodPost, "/report", globalKey, reportBody)
ack = map[string]json.RawMessage{}
json.Unmarshal(rr.Body.Bytes(), &ack)
if _, present := ack["log_tail_requests"]; present {
t.Fatalf("request survived fulfillment — the controller would ship tails every cycle: %s", rr.Body.String())
}
}
// A report whose telemetry issues carry context lands it in app_log_issues (the C wire leg).
func TestReport_IssueContextStored(t *testing.T) {
h, st, _ := newTestHandler(t)
body := `{"customer_id":"cust-a","app_telemetry":[{"app_name":"cwa","issues":[
{"severity":"error","message":"ERROR: nfs gone","count":2,"last_seen":"2026-07-10T09:00:00Z",
"context":["before","ERROR: nfs gone","after"]}]}]}`
rr := do(h, http.MethodPost, "/report", globalKey, body)
if rr.Code != http.StatusOK {
t.Fatalf("report POST = %d (%s)", rr.Code, rr.Body.String())
}
issues, err := st.GetAppIssues("cwa", longAgoAPI, false, 10)
if err != nil || len(issues) != 1 {
t.Fatalf("issues = %d/%v, want 1", len(issues), err)
}
is := issues[0]
if len(is.Context) != 3 || is.Context[1] != "ERROR: nfs gone" {
t.Fatalf("context not stored from the wire: %#v", is.Context)
}
if is.ContextCustomer != "cust-a" {
t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer)
}
}