Files
felhom.eu/hub/internal/web/apps.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

362 lines
10 KiB
Go

package web
import (
"net/http"
"sort"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// ChartData holds aggregated time-series data for Chart.js.
type ChartData struct {
Labels []string `json:"labels"`
AvgMemory []float64 `json:"avg_memory"`
PeakMemory []float64 `json:"peak_memory"`
CatalogLimit float64 `json:"catalog_limit"`
}
// handleApps renders the fleet-wide app list page.
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
period := r.URL.Query().Get("period")
since := parsePeriod(period, 7*24*time.Hour)
sortBy := r.URL.Query().Get("sort")
order := r.URL.Query().Get("order")
summary, err := s.store.GetFleetAppSummary(since)
if err != nil {
s.logger.Printf("[ERROR] GetFleetAppSummary: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
sortFleetSummary(summary, sortBy, order)
totalApps := len(summary)
totalDeployments := 0
appsWithErrors := 0
for _, app := range summary {
totalDeployments += app.DeploymentCount
if app.TotalErrors > 0 {
appsWithErrors++
}
}
csrfToken := s.getCSRFToken(r)
data := map[string]interface{}{
"Apps": summary,
"Period": period,
"TotalApps": totalApps,
"TotalDeployments": totalDeployments,
"AppsWithErrors": appsWithErrors,
"Sort": sortBy,
"Order": order,
"CSRFToken": csrfToken,
}
if err := s.templates.ExecuteTemplate(w, "apps.html", data); err != nil {
s.logger.Printf("[ERROR] apps.html template: %v", err)
}
}
// handleAppDetail renders the per-app detail page.
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)
// 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)
var appSummary *store.FleetAppSummary
for i := range fleetAll {
if fleetAll[i].AppName == appName {
appSummary = &fleetAll[i]
break
}
}
// Suggested mem_limit: ceil(P95 * 1.2), rounded up to nearest 32M
var suggestedLimit int
if appSummary != nil && appSummary.P95MemoryMB > 0 {
raw := appSummary.P95MemoryMB * 1.2
suggestedLimit = ((int(raw) + 31) / 32) * 32
}
chartData := aggregateHistoryForChart(history, appSummary)
csrfToken := s.getCSRFToken(r)
data := map[string]interface{}{
"AppName": appName,
"Summary": appSummary,
"Customers": customers,
"Issues": issues,
"ChartData": chartData,
"SuggestedLimit": suggestedLimit,
"Period": period,
"CustomerFilter": customerFilter,
"ShowDismissed": showDismissed,
"CSRFToken": csrfToken,
"Flash": r.URL.Query().Get("flash"),
}
if err := s.templates.ExecuteTemplate(w, "app_detail.html", data); err != nil {
s.logger.Printf("[ERROR] app_detail.html template: %v", err)
}
}
// handleResetAppTelemetry deletes all telemetry and issues for an app, then redirects back.
func (s *Server) handleResetAppTelemetry(w http.ResponseWriter, r *http.Request, appName string) {
telRows, err := s.store.DeleteAppTelemetry(appName)
if err != nil {
s.logger.Printf("[ERROR] Failed to reset telemetry for %s: %v", appName, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
issueRows, err := s.store.DeleteAppIssues(appName)
if err != nil {
s.logger.Printf("[ERROR] Failed to reset issues for %s: %v", appName, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Telemetry reset for %s: %d telemetry rows, %d issues deleted", appName, telRows, issueRows)
period := r.URL.Query().Get("period")
target := "/apps/" + appName + "?flash=telemetry_reset"
if period != "" {
target += "&period=" + period
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// 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 dismissedCount int64
var err error
switch action {
case "all":
dismissedCount, err = s.store.DismissAppIssues(appName)
case "selected":
r.ParseForm()
idStrs := r.Form["issue_ids"]
if len(idStrs) == 0 {
period := r.URL.Query().Get("period")
target := "/apps/" + appName + "?flash=no_issues_selected"
if period != "" {
target += "&period=" + period
}
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
ids := make([]int, 0, len(idStrs))
for _, s := range idStrs {
id, err := strconv.Atoi(s)
if err == nil && id > 0 {
ids = append(ids, id)
}
}
dismissedCount, err = s.store.DismissAppIssuesByIDs(ids)
default:
http.Error(w, "Invalid action", http.StatusBadRequest)
return
}
if err != nil {
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] Dismissed %d issues for %s (action=%s)", dismissedCount, appName, action)
period := r.URL.Query().Get("period")
target := "/apps/" + appName + "?flash=issues_dismissed"
if period != "" {
target += "&period=" + period
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
// parsePeriod converts a period string to a time.Time cutoff.
func parsePeriod(s string, defaultDur time.Duration) time.Time {
switch s {
case "24h":
return time.Now().Add(-24 * time.Hour)
case "7d":
return time.Now().Add(-7 * 24 * time.Hour)
case "30d":
return time.Now().Add(-30 * 24 * time.Hour)
default:
return time.Now().Add(-defaultDur)
}
}
// sortFleetSummary sorts the fleet summary slice in place.
func sortFleetSummary(summary []store.FleetAppSummary, sortBy, order string) {
desc := order != "asc"
sort.Slice(summary, func(i, j int) bool {
var less bool
switch sortBy {
case "memory":
less = summary[i].AvgMemoryMB < summary[j].AvgMemoryMB
case "errors":
less = summary[i].TotalErrors < summary[j].TotalErrors
default: // deployments
less = summary[i].DeploymentCount < summary[j].DeploymentCount
}
if desc {
return !less
}
return less
})
}
// aggregateHistoryForChart groups history points into hourly buckets for Chart.js.
func aggregateHistoryForChart(history []store.AppTelemetryPoint, summary *store.FleetAppSummary) ChartData {
type bucket struct {
avgMemSum float64
peakMemMax float64
count int
}
buckets := make(map[string]*bucket)
var bucketOrder []string
for _, p := range history {
key := p.ReportedAt.UTC().Format("2006-01-02 15:00")
if _, ok := buckets[key]; !ok {
buckets[key] = &bucket{}
bucketOrder = append(bucketOrder, key)
}
b := buckets[key]
b.avgMemSum += p.MemoryAvgMB
if p.MemoryPeakMB > b.peakMemMax {
b.peakMemMax = p.MemoryPeakMB
}
b.count++
}
cd := ChartData{
Labels: make([]string, 0, len(bucketOrder)),
AvgMemory: make([]float64, 0, len(bucketOrder)),
PeakMemory: make([]float64, 0, len(bucketOrder)),
}
for _, key := range bucketOrder {
b := buckets[key]
cd.Labels = append(cd.Labels, key)
avgMem := 0.0
if b.count > 0 {
avgMem = b.avgMemSum / float64(b.count)
}
cd.AvgMemory = append(cd.AvgMemory, round2(avgMem))
cd.PeakMemory = append(cd.PeakMemory, round2(b.peakMemMax))
}
if summary != nil {
cd.CatalogLimit = parseLimitMB(summary.CatalogLimit)
}
return cd
}
// parseLimitMB parses a memory limit string like "512M" or "2G" to MB float64.
func parseLimitMB(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
s = strings.ToUpper(s)
if strings.HasSuffix(s, "G") {
v, _ := strconv.ParseFloat(s[:len(s)-1], 64)
return v * 1024
}
if strings.HasSuffix(s, "M") {
v, _ := strconv.ParseFloat(s[:len(s)-1], 64)
return v
}
v, _ := strconv.ParseFloat(s, 64)
return v
}
// round2 rounds a float64 to 2 decimal places.
func round2(v float64) float64 {
return float64(int(v*100+0.5)) / 100
}
// memoryColor returns a CSS class based on current memory vs catalog limit.
func memoryColor(currentMB float64, limitStr string) string {
limit := parseLimitMB(limitStr)
if limit <= 0 {
return ""
}
ratio := currentMB / limit
if ratio >= 1.0 {
return "mem-danger"
}
if ratio >= 0.5 {
return "mem-warn"
}
return "mem-ok"
}
// accuracyClass returns accuracy indicator CSS class for P95 vs catalog limit.
func accuracyClass(p95MB float64, limitStr string) string {
if p95MB <= 0 || limitStr == "" {
return ""
}
limit := parseLimitMB(limitStr)
if limit <= 0 {
return ""
}
if p95MB > limit {
return "danger"
}
if p95MB*2 > limit {
return "warn"
}
return "ok"
}
// getCSRFToken retrieves the CSRF token from the session cookie.
func (s *Server) getCSRFToken(r *http.Request) string {
cookie, err := r.Cookie("hub_session")
if err != nil {
return ""
}
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
if sess, ok := s.sessions[cookie.Value]; ok {
return sess.csrfToken
}
return ""
}