c084046af0
- 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
190 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|