Files
felhom.eu/hub/internal/store/logtail.go
T
admin c084046af0 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
2026-07-10 16:03:32 +02:00

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
}