Files
felhom.eu/hub/internal/web/apps_render_test.go
T
admin c084046af0 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
2026-07-10 16:03:32 +02:00

190 lines
7.3 KiB
Go

package web
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func newRenderServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "render.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
return New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)), st
}
// Part A + H UI — the expanded issue row renders the FULL message in a copyable <pre>,
// the context block with provenance, the explicit affected-customers list, the dismissal
// controls, and the customer-filter header.
func TestAppDetail_ExpandableIssueRender(t *testing.T) {
s, _ := newRenderServer(t)
fullMsg := "ERROR: nfs mount /mnt/media lost — stale file handle on /mnt/media/books (retry 5/5, giving up)"
dismissed := time.Now()
data := map[string]interface{}{
"AppName": "cwa",
"Issues": []store.AppIssue{
{
ID: 7, AppName: "cwa", Fingerprint: "error: nfs mount <hex> lost", Severity: "error",
Message: fullMsg, FirstSeen: time.Now().Add(-48 * time.Hour), LastSeen: time.Now(),
OccurrenceCount: 42,
AffectedCustomers: []string{"cust-a", "cust-b"},
Context: []string{"line before", fullMsg, "line after"},
ContextCustomer: "cust-a",
},
{
ID: 8, AppName: "cwa", Fingerprint: "warn: slow", Severity: "warn",
Message: "WARN: slow disk", FirstSeen: time.Now(), LastSeen: time.Now(),
OccurrenceCount: 1, AffectedCustomers: []string{"cust-a"},
DismissedAt: &dismissed,
},
},
"ChartData": ChartData{},
"Period": "24h",
"CustomerFilter": "cust-a",
"ShowDismissed": true,
"CSRFToken": "tok",
"Flash": "",
}
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, "app_detail.html", data); err != nil {
t.Fatalf("render app_detail.html: %v", err)
}
body := buf.String()
for _, want := range []string{
fullMsg, // full message, not tooltip-only
`id="issue-msg-7"`, // copyable <pre> target
`id="issue-ctx-7"`, // context block target
"Copy", // copy buttons
"Context around first occurrence", // context label
`/customers/cust-a`, // provenance link
"line before", // context content
"filtered: cust-a", // Part H header
"Occurrences (all customers)", // fleet-total label
"Dismiss Selected", // Part G buttons
"Dismiss All Issues", //
"/apps/cwa/dismiss-issues", // dismissal route
">dismissed<", // dismissed badge on row 8
`</a>, <a href="/customers/cust-b"`, // explicit affected-customers list (linked, comma-separated)
} {
if !strings.Contains(body, want) {
t.Errorf("app_detail.html missing %q", want)
}
}
// the old hard-delete wording must be gone
if strings.Contains(body, "Delete All Issues") || strings.Contains(body, "delete-issues") {
t.Errorf("app_detail.html still renders hard-delete issue controls")
}
}
// Part D UI — the customer page renders the per-app Request button (or the pending
// badge), the ?customer= drill-down link, and the received-tails section. Driven
// through the REAL handler + store (the sections live inside {{if .HasReports}}).
func TestCustomerUnified_LogTailSectionsRender(t *testing.T) {
s, st := newRenderServer(t)
if err := st.SaveReport("cust-a", []byte(`{"customer_id":"cust-a","customer_name":"Cust A","controller_version":"0.111.0"}`)); err != nil {
t.Fatalf("SaveReport: %v", err)
}
// two telemetry apps: one gets a pending tail request, one has a received tail
recA := mkWebTelemetryRecord(t, "gokapi", "Gokapi")
recB := mkWebTelemetryRecord(t, "cwa", "Calibre-Web")
if err := st.SaveAppTelemetry("cust-a", time.Now(), []store.AppTelemetryRecord{recA, recB}); err != nil {
t.Fatalf("SaveAppTelemetry: %v", err)
}
if err := st.RequestLogTail("cust-a", "cwa"); err != nil {
t.Fatalf("RequestLogTail: %v", err)
}
if err := st.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"a", "b"}); err != nil {
t.Fatalf("SaveAppLogTail: %v", err)
}
tails, _ := st.GetCustomerLogTails("cust-a")
if len(tails) != 1 {
t.Fatalf("expected 1 stored tail")
}
tailID := strconv.Itoa(tails[0].ID)
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/cust-a", nil), "cust-a")
body := rr.Body.String()
for _, want := range []string{
"/apps/gokapi?customer=cust-a", // Part H drill-down carries the filter
"/customers/cust-a/request-log-tail", // request form
"Request log tail", // the button (gokapi row)
"tail pending", // the badge (cwa row, pending request)
"App Log Tails", // received-tails section
"/customers/cust-a/log-tail/" + tailID,
"/customers/cust-a/log-tail/" + tailID + "?download=1",
} {
if !strings.Contains(body, want) {
t.Errorf("customer page missing %q", want)
}
}
}
// mkWebTelemetryRecord builds a minimal AppTelemetryRecord via JSON (the wire path).
func mkWebTelemetryRecord(t *testing.T, app, display string) store.AppTelemetryRecord {
t.Helper()
var rec store.AppTelemetryRecord
if err := json.Unmarshal([]byte(`{"app_name":"`+app+`","display_name":"`+display+`"}`), &rec); err != nil {
t.Fatalf("record: %v", err)
}
return rec
}
// The tail view renders ordered lines; ?download=1 serves a plain-text .log attachment.
func TestLogTailView_RenderAndDownload(t *testing.T) {
s, st := newRenderServer(t)
if err := st.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"first line", "second line", "third line"}); err != nil {
t.Fatalf("SaveAppLogTail: %v", err)
}
tails, _ := st.GetCustomerLogTails("cust-a")
if len(tails) != 1 {
t.Fatalf("expected 1 stored tail")
}
id := strconv.Itoa(tails[0].ID)
// HTML view — ordered lines present
rr := httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-a/log-tail/"+id, nil), "cust-a", id)
body := rr.Body.String()
iFirst := strings.Index(body, "first line")
iSecond := strings.Index(body, "second line")
iThird := strings.Index(body, "third line")
if iFirst < 0 || iSecond < iFirst || iThird < iSecond {
t.Fatalf("tail view lines missing or out of order: %d/%d/%d", iFirst, iSecond, iThird)
}
// download — text/plain attachment with the raw lines
rr = httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-a/log-tail/"+id+"?download=1", nil), "cust-a", id)
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Fatalf("download content-type = %q", ct)
}
if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, "attachment") || !strings.Contains(cd, ".log") {
t.Fatalf("download disposition = %q", cd)
}
if got := rr.Body.String(); got != "first line\nsecond line\nthird line\n" {
t.Fatalf("download body = %q", got)
}
// cross-customer read → 404
rr = httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-b/log-tail/"+id, nil), "cust-b", id)
if rr.Code != 404 {
t.Fatalf("cross-customer tail view = %d, want 404", rr.Code)
}
}