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:
2026-02-25 16:01:55 +01:00
parent 23cb487348
commit 7860f96a56
6 changed files with 158 additions and 30 deletions
+51
View File
@@ -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 {
+8
View File
@@ -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)
+49 -27
View File
@@ -31,6 +31,12 @@
{{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>
{{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 -->
<section class="card">
@@ -183,33 +189,49 @@
{{if .Issues}}
<section class="card">
<h2>Known Issues</h2>
<table class="data-table">
<thead>
<tr>
<th>Severity</th>
<th>Message</th>
<th>Occurrences</th>
<th>Affected Customers</th>
<th>First Seen</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr>
<td>
{{if eq .Severity "error"}}<span class="badge badge-error">error</span>
{{else}}<span class="badge badge-warn">warn</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>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</td>
</tr>
{{end}}
</tbody>
</table>
<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">
<thead>
<tr>
<th style="width: 2rem;"><input type="checkbox" id="selectAll" title="Select all"></th>
<th>Severity</th>
<th>Message</th>
<th>Occurrences</th>
<th>Affected Customers</th>
<th>First Seen</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
{{range .Issues}}
<tr>
<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}}
</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>{{.OccurrenceCount}}</td>
<td>{{len .AffectedCustomers}}</td>
<td>{{timeAgo .FirstSeen}}</td>
<td>{{timeAgo .LastSeen}}</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>
</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>
{{end}}
+6
View File
@@ -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) {