c084046af0
- store: app_log_issues gains context/context_customer (first capture wins) + dismissed_at (resurface only on last_seen > dismissed_at); log_tail_requests (pending operator intents, consume-once) + app_log_tails (transient, keep last 2 per app) - api: /report ingests log_tails (stores + clears the request); ACK advertises log_tail_requests (same additive omit-when-empty pattern as escrow) - web: Known Issues rows click-to-expand (full copyable message + context with provenance + explicit affected-customers list); Dismiss replaces Delete; period selector now filters issues (F); ?customer= filtered view + customer-page drill-down links (H); per-app Request-log-tail button + pending badge + App Log Tails section + ordered tail view with line numbers + .log download; customer-visible log_tail_requested event - tests: store (context first-capture/late-adopt, range filter, dismissal old-window vs new-occurrence, tail request/fulfill/prune/scoping), api ACK round-trip, web render (expanded row, customer page sections, tail view + download + cross-customer 404) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
140 lines
4.4 KiB
Go
140 lines
4.4 KiB
Go
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
|
|
}
|