Hub v0.6.1: delete issues from UI + fingerprint hardening
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Felhom Hub — Changelog
|
# 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)
|
## v0.6.0 (2026-02-25)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -3,9 +3,17 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"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.
|
// AppTelemetryRecord holds per-app telemetry received from a controller report.
|
||||||
type AppTelemetryRecord struct {
|
type AppTelemetryRecord struct {
|
||||||
AppName string `json:"app_name"`
|
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.
|
// fingerprintIssue creates a short fingerprint key from a message string.
|
||||||
func fingerprintIssue(msg string) string {
|
func fingerprintIssue(msg string) string {
|
||||||
if len(msg) > 100 {
|
s := reANSI.ReplaceAllString(msg, "")
|
||||||
msg = msg[:100]
|
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.
|
// min returns the smaller of two ints.
|
||||||
@@ -464,3 +477,22 @@ func (s *Store) DeleteAppIssues(appName string) (int64, error) {
|
|||||||
}
|
}
|
||||||
return res.RowsAffected()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -130,6 +130,57 @@ func (s *Server) handleResetAppTelemetry(w http.ResponseWriter, r *http.Request,
|
|||||||
http.Redirect(w, r, target, http.StatusSeeOther)
|
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.
|
// parsePeriod converts a period string to a time.Time cutoff.
|
||||||
func parsePeriod(s string, defaultDur time.Duration) time.Time {
|
func parsePeriod(s string, defaultDur time.Duration) time.Time {
|
||||||
switch s {
|
switch s {
|
||||||
|
|||||||
@@ -161,6 +161,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
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/"):
|
case strings.HasPrefix(path, "/apps/"):
|
||||||
appName := strings.TrimPrefix(path, "/apps/")
|
appName := strings.TrimPrefix(path, "/apps/")
|
||||||
s.handleAppDetail(w, r, appName)
|
s.handleAppDetail(w, r, appName)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@
|
|||||||
{{if eq .Flash "telemetry_reset"}}
|
{{if eq .Flash "telemetry_reset"}}
|
||||||
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
|
<div class="flash flash-success" style="margin-top: 1rem;">Telemetry data deleted successfully.</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
{{if eq .Flash "issues_deleted"}}
|
||||||
|
<div class="flash flash-success" style="margin-top: 1rem;">Selected issues deleted successfully.</div>
|
||||||
|
{{end}}
|
||||||
|
{{if eq .Flash "no_issues_selected"}}
|
||||||
|
<div class="flash flash-error" style="margin-top: 1rem;">No issues were selected for deletion.</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<!-- Overview card -->
|
<!-- Overview card -->
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -183,9 +189,13 @@
|
|||||||
{{if .Issues}}
|
{{if .Issues}}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h2>Known Issues</h2>
|
<h2>Known Issues</h2>
|
||||||
|
<form method="POST" action="/apps/{{.AppName}}/delete-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">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
|
||||||
<th>Severity</th>
|
<th>Severity</th>
|
||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
<th>Occurrences</th>
|
<th>Occurrences</th>
|
||||||
@@ -197,6 +207,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{range .Issues}}
|
{{range .Issues}}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><input type="checkbox" name="issue_ids" value="{{.ID}}" class="issue-cb"></td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
|
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
|
||||||
{{else}}<span class="badge badge-warn">warn</span>{{end}}
|
{{else}}<span class="badge badge-warn">warn</span>{{end}}
|
||||||
@@ -210,6 +221,17 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
document.getElementById('selectAll').addEventListener('change', function() {
|
||||||
|
var cbs = document.querySelectorAll('.issue-cb');
|
||||||
|
for (var i = 0; i < cbs.length; i++) cbs[i].checked = this.checked;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</section>
|
</section>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -748,6 +748,12 @@ code {
|
|||||||
}
|
}
|
||||||
.data-table th a:hover { color: var(--text-primary); }
|
.data-table th a:hover { color: var(--text-primary); }
|
||||||
.data-table tr:hover td { background: rgba(96,165,250,0.04); }
|
.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 */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user