From 7860f96a564222faceef1a091b0634f394a13b9e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 25 Feb 2026 16:01:55 +0100 Subject: [PATCH] Hub v0.6.1: delete issues from UI + fingerprint hardening Co-Authored-By: Claude Opus 4.6 --- hub/CHANGELOG.md | 9 +++ hub/internal/store/telemetry.go | 38 ++++++++++- hub/internal/web/apps.go | 51 +++++++++++++++ hub/internal/web/server.go | 8 +++ hub/internal/web/templates/app_detail.html | 76 ++++++++++++++-------- hub/internal/web/templates/style.css | 6 ++ 6 files changed, 158 insertions(+), 30 deletions(-) diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 005cf51..3b75528 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,14 @@ # Felhom Hub — Changelog +## v0.6.1 (2026-02-25) + +### Added +- **Delete issues from app detail page** — Known Issues table now has per-row checkboxes with "Delete Selected" and "Delete All Issues" buttons; keeps telemetry data (memory trends, etc.) intact +- **`DELETE /apps/{appName}/delete-issues`** — New POST endpoint supporting `action=selected` (with `issue_ids` form values) and `action=all` + +### Fixed +- **Hub-side fingerprint hardening** — `fingerprintIssue()` now strips ANSI escape codes, ISO/syslog timestamps, and lowercases before truncating to 100 chars. Prevents duplicate issue rows when messages differ only by embedded timestamps. + ## v0.6.0 (2026-02-25) ### Added diff --git a/hub/internal/store/telemetry.go b/hub/internal/store/telemetry.go index 2f7c132..ef014d9 100644 --- a/hub/internal/store/telemetry.go +++ b/hub/internal/store/telemetry.go @@ -3,9 +3,17 @@ package store import ( "database/sql" "encoding/json" + "regexp" + "strings" "time" ) +var ( + reANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`) + reTimestamp = regexp.MustCompile(`\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}[.\d]*([+-]\d{2}:?\d{2})?[Z ]?:? ?`) + reSyslog = regexp.MustCompile(`[A-Z][a-z]{2}\s+\d{1,2} \d{2}:\d{2}:\d{2} `) +) + // AppTelemetryRecord holds per-app telemetry received from a controller report. type AppTelemetryRecord struct { AppName string `json:"app_name"` @@ -180,10 +188,15 @@ func upsertAppIssue(tx *sql.Tx, appName, fingerprint, severity, message, custome // fingerprintIssue creates a short fingerprint key from a message string. func fingerprintIssue(msg string) string { - if len(msg) > 100 { - msg = msg[:100] + s := reANSI.ReplaceAllString(msg, "") + s = reTimestamp.ReplaceAllString(s, "") + s = reSyslog.ReplaceAllString(s, "") + s = strings.TrimSpace(s) + s = strings.ToLower(s) + if len(s) > 100 { + s = s[:100] } - return msg + return s } // min returns the smaller of two ints. @@ -464,3 +477,22 @@ func (s *Store) DeleteAppIssues(appName string) (int64, error) { } return res.RowsAffected() } + +// DeleteAppIssuesByIDs removes specific issue records by their IDs. +func (s *Store) DeleteAppIssuesByIDs(ids []int) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + placeholders[i] = "?" + args[i] = id + } + query := "DELETE FROM app_log_issues WHERE id IN (" + strings.Join(placeholders, ",") + ")" + res, err := s.db.Exec(query, args...) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/hub/internal/web/apps.go b/hub/internal/web/apps.go index 2de26e4..966b85a 100644 --- a/hub/internal/web/apps.go +++ b/hub/internal/web/apps.go @@ -130,6 +130,57 @@ 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) { + action := r.FormValue("action") + + var deletedCount int64 + var err error + + switch action { + case "all": + deletedCount, err = s.store.DeleteAppIssues(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) + } + } + deletedCount, err = s.store.DeleteAppIssuesByIDs(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) + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + s.logger.Printf("[INFO] Deleted %d issues for %s (action=%s)", deletedCount, appName, action) + + period := r.URL.Query().Get("period") + target := "/apps/" + appName + "?flash=issues_deleted" + 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 { diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index d141247..3675f53 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -161,6 +161,14 @@ 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"): + appName := strings.TrimPrefix(path, "/apps/") + appName = strings.TrimSuffix(appName, "/delete-issues") + if r.Method == http.MethodPost { + s.handleDeleteAppIssues(w, r, appName) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/apps/"): appName := strings.TrimPrefix(path, "/apps/") s.handleAppDetail(w, r, appName) diff --git a/hub/internal/web/templates/app_detail.html b/hub/internal/web/templates/app_detail.html index 88d6064..eea5542 100644 --- a/hub/internal/web/templates/app_detail.html +++ b/hub/internal/web/templates/app_detail.html @@ -31,6 +31,12 @@ {{if eq .Flash "telemetry_reset"}}
Telemetry data deleted successfully.
{{end}} + {{if eq .Flash "issues_deleted"}} +
Selected issues deleted successfully.
+ {{end}} + {{if eq .Flash "no_issues_selected"}} +
No issues were selected for deletion.
+ {{end}}
@@ -183,33 +189,49 @@ {{if .Issues}}

Known Issues

- - - - - - - - - - - - - {{range .Issues}} - - - - - - - - - {{end}} - -
SeverityMessageOccurrencesAffected CustomersFirst SeenLast Seen
- {{if eq .Severity "error"}}error - {{else}}warn{{end}} - {{.Message}}{{.OccurrenceCount}}{{len .AffectedCustomers}}{{timeAgo .FirstSeen}}{{timeAgo .LastSeen}}
+
+ + + + + + + + + + + + + + + + {{range .Issues}} + + + + + + + + + + {{end}} + +
SeverityMessageOccurrencesAffected CustomersFirst SeenLast Seen
+ {{if eq .Severity "error"}}error + {{else}}warn{{end}} + {{.Message}}{{.OccurrenceCount}}{{len .AffectedCustomers}}{{timeAgo .FirstSeen}}{{timeAgo .LastSeen}}
+
+ + +
+
+
{{end}} diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index a0e2463..7ef96f2 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -748,6 +748,12 @@ code { } .data-table th a:hover { color: var(--text-primary); } .data-table tr:hover td { background: rgba(96,165,250,0.04); } +.data-table input[type="checkbox"] { + width: 1rem; + height: 1rem; + cursor: pointer; + accent-color: var(--accent); +} /* Responsive */ @media (max-width: 768px) {