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
}
+189
View File
@@ -0,0 +1,189 @@
package web
import (
"bytes"
"encoding/json"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func newRenderServer(t *testing.T) (*Server, *store.Store) {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "render.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
return New(st, "", "", "test", time.Hour, log.New(io.Discard, "", 0)), st
}
// Part A + H UI — the expanded issue row renders the FULL message in a copyable <pre>,
// the context block with provenance, the explicit affected-customers list, the dismissal
// controls, and the customer-filter header.
func TestAppDetail_ExpandableIssueRender(t *testing.T) {
s, _ := newRenderServer(t)
fullMsg := "ERROR: nfs mount /mnt/media lost — stale file handle on /mnt/media/books (retry 5/5, giving up)"
dismissed := time.Now()
data := map[string]interface{}{
"AppName": "cwa",
"Issues": []store.AppIssue{
{
ID: 7, AppName: "cwa", Fingerprint: "error: nfs mount <hex> lost", Severity: "error",
Message: fullMsg, FirstSeen: time.Now().Add(-48 * time.Hour), LastSeen: time.Now(),
OccurrenceCount: 42,
AffectedCustomers: []string{"cust-a", "cust-b"},
Context: []string{"line before", fullMsg, "line after"},
ContextCustomer: "cust-a",
},
{
ID: 8, AppName: "cwa", Fingerprint: "warn: slow", Severity: "warn",
Message: "WARN: slow disk", FirstSeen: time.Now(), LastSeen: time.Now(),
OccurrenceCount: 1, AffectedCustomers: []string{"cust-a"},
DismissedAt: &dismissed,
},
},
"ChartData": ChartData{},
"Period": "24h",
"CustomerFilter": "cust-a",
"ShowDismissed": true,
"CSRFToken": "tok",
"Flash": "",
}
var buf bytes.Buffer
if err := s.templates.ExecuteTemplate(&buf, "app_detail.html", data); err != nil {
t.Fatalf("render app_detail.html: %v", err)
}
body := buf.String()
for _, want := range []string{
fullMsg, // full message, not tooltip-only
`id="issue-msg-7"`, // copyable <pre> target
`id="issue-ctx-7"`, // context block target
"Copy", // copy buttons
"Context around first occurrence", // context label
`/customers/cust-a`, // provenance link
"line before", // context content
"filtered: cust-a", // Part H header
"Occurrences (all customers)", // fleet-total label
"Dismiss Selected", // Part G buttons
"Dismiss All Issues", //
"/apps/cwa/dismiss-issues", // dismissal route
">dismissed<", // dismissed badge on row 8
`</a>, <a href="/customers/cust-b"`, // explicit affected-customers list (linked, comma-separated)
} {
if !strings.Contains(body, want) {
t.Errorf("app_detail.html missing %q", want)
}
}
// the old hard-delete wording must be gone
if strings.Contains(body, "Delete All Issues") || strings.Contains(body, "delete-issues") {
t.Errorf("app_detail.html still renders hard-delete issue controls")
}
}
// Part D UI — the customer page renders the per-app Request button (or the pending
// badge), the ?customer= drill-down link, and the received-tails section. Driven
// through the REAL handler + store (the sections live inside {{if .HasReports}}).
func TestCustomerUnified_LogTailSectionsRender(t *testing.T) {
s, st := newRenderServer(t)
if err := st.SaveReport("cust-a", []byte(`{"customer_id":"cust-a","customer_name":"Cust A","controller_version":"0.111.0"}`)); err != nil {
t.Fatalf("SaveReport: %v", err)
}
// two telemetry apps: one gets a pending tail request, one has a received tail
recA := mkWebTelemetryRecord(t, "gokapi", "Gokapi")
recB := mkWebTelemetryRecord(t, "cwa", "Calibre-Web")
if err := st.SaveAppTelemetry("cust-a", time.Now(), []store.AppTelemetryRecord{recA, recB}); err != nil {
t.Fatalf("SaveAppTelemetry: %v", err)
}
if err := st.RequestLogTail("cust-a", "cwa"); err != nil {
t.Fatalf("RequestLogTail: %v", err)
}
if err := st.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"a", "b"}); err != nil {
t.Fatalf("SaveAppLogTail: %v", err)
}
tails, _ := st.GetCustomerLogTails("cust-a")
if len(tails) != 1 {
t.Fatalf("expected 1 stored tail")
}
tailID := strconv.Itoa(tails[0].ID)
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/cust-a", nil), "cust-a")
body := rr.Body.String()
for _, want := range []string{
"/apps/gokapi?customer=cust-a", // Part H drill-down carries the filter
"/customers/cust-a/request-log-tail", // request form
"Request log tail", // the button (gokapi row)
"tail pending", // the badge (cwa row, pending request)
"App Log Tails", // received-tails section
"/customers/cust-a/log-tail/" + tailID,
"/customers/cust-a/log-tail/" + tailID + "?download=1",
} {
if !strings.Contains(body, want) {
t.Errorf("customer page missing %q", want)
}
}
}
// mkWebTelemetryRecord builds a minimal AppTelemetryRecord via JSON (the wire path).
func mkWebTelemetryRecord(t *testing.T, app, display string) store.AppTelemetryRecord {
t.Helper()
var rec store.AppTelemetryRecord
if err := json.Unmarshal([]byte(`{"app_name":"`+app+`","display_name":"`+display+`"}`), &rec); err != nil {
t.Fatalf("record: %v", err)
}
return rec
}
// The tail view renders ordered lines; ?download=1 serves a plain-text .log attachment.
func TestLogTailView_RenderAndDownload(t *testing.T) {
s, st := newRenderServer(t)
if err := st.SaveAppLogTail("cust-a", "gokapi", time.Now(), []string{"first line", "second line", "third line"}); err != nil {
t.Fatalf("SaveAppLogTail: %v", err)
}
tails, _ := st.GetCustomerLogTails("cust-a")
if len(tails) != 1 {
t.Fatalf("expected 1 stored tail")
}
id := strconv.Itoa(tails[0].ID)
// HTML view — ordered lines present
rr := httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-a/log-tail/"+id, nil), "cust-a", id)
body := rr.Body.String()
iFirst := strings.Index(body, "first line")
iSecond := strings.Index(body, "second line")
iThird := strings.Index(body, "third line")
if iFirst < 0 || iSecond < iFirst || iThird < iSecond {
t.Fatalf("tail view lines missing or out of order: %d/%d/%d", iFirst, iSecond, iThird)
}
// download — text/plain attachment with the raw lines
rr = httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-a/log-tail/"+id+"?download=1", nil), "cust-a", id)
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
t.Fatalf("download content-type = %q", ct)
}
if cd := rr.Header().Get("Content-Disposition"); !strings.Contains(cd, "attachment") || !strings.Contains(cd, ".log") {
t.Fatalf("download disposition = %q", cd)
}
if got := rr.Body.String(); got != "first line\nsecond line\nthird line\n" {
t.Fatalf("download body = %q", got)
}
// cross-customer read → 404
rr = httptest.NewRecorder()
s.handleLogTailView(rr, httptest.NewRequest("GET", "/customers/cust-b/log-tail/"+id, nil), "cust-b", id)
if rr.Code != 404 {
t.Fatalf("cross-customer tail view = %d, want 404", rr.Code)
}
}
+19
View File
@@ -260,6 +260,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
var eventCounts map[string]int
var appTelemetry []store.CustomerAppSummary
var logTails []store.AppLogTail
var pendingTails []string
if customer != nil {
history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
@@ -268,6 +270,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
events, _ = s.store.GetRecentEvents(customerID, 50)
eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
logTails, _ = s.store.GetCustomerLogTails(customerID)
pendingTails, _ = s.store.GetPendingLogTailRequests(customerID)
}
type pageData struct {
@@ -306,6 +310,12 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
AppTelemetry []store.CustomerAppSummary
HasAppTelemetry bool
// On-demand log tails (v0.43.0): received tails (last 2 per app) + apps with a
// still-pending request (badge on the telemetry row).
LogTails []store.AppLogTail
HasLogTails bool
PendingTails map[string]bool
HasDRRecipe bool
DRRecipeUpdatedAt string
DRRecipeHasHost bool
@@ -320,6 +330,11 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
ScriptVersion string
}
pendingSet := make(map[string]bool, len(pendingTails))
for _, app := range pendingTails {
pendingSet[app] = true
}
// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
// either half has landed (host-report and/or controller report).
var hasDR, drHost, drApps bool
@@ -366,6 +381,10 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c
AppTelemetry: appTelemetry,
HasAppTelemetry: len(appTelemetry) > 0,
LogTails: logTails,
HasLogTails: len(logTails) > 0,
PendingTails: pendingSet,
HasDRRecipe: hasDR,
DRRecipeUpdatedAt: drUpdated,
DRRecipeHasHost: drHost,
+76
View File
@@ -0,0 +1,76 @@
package web
import (
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
)
// validAppName bounds the operator-typed/POSTed app name (it flows into the ACK and
// back into store keys — never into a shell, but keep it tight anyway).
var validAppName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$`)
// handleRequestLogTail — POST /customers/{id}/request-log-tail (form: app).
// Stores the pending pull-request the report ACK advertises; the controller ships the
// tail on its next report cycle (the hub never connects into the box). Transparency by
// default: a customer-visible event line records that the operator requested logs.
func (s *Server) handleRequestLogTail(w http.ResponseWriter, r *http.Request, customerID string) {
app := strings.TrimSpace(r.FormValue("app"))
if !validAppName.MatchString(app) {
http.Error(w, "Invalid app name", http.StatusBadRequest)
return
}
if err := s.store.RequestLogTail(customerID, app); err != nil {
s.logger.Printf("[ERROR] RequestLogTail %s/%s: %v", customerID, app, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if _, err := s.store.SaveEvent(customerID, "log_tail_requested", "info",
"Az üzemeltető lekérte a(z) "+app+" alkalmazás naplórészletét (távoli diagnosztika).", "", "hub"); err != nil {
s.logger.Printf("[WARN] SaveEvent log_tail_requested %s/%s: %v", customerID, app, err)
}
s.logger.Printf("[INFO] Log tail requested for %s/%s — controller delivers on its next report cycle", customerID, app)
http.Redirect(w, r, "/customers/"+customerID+"?flash=log_tail_requested", http.StatusSeeOther)
}
// handleLogTailView — GET /customers/{id}/log-tail/{tailID} renders the ordered log
// view (monospace, line numbers); ?download=1 serves it as a plain-text .log file.
func (s *Server) handleLogTailView(w http.ResponseWriter, r *http.Request, customerID, tailIDStr string) {
id, err := strconv.Atoi(tailIDStr)
if err != nil || id <= 0 {
http.NotFound(w, r)
return
}
tail, err := s.store.GetLogTail(id, customerID)
if err != nil {
s.logger.Printf("[ERROR] GetLogTail %d/%s: %v", id, customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if tail == nil {
http.NotFound(w, r)
return
}
if r.URL.Query().Get("download") == "1" {
filename := fmt.Sprintf("%s-%s-%s.log", customerID, tail.AppName, tail.CollectedAt.UTC().Format("20060102-150405"))
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
for _, line := range tail.Lines {
w.Write([]byte(line))
w.Write([]byte("\n"))
}
return
}
data := map[string]interface{}{
"CustomerID": customerID,
"Tail": tail,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.templates.ExecuteTemplate(w, "log_tail.html", data); err != nil {
s.logger.Printf("[ERROR] log_tail.html template: %v", err)
}
}
+19 -3
View File
@@ -236,11 +236,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/delete-issues"):
case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/dismiss-issues"):
appName := strings.TrimPrefix(path, "/apps/")
appName = strings.TrimSuffix(appName, "/delete-issues")
appName = strings.TrimSuffix(appName, "/dismiss-issues")
if r.Method == http.MethodPost {
s.handleDeleteAppIssues(w, r, appName)
s.handleDismissAppIssues(w, r, appName)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
@@ -302,6 +302,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
s.handleDRRecipeDownload(w, r, customerID)
case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/request-log-tail"):
customerID := strings.TrimPrefix(path, "/customers/")
customerID = strings.TrimSuffix(customerID, "/request-log-tail")
if r.Method == http.MethodPost {
s.handleRequestLogTail(w, r, customerID)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/customers/") && strings.Contains(path, "/log-tail/"):
rest := strings.TrimPrefix(path, "/customers/")
parts := strings.SplitN(rest, "/log-tail/", 2)
if len(parts) != 2 {
http.NotFound(w, r)
return
}
s.handleLogTailView(w, r, parts[0], parts[1])
case strings.HasPrefix(path, "/customers/"):
customerID := strings.TrimPrefix(path, "/customers/")
s.handleCustomerUnified(w, r, customerID)
+91 -16
View File
@@ -24,21 +24,21 @@
<a href="/apps{{if .Period}}?period={{.Period}}{{end}}" class="back-link">&larr; Apps</a>
<!-- Period selector -->
<!-- Period selector (carries the customer filter + show-dismissed state) -->
<div class="period-selector" style="margin-top: 1rem;">
<a href="?period=24h" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
<a href="?period=7d" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
<a href="?period=30d" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
<a href="?period=24h{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if eq .Period "24h"}} active{{end}}">24h</a>
<a href="?period=7d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if or (eq .Period "7d") (eq .Period "")}} active{{end}}">7d</a>
<a href="?period=30d{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" class="period-btn{{if eq .Period "30d"}} active{{end}}">30d</a>
</div>
{{if eq .Flash "telemetry_reset"}}
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
{{end}}
{{if eq .Flash "issues_deleted"}}
<div class="flash flash-success" style="margin-top: 1rem;">Selected issues deleted successfully.</div>
{{if eq .Flash "issues_dismissed"}}
<div class="flash flash-success" style="margin-top: 1rem;">Issues dismissed — they stay hidden until a NEW occurrence resurfaces them.</div>
{{end}}
{{if eq .Flash "no_issues_selected"}}
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected for deletion.</div>
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected.</div>
{{end}}
<!-- Overview card -->
@@ -189,10 +189,22 @@
{{end}}
<!-- Known issues -->
{{if .Issues}}
<section class="card">
<h2>Known Issues</h2>
<form method="POST" action="/apps/{{.AppName}}/delete-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Known Issues{{if .CustomerFilter}} <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">— filtered: {{.CustomerFilter}}</span>{{end}}</h2>
<div style="display: flex; gap: 0.75rem; align-items: center;">
{{if .CustomerFilter}}
<a href="?period={{.Period}}{{if .ShowDismissed}}&amp;show_dismissed=1{{end}}" style="font-size: 0.85rem;">Clear filter (fleet view)</a>
{{end}}
{{if .ShowDismissed}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}" style="font-size: 0.85rem;">Hide dismissed</a>
{{else}}
<a href="?period={{.Period}}{{if .CustomerFilter}}&amp;customer={{.CustomerFilter}}{{end}}&amp;show_dismissed=1" style="font-size: 0.85rem;">Show dismissed</a>
{{end}}
</div>
</div>
{{if .Issues}}
<form method="POST" action="/apps/{{.AppName}}/dismiss-issues{{if .Period}}?period={{.Period}}{{end}}" id="issueForm">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="action" value="selected" id="issueAction">
<table class="data-table">
@@ -201,32 +213,64 @@
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
<th>Severity</th>
<th>Message</th>
<th>Occurrences</th>
<th>Occurrences (all customers)</th>
<th>Affected Customers</th>
<th>First Seen</th>
<th>Last Seen</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr>
<tr{{if .DismissedAt}} style="opacity: 0.55;"{{end}}>
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
<td>
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
{{else}}<span class="badge badge-warn">warn</span>{{end}}
{{if .DismissedAt}}<span class="badge badge-neutral">dismissed</span>{{end}}
</td>
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="{{.Message}}">{{.Message}}</td>
<td style="font-family: var(--font-mono); font-size: 0.8rem; max-width: 40ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer;" onclick="toggleIssue({{.ID}})">{{.Message}}</td>
<td>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</td>
<td><button type="button" class="btn btn-sm" onclick="toggleIssue({{.ID}})">Details</button></td>
</tr>
<tr id="issue-detail-{{.ID}}" style="display: none;">
<td colspan="8" style="background: var(--bg-1); border: 1px solid var(--line-soft); padding: 0.75rem;">
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
fingerprint: <span style="font-family: var(--font-mono);">{{.Fingerprint}}</span>
&middot; severity: {{.Severity}}
&middot; first seen: {{.FirstSeen.Format "2006-01-02 15:04:05"}}
&middot; last seen: {{.LastSeen.Format "2006-01-02 15:04:05"}}
{{if .DismissedAt}}&middot; dismissed: {{.DismissedAt.Format "2006-01-02 15:04:05"}}{{end}}
</div>
<div class="text-muted" style="font-size: 0.8rem; margin-bottom: 0.5rem;">
affected customers:
{{range $i, $c := .AffectedCustomers}}{{if $i}}, {{end}}<a href="/customers/{{$c}}">{{$c}}</a>{{else}}—{{end}}
</div>
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<span class="text-muted" style="font-size: 0.8rem;">Full message:</span>
<button type="button" class="btn btn-sm" onclick="copyText('issue-msg-{{.ID}}', this)">Copy</button>
</div>
<pre id="issue-msg-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0.75rem;">{{.Message}}</pre>
{{if .Context}}
<div style="display: flex; justify-content: space-between; align-items: center; gap: 0.5rem;">
<span class="text-muted" style="font-size: 0.8rem;">Context around first occurrence{{if .ContextCustomer}} — from <a href="/customers/{{.ContextCustomer}}">{{.ContextCustomer}}</a>{{end}}:</span>
<button type="button" class="btn btn-sm" onclick="copyText('issue-ctx-{{.ID}}', this)">Copy</button>
</div>
<pre id="issue-ctx-{{.ID}}" style="font-family: var(--font-mono); font-size: 0.8rem; white-space: pre-wrap; word-break: break-word; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem; margin: 0.25rem 0 0;">{{joinStrings .Context "\n"}}</pre>
{{else}}
<span class="text-muted" style="font-size: 0.8rem;">No context captured (pre-v0.111 report or warn-severity issue).</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
<div style="display: flex; gap: 0.5rem; margin-top: 0.75rem;">
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Delete Selected</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Delete ALL issues for {{.AppName}}? This cannot be undone.')) return false; document.getElementById('issueAction').value='all';">Delete All Issues</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="document.getElementById('issueAction').value='selected';">Dismiss Selected</button>
<button type="submit" class="btn btn-sm btn-danger" onclick="if(!confirm('Dismiss ALL issues for {{.AppName}}? They resurface on a new occurrence.')) return false; document.getElementById('issueAction').value='all';">Dismiss All Issues</button>
</div>
</form>
<script>
@@ -234,9 +278,40 @@
var cbs = document.querySelectorAll('.issue-cb');
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
});
function toggleIssue(id) {
var el = document.getElementById('issue-detail-' + id);
if (el) el.style.display = (el.style.display === 'none') ? '' : 'none';
}
function copyText(id, btn) {
var el = document.getElementById(id);
if (!el) return;
var text = el.textContent;
var done = function() {
var old = btn.textContent;
btn.textContent = 'Copied';
setTimeout(function() { btn.textContent = old; }, 1200);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, function() { fallbackCopy(text, done); });
} else {
fallbackCopy(text, done);
}
}
function fallbackCopy(text, done) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
</script>
{{else}}
<p class="text-muted">No issues in this period{{if .CustomerFilter}} for this customer{{end}}.</p>
{{end}}
</section>
{{end}}
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
@@ -49,6 +49,7 @@
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
{{else if eq .Flash "log_tail_requested"}}Log tail requested — the controller delivers it on its next report cycle (a few minutes). A customer-visible event line was recorded.
{{end}}
</div>
{{end}}
@@ -616,18 +617,62 @@
<th>Catalog Limit</th>
<th>Errors</th>
<th>Warnings</th>
<th>Logs</th>
</tr>
</thead>
<tbody>
{{range .AppTelemetry}}
<tr>
<td><a href="/apps/{{.AppName}}">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td><a href="/apps/{{.AppName}}?customer={{$.CustomerID}}" title="Known issues filtered to this customer">{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</a></td>
<td class="{{memoryColor .MemoryCurrentMB .CatalogLimit}}">{{formatFloat .MemoryCurrentMB}} MB</td>
<td>{{formatFloat .MemoryAvgMB}} MB</td>
<td>{{formatFloat .MemoryPeakMB}} MB</td>
<td>{{if .CatalogLimit}}{{.CatalogLimit}}{{else}}—{{end}}</td>
<td>{{if gt .LogErrors 0}}<span class="badge badge-error">{{.LogErrors}}</span>{{else}}0{{end}}</td>
<td>{{if gt .LogWarnings 0}}<span class="badge badge-warn">{{.LogWarnings}}</span>{{else}}0{{end}}</td>
<td>
{{if index $.PendingTails .AppName}}
<span class="badge badge-neutral" title="The controller delivers the tail on its next report cycle">tail pending</span>
{{else}}
<form method="POST" action="/customers/{{$.CustomerID}}/request-log-tail" style="display: inline;">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="app" value="{{.AppName}}">
<button type="submit" class="btn btn-sm" title="Pull-based: the controller ships the last 200 log lines on its next report; a customer-visible event line is recorded">Request log tail</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
<!-- Received log tails (on-demand, transient — last 2 per app) -->
{{if .HasLogTails}}
<section class="card">
<h2>App Log Tails <span class="text-muted" style="font-size: 0.85em; font-weight: normal;">(on-demand, last 2 per app kept)</span></h2>
<table class="data-table">
<thead>
<tr>
<th>App</th>
<th>Collected</th>
<th>Received</th>
<th>Lines</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .LogTails}}
<tr>
<td style="font-family: var(--font-mono);">{{.AppName}}</td>
<td>{{.CollectedAt.Format "2006-01-02 15:04:05"}} ({{timeAgo .CollectedAt}})</td>
<td>{{timeAgo .ReceivedAt}}</td>
<td>{{len .Lines}}</td>
<td style="white-space: nowrap;">
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}" class="btn btn-sm">View</a>
<a href="/customers/{{$.CustomerID}}/log-tail/{{.ID}}?download=1" class="btn btn-sm">Download .log</a>
</td>
</tr>
{{end}}
</tbody>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Tail.AppName}} log tail — Felhom Hub</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
{{template "icon_sprite"}}
<div class="container">
<header>
<h1>Felhom <span>Hub</span></h1>
<nav class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/configs" class="nav-link active">Customers</a>
<a href="/apps" class="nav-link">Apps</a>
<a href="/hosts" class="nav-link">Hosts</a>
<a href="/offsite" class="nav-link">Offsite</a>
<a href="/configuration" class="nav-link">Configuration</a>
</nav>
</header>
<a href="/customers/{{.CustomerID}}" class="back-link">&larr; {{.CustomerID}}</a>
<section class="card" style="margin-top: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Log tail: <span style="font-family: var(--font-mono);">{{.Tail.AppName}}</span></h2>
<a href="/customers/{{.CustomerID}}/log-tail/{{.Tail.ID}}?download=1" class="btn btn-sm">Download .log</a>
</div>
<p class="text-muted" style="margin-top: 0.25rem; font-size: 0.85rem;">
Collected on the box at {{.Tail.CollectedAt.Format "2006-01-02 15:04:05"}} UTC ({{timeAgo .Tail.CollectedAt}})
&middot; {{len .Tail.Lines}} lines, ordered as emitted &middot; redacted controller-side before shipping.
</p>
{{if .Tail.Lines}}
<ol class="log-lines" style="font-family: var(--font-mono); font-size: 0.8rem; background: var(--bg-0); border: 1px solid var(--line-soft); border-radius: var(--radius); padding: 0.5rem 0.5rem 0.5rem 4rem; margin: 0.5rem 0 0; overflow-x: auto;">
{{range .Tail.Lines}}
<li style="white-space: pre-wrap; word-break: break-word; color: var(--text-1);">{{.}}</li>
{{end}}
</ol>
{{else}}
<p class="text-muted">The tail arrived empty (the container produced no log lines).</p>
{{end}}
</section>
<footer style="margin-top: 2rem; color: var(--text-muted); font-size: 0.8rem; text-align: center;">
Felhom Hub <span style="font-family: var(--font-mono)">{{hubVersion}}</span>
</footer>
</div>
</body>
</html>
+4
View File
@@ -759,6 +759,10 @@ code {
background: rgba(250, 204, 21, 0.2);
color: var(--warn);
}
.badge-neutral {
background: var(--bg-2);
color: var(--text-3);
}
/* Summary cards row */
.summary-cards {