diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 9b2eb7d..3f8ba71 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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 diff --git a/hub/internal/api/logtail_ack_test.go b/hub/internal/api/logtail_ack_test.go new file mode 100644 index 0000000..e720a9b --- /dev/null +++ b/hub/internal/api/logtail_ack_test.go @@ -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) + } +} diff --git a/hub/internal/store/logtail.go b/hub/internal/store/logtail.go new file mode 100644 index 0000000..bd7249b --- /dev/null +++ b/hub/internal/store/logtail.go @@ -0,0 +1,139 @@ +package store + +import ( + "database/sql" + "encoding/json" + "time" +) + +// On-demand app log tails (Part D) — the pull-based counterpart of the controller's +// log_tails report section. The hub stores an operator intent in log_tail_requests, +// advertises it on the report ACK (log_tail_requests: [app…]), and when the tail lands +// on the NEXT report it is stored here and the request is cleared (consume-once). +// Tails are transient custody: the last 2 per (customer, app) are kept, older pruned. + +// AppLogTail is one received, ordered log tail. +type AppLogTail struct { + ID int + CustomerID string + AppName string + CollectedAt time.Time + ReceivedAt time.Time + Lines []string +} + +// RequestLogTail records (or refreshes) the operator's pending request for one app's +// log tail. One active request per (customer, app) — a re-click refreshes requested_at. +func (s *Store) RequestLogTail(customerID, appName string) error { + _, err := s.db.Exec(` + INSERT INTO log_tail_requests (customer_id, app_name, requested_at) + VALUES (?, ?, ?) + ON CONFLICT(customer_id, app_name) DO UPDATE SET requested_at = excluded.requested_at`, + customerID, appName, time.Now().UTC()) + return err +} + +// GetPendingLogTailRequests returns the app names with a pending tail request for the +// customer — the value advertised on the report ACK. nil/empty = nothing pending. +func (s *Store) GetPendingLogTailRequests(customerID string) ([]string, error) { + rows, err := s.db.Query(`SELECT app_name FROM log_tail_requests WHERE customer_id = ? ORDER BY app_name`, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + var apps []string + for rows.Next() { + var app string + if err := rows.Scan(&app); err == nil { + apps = append(apps, app) + } + } + return apps, rows.Err() +} + +// SaveAppLogTail stores a received tail, prunes to the newest 2 per (customer, app), +// and clears the pending request (consume-once — without the clear the ACK would keep +// advertising the request and the controller would ship the tail every cycle). +func (s *Store) SaveAppLogTail(customerID, appName string, collectedAt time.Time, lines []string) error { + linesJSON, err := json.Marshal(lines) + if err != nil { + return err + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(` + INSERT INTO app_log_tails (customer_id, app_name, collected_at, received_at, lines_json) + VALUES (?, ?, ?, ?, ?)`, + customerID, appName, collectedAt, time.Now().UTC(), string(linesJSON)); err != nil { + return err + } + // Keep the newest 2 per (customer, app). + if _, err := tx.Exec(` + DELETE FROM app_log_tails + WHERE customer_id = ? AND app_name = ? AND id NOT IN ( + SELECT id FROM app_log_tails WHERE customer_id = ? AND app_name = ? + ORDER BY id DESC LIMIT 2 + )`, customerID, appName, customerID, appName); err != nil { + return err + } + // Consume-once: the fulfilled request is cleared. + if _, err := tx.Exec(`DELETE FROM log_tail_requests WHERE customer_id = ? AND app_name = ?`, + customerID, appName); err != nil { + return err + } + return tx.Commit() +} + +// GetCustomerLogTails returns all stored tails for a customer, newest first. +func (s *Store) GetCustomerLogTails(customerID string) ([]AppLogTail, error) { + rows, err := s.db.Query(` + SELECT id, customer_id, app_name, collected_at, received_at, lines_json + FROM app_log_tails WHERE customer_id = ? + ORDER BY id DESC`, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + var tails []AppLogTail + for rows.Next() { + t, err := scanLogTail(rows) + if err != nil { + continue + } + tails = append(tails, t) + } + return tails, rows.Err() +} + +// GetLogTail returns one stored tail by id, scoped to the customer (no cross-customer reads). +func (s *Store) GetLogTail(id int, customerID string) (*AppLogTail, error) { + rows, err := s.db.Query(` + SELECT id, customer_id, app_name, collected_at, received_at, lines_json + FROM app_log_tails WHERE id = ? AND customer_id = ?`, id, customerID) + if err != nil { + return nil, err + } + defer rows.Close() + if !rows.Next() { + return nil, rows.Err() + } + t, err := scanLogTail(rows) + if err != nil { + return nil, err + } + return &t, nil +} + +func scanLogTail(rows *sql.Rows) (AppLogTail, error) { + var t AppLogTail + var linesJSON string + if err := rows.Scan(&t.ID, &t.CustomerID, &t.AppName, &t.CollectedAt, &t.ReceivedAt, &linesJSON); err != nil { + return t, err + } + json.Unmarshal([]byte(linesJSON), &t.Lines) + return t, nil +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 4c124ec..70a67ba 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -440,6 +440,42 @@ func (s *Store) migrate() error { return err } + // v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues: + // context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture + // wins — stable repro context, no churn); context_customer = whose box it came from + // (provenance); dismissed_at = dismissal instead of futile deletion (the controller + // re-sends recurring issues — a delete is undone minutes later; a dismissal stays until + // a genuinely NEW occurrence with last_seen > dismissed_at resurfaces the row). + s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context TEXT`) + s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN context_customer TEXT`) + s.db.Exec(`ALTER TABLE app_log_issues ADD COLUMN dismissed_at DATETIME`) + + // log_tail_requests: the operator's pending "send me this app's logs" intents — the + // pull-based ACK flag (the hub NEVER connects into a box; the controller sees the flag + // in its report ACK and ships the tail on its next cycle). One active request per + // (customer, app) — a re-click refreshes requested_at. Cleared when the tail arrives + // (consume-once). app_log_tails: the received tails, transient — keep the last 2 per + // (customer, app), older pruned at insert. + _, err = s.db.Exec(` + CREATE TABLE IF NOT EXISTS log_tail_requests ( + customer_id TEXT NOT NULL, + app_name TEXT NOT NULL, + requested_at DATETIME NOT NULL, + PRIMARY KEY (customer_id, app_name) + ); + CREATE TABLE IF NOT EXISTS app_log_tails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + app_name TEXT NOT NULL, + collected_at DATETIME NOT NULL, + received_at DATETIME NOT NULL, + lines_json TEXT NOT NULL + ); + `) + if err != nil { + return err + } + return nil } diff --git a/hub/internal/store/telemetry.go b/hub/internal/store/telemetry.go index 9138554..4ea892e 100644 --- a/hub/internal/store/telemetry.go +++ b/hub/internal/store/telemetry.go @@ -32,6 +32,9 @@ type AppTelemetryRecord struct { Message string `json:"message"` Count int `json:"count"` LastSeen time.Time `json:"last_seen"` + // Context (controller v0.111.0+): ±5 redacted lines around the first occurrence. + // Absent on older controllers — nil-safe everywhere. + Context []string `json:"context,omitempty"` } `json:"issues,omitempty"` } @@ -94,6 +97,13 @@ type AppIssue struct { LastSeen time.Time OccurrenceCount int AffectedCustomers []string + // Context: redacted lines around the FIRST captured occurrence (first capture wins); + // ContextCustomer records whose box it came from (provenance). Empty on pre-v0.111 data. + Context []string + ContextCustomer string + // DismissedAt: non-nil = operator-dismissed; hidden from the default view until a + // genuinely NEW occurrence (last_seen > dismissed_at) resurfaces it. + DismissedAt *time.Time } // SaveAppTelemetry inserts telemetry records for a customer report into the database. @@ -136,7 +146,7 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record // Upsert log issues for _, issue := range r.Issues { fp := fingerprintIssue(issue.Message) - if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen); err != nil { + if err := upsertAppIssue(tx, r.AppName, fp, issue.Severity, issue.Message, customerID, issue.LastSeen, issue.Context); err != nil { s.logger.Printf("[WARN] upsertAppIssue %s/%s: %v", r.AppName, fp[:min(len(fp), 20)], err) } } @@ -146,15 +156,32 @@ func (s *Store) SaveAppTelemetry(customerID string, reportedAt time.Time, record } // upsertAppIssue inserts or updates a known log issue record. -func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time) error { +// +// Context: stored on INSERT; on conflict only adopted when the stored context is still +// empty (first capture wins — stable repro context, no churn). context_customer moves +// with it (provenance). +// +// Dismissal: a dismissed row is resurrected ONLY by a genuinely NEW occurrence +// (excluded.last_seen > dismissed_at) — a re-sent old window stays hidden, but a +// recurrence is never silently swallowed. +func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, customerID string, lastSeen time.Time, context []string) error { + contextJSON := "" + if len(context) > 0 { + if b, err := json.Marshal(context); err == nil { + contextJSON = string(b) + } + } // Try insert first _, err := tx.Exec(` - INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers) - VALUES (?, ?, ?, ?, ?, ?, 1, ?) + INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers, context, context_customer) + VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT(app_name, fingerprint) DO UPDATE SET last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END, - occurrence_count = occurrence_count + 1`, - appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`) + occurrence_count = occurrence_count + 1, + dismissed_at = CASE WHEN dismissed_at IS NOT NULL AND excluded.last_seen > dismissed_at THEN NULL ELSE dismissed_at END, + context_customer = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context_customer ELSE context_customer END, + context = CASE WHEN (context IS NULL OR context = '') AND excluded.context != '' THEN excluded.context ELSE context END`, + appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`, contextJSON, customerID) if err != nil { return err } @@ -394,15 +421,23 @@ func (s *Store) GetCustomerAppSummary(customerID string, since time.Time) ([]Cus return result, nil } -// GetAppIssues returns recent known issues for a specific app. -func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) { - rows, err := s.db.Query(` +// GetAppIssues returns known issues for a specific app seen since the given time. +// The time filter is the SAME period the memory-trend chart uses (Part F fix: the +// 24h/7d/30d selector now applies to issues too). Dismissed rows are excluded unless +// includeDismissed is set. +func (s *Store) GetAppIssues(appName string, since time.Time, includeDismissed bool, limit int) ([]AppIssue, error) { + q := ` SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen, - occurrence_count, affected_customers + occurrence_count, affected_customers, context, context_customer, dismissed_at FROM app_log_issues - WHERE app_name = ? + WHERE app_name = ? AND last_seen >= ?` + if !includeDismissed { + q += ` AND dismissed_at IS NULL` + } + q += ` ORDER BY last_seen DESC - LIMIT ?`, appName, limit) + LIMIT ?` + rows, err := s.db.Query(q, appName, since, limit) if err != nil { return nil, err } @@ -411,12 +446,13 @@ func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) { return scanAppIssues(rows) } -// GetRecentIssuesAllApps returns the most recently seen issues across all apps. +// GetRecentIssuesAllApps returns the most recently seen non-dismissed issues across all apps. func (s *Store) GetRecentIssuesAllApps(limit int) ([]AppIssue, error) { rows, err := s.db.Query(` SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen, - occurrence_count, affected_customers + occurrence_count, affected_customers, context, context_customer, dismissed_at FROM app_log_issues + WHERE dismissed_at IS NULL ORDER BY last_seen DESC LIMIT ?`, limit) if err != nil { @@ -432,16 +468,61 @@ func scanAppIssues(rows *sql.Rows) ([]AppIssue, error) { for rows.Next() { var ai AppIssue var affectedJSON string + var contextJSON, contextCustomer sql.NullString + var dismissedAt sql.NullTime if err := rows.Scan(&ai.ID, &ai.AppName, &ai.Fingerprint, &ai.Severity, &ai.Message, - &ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON); err != nil { + &ai.FirstSeen, &ai.LastSeen, &ai.OccurrenceCount, &affectedJSON, + &contextJSON, &contextCustomer, &dismissedAt); err != nil { continue } json.Unmarshal([]byte(affectedJSON), &ai.AffectedCustomers) + if contextJSON.Valid && contextJSON.String != "" { + json.Unmarshal([]byte(contextJSON.String), &ai.Context) + } + if contextCustomer.Valid { + ai.ContextCustomer = contextCustomer.String + } + if dismissedAt.Valid { + t := dismissedAt.Time + ai.DismissedAt = &t + } issues = append(issues, ai) } return issues, rows.Err() } +// DismissAppIssues marks all non-dismissed issues of an app dismissed (Part G: dismissal +// over deletion — the controller re-sends recurring issues, so a delete is futile; a +// dismissed row stays hidden until a NEW occurrence resurfaces it via upsertAppIssue). +func (s *Store) DismissAppIssues(appName string) (int64, error) { + res, err := s.db.Exec(`UPDATE app_log_issues SET dismissed_at = ? WHERE app_name = ? AND dismissed_at IS NULL`, + time.Now().UTC(), appName) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +// DismissAppIssuesByIDs marks specific issues dismissed by their IDs. +func (s *Store) DismissAppIssuesByIDs(ids []int) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + placeholders := make([]string, len(ids)) + args := make([]interface{}, 0, len(ids)+1) + args = append(args, time.Now().UTC()) + for i, id := range ids { + placeholders[i] = "?" + args = append(args, id) + } + query := "UPDATE app_log_issues SET dismissed_at = ? WHERE id IN (" + strings.Join(placeholders, ",") + ")" + res, err := s.db.Exec(query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + // PruneAppTelemetry removes telemetry rows older than the given time. func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) { res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before) diff --git a/hub/internal/store/telemetry_test.go b/hub/internal/store/telemetry_test.go new file mode 100644 index 0000000..cbd563e --- /dev/null +++ b/hub/internal/store/telemetry_test.go @@ -0,0 +1,260 @@ +package store + +import ( + "encoding/json" + "io" + "log" + "path/filepath" + "testing" + "time" +) + +func newTelemetryTestStore(t *testing.T) *Store { + t.Helper() + s, err := New(filepath.Join(t.TempDir(), "telemetry.db"), log.New(io.Discard, "", 0)) + if err != nil { + t.Fatalf("store.New: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +// mkTelemetryRecord builds an AppTelemetryRecord through JSON — the same path a real +// controller report takes (also proves nil-safety when context is absent entirely). +func mkTelemetryRecord(t *testing.T, appName, severity, message string, lastSeen time.Time, context []string) AppTelemetryRecord { + t.Helper() + issue := map[string]interface{}{ + "severity": severity, + "message": message, + "count": 1, + "last_seen": lastSeen.UTC().Format(time.RFC3339), + } + if context != nil { + issue["context"] = context + } + b, _ := json.Marshal(map[string]interface{}{ + "app_name": appName, + "issues": []interface{}{issue}, + }) + var rec AppTelemetryRecord + if err := json.Unmarshal(b, &rec); err != nil { + t.Fatalf("unmarshal record: %v", err) + } + return rec +} + +func saveIssue(t *testing.T, s *Store, customer, app, severity, msg string, lastSeen time.Time, context []string) { + t.Helper() + rec := mkTelemetryRecord(t, app, severity, msg, lastSeen, context) + if err := s.SaveAppTelemetry(customer, time.Now(), []AppTelemetryRecord{rec}); err != nil { + t.Fatalf("SaveAppTelemetry: %v", err) + } +} + +func getOneIssue(t *testing.T, s *Store, app string, since time.Time, includeDismissed bool) *AppIssue { + t.Helper() + issues, err := s.GetAppIssues(app, since, includeDismissed, 20) + if err != nil { + t.Fatalf("GetAppIssues: %v", err) + } + if len(issues) == 0 { + return nil + } + return &issues[0] +} + +var longAgo = time.Now().Add(-365 * 24 * time.Hour) + +// Part C — context stored on INSERT with provenance; a later empty-context upsert does +// not clobber; a later DIFFERENT context does not churn it (first capture wins). +func TestUpsertIssue_ContextFirstCaptureWins(t *testing.T) { + s := newTelemetryTestStore(t) + msg := "ERROR: nfs mount /mnt/media lost" + now := time.Now() + + saveIssue(t, s, "cust-a", "cwa", "error", msg, now, []string{"before line", "ERROR: nfs mount /mnt/media lost", "after line"}) + is := getOneIssue(t, s, "cwa", longAgo, false) + if is == nil { + t.Fatalf("issue not stored") + } + if len(is.Context) != 3 || is.Context[0] != "before line" { + t.Fatalf("context not stored on insert: %#v", is.Context) + } + if is.ContextCustomer != "cust-a" { + t.Fatalf("provenance = %q, want cust-a", is.ContextCustomer) + } + + // A pre-v0.111 controller (no context field) re-reports the same issue: must not clobber. + saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(time.Minute), nil) + is = getOneIssue(t, s, "cwa", longAgo, false) + if len(is.Context) != 3 { + t.Fatalf("empty-context upsert clobbered the stored context: %#v", is.Context) + } + if is.ContextCustomer != "cust-a" { + t.Fatalf("provenance churned to %q", is.ContextCustomer) + } + // both customers recorded + if len(is.AffectedCustomers) != 2 { + t.Fatalf("affected customers = %v, want both", is.AffectedCustomers) + } + + // A different context from another box: first capture still wins (no churn). + saveIssue(t, s, "cust-b", "cwa", "error", msg, now.Add(2*time.Minute), []string{"other box context"}) + is = getOneIssue(t, s, "cwa", longAgo, false) + if is.Context[0] != "before line" || is.ContextCustomer != "cust-a" { + t.Fatalf("first-capture-wins violated: ctx=%#v from %q", is.Context, is.ContextCustomer) + } +} + +// Part C — an issue first seen WITHOUT context (old controller) adopts the first +// context that arrives later. +func TestUpsertIssue_LateContextAdopted(t *testing.T) { + s := newTelemetryTestStore(t) + msg := "ERROR: db locked" + saveIssue(t, s, "cust-a", "gokapi", "error", msg, time.Now(), nil) + is := getOneIssue(t, s, "gokapi", longAgo, false) + if len(is.Context) != 0 { + t.Fatalf("expected no context yet, got %#v", is.Context) + } + saveIssue(t, s, "cust-b", "gokapi", "error", msg, time.Now().Add(time.Minute), []string{"ctx line"}) + is = getOneIssue(t, s, "gokapi", longAgo, false) + if len(is.Context) != 1 || is.Context[0] != "ctx line" || is.ContextCustomer != "cust-b" { + t.Fatalf("late context not adopted: %#v from %q", is.Context, is.ContextCustomer) + } +} + +// Part F — the time-range filter: a 10-day-old issue is absent from the 24h view, +// present in the 30d view. Red-proof target: drop the last_seen >= since predicate → +// the 24h view shows the old row → this fails. +func TestGetAppIssues_RangeFilter(t *testing.T) { + s := newTelemetryTestStore(t) + tenDaysAgo := time.Now().Add(-10 * 24 * time.Hour) + saveIssue(t, s, "cust-a", "vault", "error", "ERROR: smtp down", tenDaysAgo, nil) + + if got := getOneIssue(t, s, "vault", time.Now().Add(-24*time.Hour), false); got != nil { + t.Fatalf("10d-old issue visible in the 24h view (last_seen=%s)", got.LastSeen) + } + if got := getOneIssue(t, s, "vault", time.Now().Add(-30*24*time.Hour), false); got == nil { + t.Fatalf("10d-old issue missing from the 30d view") + } +} + +// Part G — dismissal semantics: a re-sent OLD window (last_seen <= dismissed_at) stays +// hidden; a genuinely NEW occurrence (last_seen > dismissed_at) resurfaces the row. +// Red-proof: drop the `excluded.last_seen > dismissed_at` guard in upsertAppIssue (make +// the conflict always clear dismissed_at) → the old-window re-report resurrects → FAIL. +func TestDismiss_OldWindowHidden_NewOccurrenceResurfaces(t *testing.T) { + s := newTelemetryTestStore(t) + msg := "ERROR: cwa nfs stale handle" + oldSeen := time.Now().Add(-time.Hour) + + saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil) + if n, err := s.DismissAppIssues("cwa"); err != nil || n != 1 { + t.Fatalf("DismissAppIssues = %d/%v, want 1", n, err) + } + if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil { + t.Fatalf("dismissed issue still in the default view") + } + // visible with the toggle, flagged + if got := getOneIssue(t, s, "cwa", longAgo, true); got == nil || got.DismissedAt == nil { + t.Fatalf("show-dismissed view must expose the row with DismissedAt set") + } + + // Old-window re-report (controller re-sends its rolling window with the SAME last_seen). + saveIssue(t, s, "cust-a", "cwa", "error", msg, oldSeen, nil) + if got := getOneIssue(t, s, "cwa", longAgo, false); got != nil { + t.Fatalf("old-window re-report RESURRECTED the dismissed issue (last_seen=%s)", got.LastSeen) + } + + // A genuinely NEW occurrence — recurrence must never be silently swallowed. + newSeen := time.Now().Add(time.Minute) + saveIssue(t, s, "cust-a", "cwa", "error", msg, newSeen, nil) + got := getOneIssue(t, s, "cwa", longAgo, false) + if got == nil { + t.Fatalf("NEW occurrence did not resurface the dismissed issue") + } + if got.DismissedAt != nil { + t.Fatalf("resurfaced issue still flagged dismissed") + } +} + +// Part D (store half) — request → pending; tail arrival stores, prunes to 2, and clears +// the request (consume-once). Red-proof: remove the request-DELETE in SaveAppLogTail → +// the pending request survives fulfillment → this fails. +func TestLogTail_RequestFulfillConsumeOnce(t *testing.T) { + s := newTelemetryTestStore(t) + + if err := s.RequestLogTail("cust-a", "gokapi"); err != nil { + t.Fatalf("RequestLogTail: %v", err) + } + // re-click refreshes, still one request + if err := s.RequestLogTail("cust-a", "gokapi"); err != nil { + t.Fatalf("RequestLogTail re-click: %v", err) + } + apps, err := s.GetPendingLogTailRequests("cust-a") + if err != nil || len(apps) != 1 || apps[0] != "gokapi" { + t.Fatalf("pending = %v/%v, want [gokapi]", apps, err) + } + // another customer sees nothing + if other, _ := s.GetPendingLogTailRequests("cust-b"); len(other) != 0 { + t.Fatalf("cross-customer pending leak: %v", other) + } + + lines := []string{"first line", "second line", "third line"} + if err := s.SaveAppLogTail("cust-a", "gokapi", time.Now(), lines); err != nil { + t.Fatalf("SaveAppLogTail: %v", err) + } + // consume-once: fulfilled request cleared + if apps, _ := s.GetPendingLogTailRequests("cust-a"); len(apps) != 0 { + t.Fatalf("request survived fulfillment: %v — the ACK would re-request every cycle", apps) + } + tails, err := s.GetCustomerLogTails("cust-a") + if err != nil || len(tails) != 1 { + t.Fatalf("tails = %d/%v, want 1", len(tails), err) + } + if len(tails[0].Lines) != 3 || tails[0].Lines[0] != "first line" || tails[0].Lines[2] != "third line" { + t.Fatalf("tail lines lost order: %#v", tails[0].Lines) + } + + // keep-last-2 pruning + s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-2"}) + s.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"tail-3"}) + tails, _ = s.GetCustomerLogTails("cust-a") + if len(tails) != 2 { + t.Fatalf("keep-last-2 violated: %d tails stored", len(tails)) + } + if tails[0].Lines[0] != "tail-3" || tails[1].Lines[0] != "tail-2" { + t.Fatalf("pruning kept the wrong tails: %v / %v", tails[0].Lines, tails[1].Lines) + } + + // customer-scoped single read + if tail, _ := s.GetLogTail(tails[0].ID, "cust-b"); tail != nil { + t.Fatalf("cross-customer tail read must return nil") + } + if tail, _ := s.GetLogTail(tails[0].ID, "cust-a"); tail == nil || tail.AppName != "gokapi" { + t.Fatalf("scoped tail read failed: %+v", tail) + } +} + +// Warn issues ride with no context and stay message-only end-to-end (nil-safe). +func TestUpsertIssue_WarnNoContext(t *testing.T) { + s := newTelemetryTestStore(t) + saveIssue(t, s, "cust-a", "app", "warn", "WARN: slow disk", time.Now(), nil) + is := getOneIssue(t, s, "app", longAgo, false) + if is == nil || len(is.Context) != 0 { + t.Fatalf("warn issue context = %#v, want none", is) + } +} + +// Occurrence counting still works with the new upsert (regression guard). +func TestUpsertIssue_OccurrenceCount(t *testing.T) { + s := newTelemetryTestStore(t) + msg := "ERROR: boom" + for i := 0; i < 3; i++ { + saveIssue(t, s, "cust-a", "app", "error", msg, time.Now().Add(time.Duration(i)*time.Minute), nil) + } + is := getOneIssue(t, s, "app", longAgo, false) + if is.OccurrenceCount != 3 { + t.Fatalf("occurrence_count = %d, want 3", is.OccurrenceCount) + } +} diff --git a/hub/internal/web/apps.go b/hub/internal/web/apps.go index 966b85a..2270ef3 100644 --- a/hub/internal/web/apps.go +++ b/hub/internal/web/apps.go @@ -65,10 +65,29 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName string) { period := r.URL.Query().Get("period") since := parsePeriod(period, 7*24*time.Hour) + // Part H: ?customer= narrows Known Issues to rows affecting that customer + // (the customer page's per-app drill-down carries it). The unfiltered page stays + // the fleet view. + customerFilter := r.URL.Query().Get("customer") + showDismissed := r.URL.Query().Get("show_dismissed") == "1" customers, _ := s.store.GetAppCustomerBreakdown(appName, since) history, _ := s.store.GetAppTelemetryHistory(appName, since) - issues, _ := s.store.GetAppIssues(appName, 20) + // Part F fix: the issues query gets the SAME period cutoff the chart uses (it used + // to ignore the selector — a 24h view showed 25-day-old rows). + issues, _ := s.store.GetAppIssues(appName, since, showDismissed, 20) + if customerFilter != "" { + filtered := issues[:0] + for _, is := range issues { + for _, c := range is.AffectedCustomers { + if c == customerFilter { + filtered = append(filtered, is) + break + } + } + } + issues = filtered + } // Get fleet summary to find this app's summary fleetAll, _ := s.store.GetFleetAppSummary(since) @@ -98,6 +117,8 @@ func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName "ChartData": chartData, "SuggestedLimit": suggestedLimit, "Period": period, + "CustomerFilter": customerFilter, + "ShowDismissed": showDismissed, "CSRFToken": csrfToken, "Flash": r.URL.Query().Get("flash"), } @@ -130,16 +151,19 @@ func (s *Server) handleResetAppTelemetry(w http.ResponseWriter, r *http.Request, http.Redirect(w, r, target, http.StatusSeeOther) } -// handleDeleteAppIssues handles POST requests to delete selected or all issues for an app. -func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, appName string) { +// handleDismissAppIssues handles POST requests to dismiss selected or all issues for an app. +// Dismissal, not deletion (Part G): the controller re-sends recurring issues every report, +// so a hard delete reappears minutes later. A dismissed row stays hidden until a genuinely +// NEW occurrence (last_seen > dismissed_at) resurfaces it in upsertAppIssue. +func (s *Server) handleDismissAppIssues(w http.ResponseWriter, r *http.Request, appName string) { action := r.FormValue("action") - var deletedCount int64 + var dismissedCount int64 var err error switch action { case "all": - deletedCount, err = s.store.DeleteAppIssues(appName) + dismissedCount, err = s.store.DismissAppIssues(appName) case "selected": r.ParseForm() idStrs := r.Form["issue_ids"] @@ -159,22 +183,22 @@ func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, a ids = append(ids, id) } } - deletedCount, err = s.store.DeleteAppIssuesByIDs(ids) + dismissedCount, err = s.store.DismissAppIssuesByIDs(ids) default: http.Error(w, "Invalid action", http.StatusBadRequest) return } if err != nil { - s.logger.Printf("[ERROR] Failed to delete issues for %s: %v", appName, err) + s.logger.Printf("[ERROR] Failed to dismiss issues for %s: %v", appName, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } - s.logger.Printf("[INFO] Deleted %d issues for %s (action=%s)", deletedCount, appName, action) + s.logger.Printf("[INFO] Dismissed %d issues for %s (action=%s)", dismissedCount, appName, action) period := r.URL.Query().Get("period") - target := "/apps/" + appName + "?flash=issues_deleted" + target := "/apps/" + appName + "?flash=issues_dismissed" if period != "" { target += "&period=" + period } diff --git a/hub/internal/web/apps_render_test.go b/hub/internal/web/apps_render_test.go new file mode 100644 index 0000000..1a4b1ab --- /dev/null +++ b/hub/internal/web/apps_render_test.go @@ -0,0 +1,189 @@ +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
,
+// 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  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 
 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
+		`,  0,
 
+		LogTails:     logTails,
+		HasLogTails:  len(logTails) > 0,
+		PendingTails: pendingSet,
+
 		HasDRRecipe:       hasDR,
 		DRRecipeUpdatedAt: drUpdated,
 		DRRecipeHasHost:   drHost,
diff --git a/hub/internal/web/logtail.go b/hub/internal/web/logtail.go
new file mode 100644
index 0000000..44c8b5a
--- /dev/null
+++ b/hub/internal/web/logtail.go
@@ -0,0 +1,76 @@
+package web
+
+import (
+	"fmt"
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// validAppName bounds the operator-typed/POSTed app name (it flows into the ACK and
+// back into store keys — never into a shell, but keep it tight anyway).
+var validAppName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`)
+
+// handleRequestLogTail — POST /customers/{id}/request-log-tail (form: app).
+// Stores the pending pull-request the report ACK advertises; the controller ships the
+// tail on its next report cycle (the hub never connects into the box). Transparency by
+// default: a customer-visible event line records that the operator requested logs.
+func (s *Server) handleRequestLogTail(w http.ResponseWriter, r *http.Request, customerID string) {
+	app := strings.TrimSpace(r.FormValue("app"))
+	if !validAppName.MatchString(app) {
+		http.Error(w, "Invalid app name", http.StatusBadRequest)
+		return
+	}
+	if err := s.store.RequestLogTail(customerID, app); err != nil {
+		s.logger.Printf("[ERROR] RequestLogTail %s/%s: %v", customerID, app, err)
+		http.Error(w, "Internal error", http.StatusInternalServerError)
+		return
+	}
+	if _, err := s.store.SaveEvent(customerID, "log_tail_requested", "info",
+		"Az üzemeltető lekérte a(z) "+app+" alkalmazás naplórészletét (távoli diagnosztika).", "", "hub"); err != nil {
+		s.logger.Printf("[WARN] SaveEvent log_tail_requested %s/%s: %v", customerID, app, err)
+	}
+	s.logger.Printf("[INFO] Log tail requested for %s/%s — controller delivers on its next report cycle", customerID, app)
+	http.Redirect(w, r, "/customers/"+customerID+"?flash=log_tail_requested", http.StatusSeeOther)
+}
+
+// handleLogTailView — GET /customers/{id}/log-tail/{tailID} renders the ordered log
+// view (monospace, line numbers); ?download=1 serves it as a plain-text .log file.
+func (s *Server) handleLogTailView(w http.ResponseWriter, r *http.Request, customerID, tailIDStr string) {
+	id, err := strconv.Atoi(tailIDStr)
+	if err != nil || id <= 0 {
+		http.NotFound(w, r)
+		return
+	}
+	tail, err := s.store.GetLogTail(id, customerID)
+	if err != nil {
+		s.logger.Printf("[ERROR] GetLogTail %d/%s: %v", id, customerID, err)
+		http.Error(w, "Internal error", http.StatusInternalServerError)
+		return
+	}
+	if tail == nil {
+		http.NotFound(w, r)
+		return
+	}
+
+	if r.URL.Query().Get("download") == "1" {
+		filename := fmt.Sprintf("%s-%s-%s.log", customerID, tail.AppName, tail.CollectedAt.UTC().Format("20060102-150405"))
+		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+		w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
+		for _, line := range tail.Lines {
+			w.Write([]byte(line))
+			w.Write([]byte("\n"))
+		}
+		return
+	}
+
+	data := map[string]interface{}{
+		"CustomerID": customerID,
+		"Tail":       tail,
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	if err := s.templates.ExecuteTemplate(w, "log_tail.html", data); err != nil {
+		s.logger.Printf("[ERROR] log_tail.html template: %v", err)
+	}
+}
diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go
index 444e8d0..11d2f98 100644
--- a/hub/internal/web/server.go
+++ b/hub/internal/web/server.go
@@ -236,11 +236,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		} else {
 			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 		}
-	case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/delete-issues"):
+	case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/dismiss-issues"):
 		appName := strings.TrimPrefix(path, "/apps/")
-		appName = strings.TrimSuffix(appName, "/delete-issues")
+		appName = strings.TrimSuffix(appName, "/dismiss-issues")
 		if r.Method == http.MethodPost {
-			s.handleDeleteAppIssues(w, r, appName)
+			s.handleDismissAppIssues(w, r, appName)
 		} else {
 			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
 		}
@@ -302,6 +302,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		customerID := strings.TrimPrefix(path, "/customers/")
 		customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
 		s.handleDRRecipeDownload(w, r, customerID)
+	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/request-log-tail"):
+		customerID := strings.TrimPrefix(path, "/customers/")
+		customerID = strings.TrimSuffix(customerID, "/request-log-tail")
+		if r.Method == http.MethodPost {
+			s.handleRequestLogTail(w, r, customerID)
+		} else {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+		}
+	case strings.HasPrefix(path, "/customers/") && strings.Contains(path, "/log-tail/"):
+		rest := strings.TrimPrefix(path, "/customers/")
+		parts := strings.SplitN(rest, "/log-tail/", 2)
+		if len(parts) != 2 {
+			http.NotFound(w, r)
+			return
+		}
+		s.handleLogTailView(w, r, parts[0], parts[1])
 	case strings.HasPrefix(path, "/customers/"):
 		customerID := strings.TrimPrefix(path, "/customers/")
 		s.handleCustomerUnified(w, r, customerID)
diff --git a/hub/internal/web/templates/app_detail.html b/hub/internal/web/templates/app_detail.html
index 8c350d9..0fff544 100644
--- a/hub/internal/web/templates/app_detail.html
+++ b/hub/internal/web/templates/app_detail.html
@@ -24,21 +24,21 @@
 
         ← Apps
 
-        
+        
         
- 24h - 7d - 30d + 24h + 7d + 30d
{{if eq .Flash "telemetry_reset"}}
Telemetry data deleted successfully.
{{end}} - {{if eq .Flash "issues_deleted"}} -
Selected issues deleted successfully.
+ {{if eq .Flash "issues_dismissed"}} +
Issues dismissed — they stay hidden until a NEW occurrence resurfaces them.
{{end}} {{if eq .Flash "no_issues_selected"}} -
No issues were selected for deletion.
+
No issues were selected.
{{end}} @@ -189,10 +189,22 @@ {{end}} - {{if .Issues}}
-

Known Issues

-
+
+

Known Issues{{if .CustomerFilter}} — filtered: {{.CustomerFilter}}{{end}}

+
+ {{if .CustomerFilter}} + Clear filter (fleet view) + {{end}} + {{if .ShowDismissed}} + Hide dismissed + {{else}} + Show dismissed + {{end}} +
+
+ {{if .Issues}} + @@ -201,32 +213,64 @@ - + + {{range .Issues}} - + - + + + + + {{end}}
Severity MessageOccurrencesOccurrences (all customers) Affected Customers First Seen Last Seen
{{if eq .Severity "error"}}error {{else}}warn{{end}} + {{if .DismissedAt}}dismissed{{end}} {{.Message}}{{.Message}} {{.OccurrenceCount}} {{len .AffectedCustomers}} {{timeAgo .FirstSeen}} {{timeAgo .LastSeen}}
- - + +
+ {{else}} +

No issues in this period{{if .CustomerFilter}} for this customer{{end}}.

+ {{end}}
- {{end}}