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:
2026-07-10 16:03:32 +02:00
parent 0cc70a7a5a
commit c084046af0
15 changed files with 1180 additions and 44 deletions
+33 -9
View File
@@ -65,10 +65,29 @@ func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName string) {
period := r.URL.Query().Get("period")
since := parsePeriod(period, 7*24*time.Hour)
// Part H: ?customer=<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)
history, _ := s.store.GetAppTelemetryHistory(appName, since)
issues, _ := s.store.GetAppIssues(appName, 20)
// Part F fix: the issues query gets the SAME period cutoff the chart uses (it used
// to ignore the selector — a 24h view showed 25-day-old rows).
issues, _ := s.store.GetAppIssues(appName, since, showDismissed, 20)
if customerFilter != "" {
filtered := issues[:0]
for _, is := range issues {
for _, c := range is.AffectedCustomers {
if c == customerFilter {
filtered = append(filtered, is)
break
}
}
}
issues = filtered
}
// Get fleet summary to find this app's summary
fleetAll, _ := s.store.GetFleetAppSummary(since)
@@ -98,6 +117,8 @@ func (s *Server) handleAppDetail(w http.ResponseWriter, r *http.Request, appName
"ChartData": chartData,
"SuggestedLimit": suggestedLimit,
"Period": period,
"CustomerFilter": customerFilter,
"ShowDismissed": showDismissed,
"CSRFToken": csrfToken,
"Flash": r.URL.Query().Get("flash"),
}
@@ -130,16 +151,19 @@ func (s *Server) handleResetAppTelemetry(w http.ResponseWriter, r *http.Request,
http.Redirect(w, r, target, http.StatusSeeOther)
}
// handleDeleteAppIssues handles POST requests to delete selected or all issues for an app.
func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, appName string) {
// handleDismissAppIssues handles POST requests to dismiss selected or all issues for an app.
// Dismissal, not deletion (Part G): the controller re-sends recurring issues every report,
// so a hard delete reappears minutes later. A dismissed row stays hidden until a genuinely
// NEW occurrence (last_seen > dismissed_at) resurfaces it in upsertAppIssue.
func (s *Server) handleDismissAppIssues(w http.ResponseWriter, r *http.Request, appName string) {
action := r.FormValue("action")
var deletedCount int64
var dismissedCount int64
var err error
switch action {
case "all":
deletedCount, err = s.store.DeleteAppIssues(appName)
dismissedCount, err = s.store.DismissAppIssues(appName)
case "selected":
r.ParseForm()
idStrs := r.Form["issue_ids"]
@@ -159,22 +183,22 @@ func (s *Server) handleDeleteAppIssues(w http.ResponseWriter, r *http.Request, a
ids = append(ids, id)
}
}
deletedCount, err = s.store.DeleteAppIssuesByIDs(ids)
dismissedCount, err = s.store.DismissAppIssuesByIDs(ids)
default:
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
if err != nil {
s.logger.Printf("[ERROR] Failed to delete issues for %s: %v", appName, err)
s.logger.Printf("[ERROR] Failed to dismiss issues for %s: %v", appName, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Deleted %d issues for %s (action=%s)", deletedCount, appName, action)
s.logger.Printf("[INFO] Dismissed %d issues for %s (action=%s)", dismissedCount, appName, action)
period := r.URL.Query().Get("period")
target := "/apps/" + appName + "?flash=issues_deleted"
target := "/apps/" + appName + "?flash=issues_dismissed"
if period != "" {
target += "&period=" + period
}