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:
@@ -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).
|
// 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.
|
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
|
||||||
var drPayload struct {
|
var drPayload struct {
|
||||||
@@ -342,6 +365,13 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
resp["escrow"] = es
|
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
|
// 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
|
// 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
|
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -440,6 +440,42 @@ func (s *Store) migrate() error {
|
|||||||
return err
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ type AppTelemetryRecord struct {
|
|||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
LastSeen time.Time `json:"last_seen"`
|
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"`
|
} `json:"issues,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +97,13 @@ type AppIssue struct {
|
|||||||
LastSeen time.Time
|
LastSeen time.Time
|
||||||
OccurrenceCount int
|
OccurrenceCount int
|
||||||
AffectedCustomers []string
|
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.
|
// 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
|
// Upsert log issues
|
||||||
for _, issue := range r.Issues {
|
for _, issue := range r.Issues {
|
||||||
fp := fingerprintIssue(issue.Message)
|
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)
|
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.
|
// 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
|
// Try insert first
|
||||||
_, err := tx.Exec(`
|
_, err := tx.Exec(`
|
||||||
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers)
|
INSERT INTO app_log_issues (app_name, fingerprint, severity, message, first_seen, last_seen, occurrence_count, affected_customers, context, context_customer)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||||
ON CONFLICT(app_name, fingerprint) DO UPDATE SET
|
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,
|
last_seen = CASE WHEN excluded.last_seen > last_seen THEN excluded.last_seen ELSE last_seen END,
|
||||||
occurrence_count = occurrence_count + 1`,
|
occurrence_count = occurrence_count + 1,
|
||||||
appName, fingerprint, severity, message, lastSeen, lastSeen, `[]`)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -394,15 +421,23 @@ func (s *Store) GetCustomerAppSummary(customerID string, since time.Time) ([]Cus
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAppIssues returns recent known issues for a specific app.
|
// GetAppIssues returns known issues for a specific app seen since the given time.
|
||||||
func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
|
// The time filter is the SAME period the memory-trend chart uses (Part F fix: the
|
||||||
rows, err := s.db.Query(`
|
// 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,
|
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
|
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
|
ORDER BY last_seen DESC
|
||||||
LIMIT ?`, appName, limit)
|
LIMIT ?`
|
||||||
|
rows, err := s.db.Query(q, appName, since, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -411,12 +446,13 @@ func (s *Store) GetAppIssues(appName string, limit int) ([]AppIssue, error) {
|
|||||||
return scanAppIssues(rows)
|
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) {
|
func (s *Store) GetRecentIssuesAllApps(limit int) ([]AppIssue, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT id, app_name, fingerprint, severity, message, first_seen, last_seen,
|
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
|
FROM app_log_issues
|
||||||
|
WHERE dismissed_at IS NULL
|
||||||
ORDER BY last_seen DESC
|
ORDER BY last_seen DESC
|
||||||
LIMIT ?`, limit)
|
LIMIT ?`, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -432,16 +468,61 @@ func scanAppIssues(rows *sql.Rows) ([]AppIssue, error) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ai AppIssue
|
var ai AppIssue
|
||||||
var affectedJSON string
|
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,
|
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
|
continue
|
||||||
}
|
}
|
||||||
json.Unmarshal([]byte(affectedJSON), &ai.AffectedCustomers)
|
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)
|
issues = append(issues, ai)
|
||||||
}
|
}
|
||||||
return issues, rows.Err()
|
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.
|
// PruneAppTelemetry removes telemetry rows older than the given time.
|
||||||
func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) {
|
func (s *Store) PruneAppTelemetry(before time.Time) (int64, error) {
|
||||||
res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before)
|
res, err := s.db.Exec("DELETE FROM app_telemetry WHERE reported_at < ?", before)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName string) {
|
||||||
period := r.URL.Query().Get("period")
|
period := r.URL.Query().Get("period")
|
||||||
since := parsePeriod(period, 7*24*time.Hour)
|
since := parsePeriod(period, 7*24*time.Hour)
|
||||||
|
// Part H: ?customer=<id> 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)
|
customers, _ := s.store.GetAppCustomerBreakdown(appName, since)
|
||||||
history, _ := s.store.GetAppTelemetryHistory(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
|
// Get fleet summary to find this app's summary
|
||||||
fleetAll, _ := s.store.GetFleetAppSummary(since)
|
fleetAll, _ := s.store.GetFleetAppSummary(since)
|
||||||
@@ -98,6 +117,8 @@ func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName
|
|||||||
"ChartData": chartData,
|
"ChartData": chartData,
|
||||||
"SuggestedLimit": suggestedLimit,
|
"SuggestedLimit": suggestedLimit,
|
||||||
"Period": period,
|
"Period": period,
|
||||||
|
"CustomerFilter": customerFilter,
|
||||||
|
"ShowDismissed": showDismissed,
|
||||||
"CSRFToken": csrfToken,
|
"CSRFToken": csrfToken,
|
||||||
"Flash": r.URL.Query().Get("flash"),
|
"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)
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleDeleteAppIssues handles POST requests to delete selected or all issues for an app.
|
// handleDismissAppIssues handles POST requests to dismiss selected or all issues for an app.
|
||||||
func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, appName string) {
|
// 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")
|
action := r.FormValue("action")
|
||||||
|
|
||||||
var deletedCount int64
|
var dismissedCount int64
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch action {
|
switch action {
|
||||||
case "all":
|
case "all":
|
||||||
deletedCount, err = s.store.DeleteAppIssues(appName)
|
dismissedCount, err = s.store.DismissAppIssues(appName)
|
||||||
case "selected":
|
case "selected":
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
idStrs := r.Form["issue_ids"]
|
idStrs := r.Form["issue_ids"]
|
||||||
@@ -159,22 +183,22 @@ func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, a
|
|||||||
ids = append(ids, id)
|
ids = append(ids, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deletedCount, err = s.store.DeleteAppIssuesByIDs(ids)
|
dismissedCount, err = s.store.DismissAppIssuesByIDs(ids)
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Invalid action", http.StatusBadRequest)
|
http.Error(w, "Invalid action", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||||
return
|
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")
|
period := r.URL.Query().Get("period")
|
||||||
target := "/apps/" + appName + "?flash=issues_deleted"
|
target := "/apps/" + appName + "?flash=issues_dismissed"
|
||||||
if period != "" {
|
if period != "" {
|
||||||
target += "&period=" + period
|
target += "&period=" + period
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -260,6 +260,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
var eventCounts map[string]int
|
var eventCounts map[string]int
|
||||||
|
|
||||||
var appTelemetry []store.CustomerAppSummary
|
var appTelemetry []store.CustomerAppSummary
|
||||||
|
var logTails []store.AppLogTail
|
||||||
|
var pendingTails []string
|
||||||
|
|
||||||
if customer != nil {
|
if customer != nil {
|
||||||
history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
|
history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
|
||||||
@@ -268,6 +270,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
events, _ = s.store.GetRecentEvents(customerID, 50)
|
events, _ = s.store.GetRecentEvents(customerID, 50)
|
||||||
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
|
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
|
||||||
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
|
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
|
||||||
|
logTails, _ = s.store.GetCustomerLogTails(customerID)
|
||||||
|
pendingTails, _ = s.store.GetPendingLogTailRequests(customerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
type pageData struct {
|
type pageData struct {
|
||||||
@@ -306,6 +310,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
AppTelemetry []store.CustomerAppSummary
|
AppTelemetry []store.CustomerAppSummary
|
||||||
HasAppTelemetry bool
|
HasAppTelemetry bool
|
||||||
|
|
||||||
|
// On-demand log tails (v0.43.0): received tails (last 2 per app) + apps with a
|
||||||
|
// still-pending request (badge on the telemetry row).
|
||||||
|
LogTails []store.AppLogTail
|
||||||
|
HasLogTails bool
|
||||||
|
PendingTails map[string]bool
|
||||||
|
|
||||||
HasDRRecipe bool
|
HasDRRecipe bool
|
||||||
DRRecipeUpdatedAt string
|
DRRecipeUpdatedAt string
|
||||||
DRRecipeHasHost bool
|
DRRecipeHasHost bool
|
||||||
@@ -320,6 +330,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
ScriptVersion string
|
ScriptVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingSet := make(map[string]bool, len(pendingTails))
|
||||||
|
for _, app := range pendingTails {
|
||||||
|
pendingSet[app] = true
|
||||||
|
}
|
||||||
|
|
||||||
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
|
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
|
||||||
// either half has landed (host-report and/or controller report).
|
// either half has landed (host-report and/or controller report).
|
||||||
var hasDR, drHost, drApps bool
|
var hasDR, drHost, drApps bool
|
||||||
@@ -366,6 +381,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
|
|||||||
AppTelemetry: appTelemetry,
|
AppTelemetry: appTelemetry,
|
||||||
HasAppTelemetry: len(appTelemetry) > 0,
|
HasAppTelemetry: len(appTelemetry) > 0,
|
||||||
|
|
||||||
|
LogTails: logTails,
|
||||||
|
HasLogTails: len(logTails) > 0,
|
||||||
|
PendingTails: pendingSet,
|
||||||
|
|
||||||
HasDRRecipe: hasDR,
|
HasDRRecipe: hasDR,
|
||||||
DRRecipeUpdatedAt: drUpdated,
|
DRRecipeUpdatedAt: drUpdated,
|
||||||
DRRecipeHasHost: drHost,
|
DRRecipeHasHost: drHost,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -236,11 +236,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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.TrimPrefix(path, "/apps/")
|
||||||
appName = strings.TrimSuffix(appName, "/delete-issues")
|
appName = strings.TrimSuffix(appName, "/dismiss-issues")
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
s.handleDeleteAppIssues(w, r, appName)
|
s.handleDismissAppIssues(w, r, appName)
|
||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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.TrimPrefix(path, "/customers/")
|
||||||
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
|
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
|
||||||
s.handleDRRecipeDownload(w, r, customerID)
|
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/"):
|
case strings.HasPrefix(path, "/customers/"):
|
||||||
customerID := strings.TrimPrefix(path, "/customers/")
|
customerID := strings.TrimPrefix(path, "/customers/")
|
||||||
s.handleCustomerUnified(w, r, customerID)
|
s.handleCustomerUnified(w, r, customerID)
|
||||||
|
|||||||
@@ -24,21 +24,21 @@
|
|||||||
|
|
||||||
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">← Apps</a>
|
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">← Apps</a>
|
||||||
|
|
||||||
<!-- Period selector -->
|
<!-- Period selector (carries the customer filter + show-dismissed state) -->
|
||||||
<div class="period-selector" style="margin-top: 1rem;">
|
<div class="period-selector" style="margin-top: 1rem;">
|
||||||
<a href="?period=24h" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
|
<a href="?period=24h{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
|
||||||
<a href="?period=7d" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
|
<a href="?period=7d{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
|
||||||
<a href="?period=30d" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
|
<a href="?period=30d{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if eq .Flash "telemetry_reset"}}
|
{{if eq .Flash "telemetry_reset"}}
|
||||||
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
|
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Flash "issues_deleted"}}
|
{{if eq .Flash "issues_dismissed"}}
|
||||||
<div class="flash flash-success" style="margin-top: 1rem;">Selected issues deleted successfully.</div>
|
<div class="flash flash-success" style="margin-top: 1rem;">Issues dismissed — they stay hidden until a NEW occurrence resurfaces them.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if eq .Flash "no_issues_selected"}}
|
{{if eq .Flash "no_issues_selected"}}
|
||||||
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected for deletion.</div>
|
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Overview card -->
|
<!-- Overview card -->
|
||||||
@@ -189,10 +189,22 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<!-- Known issues -->
|
<!-- Known issues -->
|
||||||
{{if .Issues}}
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Known Issues</h2>
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||||
<form method="POST" action="/apps/{{.AppName}}/delete-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
|
<h2 style="margin: 0;">Known Issues{{if .CustomerFilter}} <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">— filtered: {{.CustomerFilter}}</span>{{end}}</h2>
|
||||||
|
<div style="display: flex; gap: 0.75rem; align-items: center;">
|
||||||
|
{{if .CustomerFilter}}
|
||||||
|
<a href="?period={{.Period}}{{if .ShowDismissed}}&show_dismissed=1{{end}}" style="font-size: 0.85rem;">Clear filter (fleet view)</a>
|
||||||
|
{{end}}
|
||||||
|
{{if .ShowDismissed}}
|
||||||
|
<a href="?period={{.Period}}{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}" style="font-size: 0.85rem;">Hide dismissed</a>
|
||||||
|
{{else}}
|
||||||
|
<a href="?period={{.Period}}{{if .CustomerFilter}}&customer={{.CustomerFilter}}{{end}}&show_dismissed=1" style="font-size: 0.85rem;">Show dismissed</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if .Issues}}
|
||||||
|
<form method="POST" action="/apps/{{.AppName}}/dismiss-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
|
||||||
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
|
||||||
<input type="hidden" name="action" value="selected" id="issueAction">
|
<input type="hidden" name="action" value="selected" id="issueAction">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
@@ -201,32 +213,64 @@
|
|||||||
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
|
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
<th>Occurrences</th>
|
<th>Occurrences (all customers)</th>
|
||||||
<th>Affected Customers</th>
|
<th>Affected Customers</th>
|
||||||
<th>First Seen</th>
|
<th>First Seen</th>
|
||||||
<th>Last Seen</th>
|
<th>Last Seen</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Issues}}
|
{{range .Issues}}
|
||||||
<tr>
|
<tr{{if .DismissedAt}} style="opacity: 0.55;"{{end}}>
|
||||||
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
|
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
|
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
|
||||||
{{else}}<span class="badge badge-warn">warn</span>{{end}}
|
{{else}}<span class="badge badge-warn">warn</span>{{end}}
|
||||||
|
{{if .DismissedAt}}<span class="badge badge-neutral">dismissed</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Message}}">{{.Message}}</td>
|
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;" onclick="toggleIssue({{.ID}})">{{.Message}}</td>
|
||||||
<td>{{.OccurrenceCount}}</td>
|
<td>{{.OccurrenceCount}}</td>
|
||||||
<td>{{len .AffectedCustomers}}</td>
|
<td>{{len .AffectedCustomers}}</td>
|
||||||
<td>{{timeAgo .FirstSeen}}</td>
|
<td>{{timeAgo .FirstSeen}}</td>
|
||||||
<td>{{timeAgo .LastSeen}}</td>
|
<td>{{timeAgo .LastSeen}}</td>
|
||||||
|
<td><button type="button" class="btn btn-sm" onclick="toggleIssue({{.ID}})">Details</button></td>
|
||||||
|
</tr>
|
||||||
|
<tr id="issue-detail-{{.ID}}" style="display: none;">
|
||||||
|
<td colspan="8" style="background: var(--bg-1); border: 1px solid var(--line-soft); padding: 0.75rem;">
|
||||||
|
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
|
||||||
|
fingerprint: <span style="font-family: var(--font-mono);">{{.Fingerprint}}</span>
|
||||||
|
· severity: {{.Severity}}
|
||||||
|
· first seen: {{.FirstSeen.Format "2006-01-02 15:04:05"}}
|
||||||
|
· last seen: {{.LastSeen.Format "2006-01-02 15:04:05"}}
|
||||||
|
{{if .DismissedAt}}· dismissed: {{.DismissedAt.Format "2006-01-02 15:04:05"}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
|
||||||
|
affected customers:
|
||||||
|
{{range $i, $c := .AffectedCustomers}}{{if $i}}, {{end}}<a href="/customers/{{$c}}">{{$c}}</a>{{else}}—{{end}}
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem;">Full message:</span>
|
||||||
|
<button type="button" class="btn btn-sm" onclick="copyText('issue-msg-{{.ID}}', this)">Copy</button>
|
||||||
|
</div>
|
||||||
|
<pre id="issue-msg-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0.75rem;">{{.Message}}</pre>
|
||||||
|
{{if .Context}}
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem;">Context around first occurrence{{if .ContextCustomer}} — from <a href="/customers/{{.ContextCustomer}}">{{.ContextCustomer}}</a>{{end}}:</span>
|
||||||
|
<button type="button" class="btn btn-sm" onclick="copyText('issue-ctx-{{.ID}}', this)">Copy</button>
|
||||||
|
</div>
|
||||||
|
<pre id="issue-ctx-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0;">{{joinStrings .Context "\n"}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<span class="text-muted" style="font-size: 0.8rem;">No context captured (pre-v0.111 report or warn-severity issue).</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Delete Selected</button>
|
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Dismiss Selected</button>
|
||||||
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Delete ALL issues for {{.AppName}}? This cannot be undone.')) return false; document.getElementById('issueAction').value='all';">Delete All Issues</button>
|
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Dismiss ALL issues for {{.AppName}}? They resurface on a new occurrence.')) return false; document.getElementById('issueAction').value='all';">Dismiss All Issues</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<script>
|
<script>
|
||||||
@@ -234,9 +278,40 @@
|
|||||||
var cbs = document.querySelectorAll('.issue-cb');
|
var cbs = document.querySelectorAll('.issue-cb');
|
||||||
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
|
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
|
||||||
});
|
});
|
||||||
|
function toggleIssue(id) {
|
||||||
|
var el = document.getElementById('issue-detail-' + id);
|
||||||
|
if (el) el.style.display = (el.style.display === 'none') ? '' : 'none';
|
||||||
|
}
|
||||||
|
function copyText(id, btn) {
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
var text = el.textContent;
|
||||||
|
var done = function() {
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.textContent = 'Copied';
|
||||||
|
setTimeout(function() { btn.textContent = old; }, 1200);
|
||||||
|
};
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(done, function() { fallbackCopy(text, done); });
|
||||||
|
} else {
|
||||||
|
fallbackCopy(text, done);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fallbackCopy(text, done) {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
try { document.execCommand('copy'); done(); } catch (e) {}
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted">No issues in this period{{if .CustomerFilter}} for this customer{{end}}.</p>
|
||||||
|
{{end}}
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||||
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
|
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
|
||||||
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
||||||
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
||||||
|
{{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded.
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -616,18 +617,62 @@
|
|||||||
<th>Catalog Limit</th>
|
<th>Catalog Limit</th>
|
||||||
<th>Errors</th>
|
<th>Errors</th>
|
||||||
<th>Warnings</th>
|
<th>Warnings</th>
|
||||||
|
<th>Logs</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .AppTelemetry}}
|
{{range .AppTelemetry}}
|
||||||
<tr>
|
<tr>
|
||||||
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
|
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
|
||||||
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
|
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
|
||||||
<td>{{formatFloat .MemoryAvgMB}} MB</td>
|
<td>{{formatFloat .MemoryAvgMB}} MB</td>
|
||||||
<td>{{formatFloat .MemoryPeakMB}} MB</td>
|
<td>{{formatFloat .MemoryPeakMB}} MB</td>
|
||||||
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
|
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
|
||||||
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
|
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
|
||||||
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
|
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if index $.PendingTails .AppName}}
|
||||||
|
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
|
||||||
|
{{else}}
|
||||||
|
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
|
||||||
|
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
|
||||||
|
<input type="hidden" name="app" value="{{.AppName}}">
|
||||||
|
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Received log tails (on-demand, transient — last 2 per app) -->
|
||||||
|
{{if .HasLogTails}}
|
||||||
|
<section class="card">
|
||||||
|
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>App</th>
|
||||||
|
<th>Collected</th>
|
||||||
|
<th>Received</th>
|
||||||
|
<th>Lines</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .LogTails}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
|
||||||
|
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
|
||||||
|
<td>{{timeAgo .ReceivedAt}}</td>
|
||||||
|
<td>{{len .Lines}}</td>
|
||||||
|
<td style="white-space: nowrap;">
|
||||||
|
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
|
||||||
|
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.Tail.AppName}} log tail — Felhom Hub</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "icon_sprite"}}
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Felhom <span>Hub</span></h1>
|
||||||
|
<nav class="nav-links">
|
||||||
|
<a href="/" class="nav-link">Dashboard</a>
|
||||||
|
<a href="/configs" class="nav-link active">Customers</a>
|
||||||
|
<a href="/apps" class="nav-link">Apps</a>
|
||||||
|
<a href="/hosts" class="nav-link">Hosts</a>
|
||||||
|
<a href="/offsite" class="nav-link">Offsite</a>
|
||||||
|
<a href="/configuration" class="nav-link">Configuration</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<a href="/customers/{{.CustomerID}}" class="back-link">← {{.CustomerID}}</a>
|
||||||
|
|
||||||
|
<section class="card" style="margin-top: 1rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
||||||
|
<h2 style="margin: 0;">Log tail: <span style="font-family: var(--font-mono);">{{.Tail.AppName}}</span></h2>
|
||||||
|
<a href="/customers/{{.CustomerID}}/log-tail/{{.Tail.ID}}?download=1" class="btn btn-sm">Download .log</a>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted" style="margin-top: 0.25rem; font-size: 0.85rem;">
|
||||||
|
Collected on the box at {{.Tail.CollectedAt.Format "2006-01-02 15:04:05"}} UTC ({{timeAgo .Tail.CollectedAt}})
|
||||||
|
· {{len .Tail.Lines}} lines, ordered as emitted · redacted controller-side before shipping.
|
||||||
|
</p>
|
||||||
|
{{if .Tail.Lines}}
|
||||||
|
<ol class="log-lines" style="font-family: var(--font-mono); font-size: 0.8rem; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem 0.5rem 0.5rem 4rem; margin: 0.5rem 0 0; overflow-x: auto;">
|
||||||
|
{{range .Tail.Lines}}
|
||||||
|
<li style="white-space: pre-wrap; word-break: break-word; color: var(--text-1);">{{.}}</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted">The tail arrived empty (the container produced no log lines).</p>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
|
||||||
|
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -759,6 +759,10 @@ code {
|
|||||||
background: rgba(250, 204, 21, 0.2);
|
background: rgba(250, 204, 21, 0.2);
|
||||||
color: var(--warn);
|
color: var(--warn);
|
||||||
}
|
}
|
||||||
|
.badge-neutral {
|
||||||
|
background: var(--bg-2);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Summary cards row */
|
/* Summary cards row */
|
||||||
.summary-cards {
|
.summary-cards {
|
||||||
|
|||||||
Reference in New Issue
Block a user