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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user