hub v0.31.0: accept 'critical' severity at event ingest + UI badges/CSS; event_test.go (red-proofed); REUSE.md §1/§3 updated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-03 11:08:44 +02:00
parent d331eb26d1
commit b5f00509ee
9 changed files with 141 additions and 12 deletions
+2 -2
View File
@@ -12,7 +12,7 @@
|---|---|---|---|---|
| `(*Handler).checkAuthCustomer` | hub/internal/api/handler.go (~L94) | `(r) (customerID string, isGlobal, ok bool)` | Bearer auth for controller-facing endpoints (global key OR per-customer key) | Global key → `("", true, true)`: caller must then trust body `customer_id`. Constant-time compare on global key. |
| `(*Handler).checkAuthHost` | hub/internal/api/handler.go (~L119) | `(r) (hostID, customerID string, isGlobal, ok bool)` | Bearer auth for agent-facing endpoints (global OR per-host key) | Sibling of checkAuthCustomer — do NOT mix the two token namespaces. Global key requires the host row to already exist (see handleHostReport). |
| `(*Handler).handleEvent` + `allowedEventTypes` | hub/internal/api/handler.go (~L1115 / ~L1063) | `POST /api/v1/event` | The ONLY controller→hub structured-event ingest | Unknown `event_type` → 400 (add to the map FIRST). Severity coerced to info/warning/error — `"critical"` silently becomes `"info"` (see §3). |
| `(*Handler).handleEvent` + `allowedEventTypes` | hub/internal/api/handler.go (~L1115 / ~L1063) | `POST /api/v1/event` | The ONLY controller→hub structured-event ingest | Unknown `event_type` → 400 (add to the map FIRST). Accepted severities: info/warning/error/critical (critical since v0.31.0); anything else coerces to `"info"` — exact-match lowercase (`"Critical"` coerces). Tests: hub/internal/api/event_test.go. |
| `(*Handler).handleHostReport` | hub/internal/api/handler.go (~L464) | `POST /api/v1/host-report` | Agent heartbeat ingest: denorm + guest upsert | Body cap via LimitReader; per-host key enforces `host_id` match (403 on mismatch); `received_at` is the dead-man's-switch. |
| `(*Handler).handleConfigRetrieve` | hub/internal/api/handler.go (~L1484) | `GET /api/v1/config/{id}`, header `X-Retrieval-Password` | Canonical password-gated retrieval endpoint | Constant-time compare vs `cfg.RetrievalPassword`; 404-before-401 ordering. `handleArtifactManifest` mirrors it EXACTLY — keep them in lockstep. |
| `writeJSON` | hub/internal/api/dr.go (~L25) | `(w, code int, v any)` | JSON responses in api package | Only used in dr.go so far; prefer it over ad-hoc byte-writes for new endpoints. |
@@ -107,7 +107,7 @@
| Trap | Why it bites | Use instead |
|---|---|---|
| `(*Handler).handleNotify` + `formatNotificationEmail` + `sendResendEmail` (hub/internal/api/handler.go ~L1289/1624/1589) | Legacy pre-dispatcher notification trio: no cooldowns, no operator channel, no allowedEventTypes gate, duplicate Hungarian formatter. Controller path is FROZEN until slice-10 cutover. | `POST /api/v1/event``Dispatcher.ProcessEvent` + `notify.Format*Email` |
| Severity `"critical"` via `POST /api/v1/event` | `handleEvent` (~L1157) coerces unknown severities — including `critical` — to `"info"`, which never notifies. Silent alert loss. | Send `warning`/`error` from controllers, or extend the handleEvent switch AND allowedEventTypes together |
| Severity `"critical"` POSTed to a PRE-v0.31.0 hub | Fixed in hub v0.31.0 (`handleEvent` now accepts critical). Older hubs coerce `critical` `"info"`, which never notifies — silent alert loss. Case-variants (`"Critical"`) still coerce on every version. | Against an old hub send `warning`/`error`; otherwise lowercase `critical` is safe |
| `compareVersions` for anything security-ish (hub/internal/web/server.go ~L571) | Returns 0 (equal) on unparseable input — a garbage version passes a floor check. `gitea.compareSemver` behaves differently (lexical fallback). | Validate input with `normalizeFloorInput` first; then compareVersions is safe |
| Inline `stringData` secrets à la manifests/felhom.secret.yaml | Commits real credentials to git (healthchecks superuser pw, umami APP_SECRET/POSTGRES_PASSWORD still live there). | Out-of-band `kubectl create secret` + `secretKeyRef` (hub.yaml resend-api pattern; runbook documentation/runbooks/secrets.md) |
| `kubectl apply` / `kubectl set image` on manifests/ | ArgoCD app `felhom` reverts drift on next sync; live state lies about git. | Edit manifest in git → push → ArgoCD sync (CLAUDE.md steps 35) |
+23
View File
@@ -1,5 +1,28 @@
# Felhom Hub — Changelog
## v0.31.0 — critical severity accepted at event ingest + visible in UI (2026-07-03)
Fixes the gotcha the REUSE sweep surfaced: `handleEvent` coerced any severity outside
{info,warning,error} — including `"critical"` — to `"info"` at ingest, so a controller-POSTed
critical event never notified even though the dispatcher (`severityNotifies`, v0.24.0) and
`FormatOperatorEmail` already handle critical correctly.
- **Ingest (`internal/api/handler.go` `handleEvent`):** `"critical"` added to the severity case
list. Unknown values (and case-variants like `"Critical"`) still coerce to `"info"` — the
exact-match-lowercase coercion contract is kept and now locked by test.
- **Hungarian label (`internal/notify/templates.go`):** `severityLabels["critical"] = "Kritikus hiba"`
(was missing — customer emails would have shown the raw English word).
- **UI counts:** dashboard consumer (`internal/web/server.go`) gains `EventCriticals`;
`dashboard.html` renders the critical badge FIRST in the 24h count chain (guard extended);
`customer_unified.html` gains the `{{.}} critical` summary badge before errors.
- **style.css:** defines the previously-referenced-but-undefined `.severity-critical`
(`--crit`/`--crit-dim` tokens) and `.severity-ok` (neutral, exception-color principle). No other
restyle.
- **Tests (`internal/api/event_test.go`, new):** critical preserved to store (companion red-proof:
shown failing against the pre-fix switch — stored `"info"`); unknown severity → info; unknown
event_type → 400 + nothing stored; no-auth → 401. First tests on the /event endpoint.
- REUSE.md §1/§3 updated in the same commit (the maintenance rule's first outing).
## docs — REUSE.md introduced (2026-07-03)
Cross-repo reuse-map rollout (docs-only, no code change, no version bump). New `REUSE.md` at the
+92
View File
@@ -0,0 +1,92 @@
package api
import (
"net/http"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func eventBody(severity, eventType string) string {
return `{"customer_id":"c1","event_type":"` + eventType + `","severity":"` + severity + `","message":"probe"}`
}
func newEventTestHandler(t *testing.T) (*Handler, *store.Store) {
t.Helper()
h, st, _ := newTestHandler(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}); err != nil {
t.Fatalf("SaveCustomerConfig: %v", err)
}
return h, st
}
// Scenario A: a controller-POSTed "critical" event must be stored as "critical",
// not coerced to "info" (pre-v0.31.0 bug: the severity switch omitted "critical",
// so critical events silently became info and never notified).
func TestHandleEvent_CriticalPreserved(t *testing.T) {
h, st := newEventTestHandler(t)
rr := do(h, http.MethodPost, "/event", "ckey", eventBody("critical", "test"))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
evs, err := st.GetRecentEvents("c1", 10)
if err != nil {
t.Fatalf("GetRecentEvents: %v", err)
}
if len(evs) != 1 {
t.Fatalf("stored events = %d, want 1", len(evs))
}
if evs[0].Severity != "critical" {
t.Errorf("stored severity = %q, want %q (critical must survive ingest)", evs[0].Severity, "critical")
}
}
// Scenario B: unknown severities still coerce to "info" — the coercion contract is
// exact-match lowercase; only "critical" joined the known set.
func TestHandleEvent_UnknownSeverityCoercesToInfo(t *testing.T) {
h, st := newEventTestHandler(t)
rr := do(h, http.MethodPost, "/event", "ckey", eventBody("banana", "test"))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
}
evs, err := st.GetRecentEvents("c1", 10)
if err != nil {
t.Fatalf("GetRecentEvents: %v", err)
}
if len(evs) != 1 || evs[0].Severity != "info" {
t.Errorf("stored = %+v, want exactly one event with severity info", evs)
}
}
// Scenario C: an event_type outside allowedEventTypes is rejected with 400 and
// nothing is stored (locks the allowlist behavior).
func TestHandleEvent_UnknownEventTypeRejected(t *testing.T) {
h, st := newEventTestHandler(t)
rr := do(h, http.MethodPost, "/event", "ckey", eventBody("error", "not-a-real-type"))
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body=%s", rr.Code, rr.Body.String())
}
evs, err := st.GetRecentEvents("c1", 10)
if err != nil {
t.Fatalf("GetRecentEvents: %v", err)
}
if len(evs) != 0 {
t.Errorf("stored events = %d, want 0 after a rejected event_type", len(evs))
}
}
// Auth: no bearer at all is a 401 and stores nothing.
func TestHandleEvent_Unauthorized(t *testing.T) {
h, st := newEventTestHandler(t)
rr := do(h, http.MethodPost, "/event", "", eventBody("critical", "test"))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rr.Code)
}
if evs, _ := st.GetRecentEvents("c1", 10); len(evs) != 0 {
t.Errorf("stored events = %d, want 0 without auth", len(evs))
}
}
+2 -2
View File
@@ -1153,9 +1153,9 @@ func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
return
}
// Validate/default severity
// Validate/default severity (exact-match lowercase; unknown values coerce to info)
switch payload.Severity {
case "info", "warning", "error":
case "info", "warning", "error", "critical":
default:
payload.Severity = "info"
}
+4 -3
View File
@@ -109,9 +109,10 @@ var customerMessages = map[string]string{
// severityLabels maps severity to Hungarian labels.
var severityLabels = map[string]string{
"info": "Információ",
"warning": "Figyelmeztetés",
"error": "Hiba",
"info": "Információ",
"warning": "Figyelmeztetés",
"error": "Hiba",
"critical": "Kritikus hiba",
}
// FormatCustomerEmail returns (subject, textBody) for the customer channel.
+6 -4
View File
@@ -497,10 +497,11 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
type dashboardCustomer struct {
store.CustomerSummary
OverallStatus string // "ok", "warn", "down", "pending"
BackupAge string
EventErrors int
EventWarnings int
OverallStatus string // "ok", "warn", "down", "pending"
BackupAge string
EventCriticals int
EventErrors int
EventWarnings int
}
// Build map of report customers keyed by ID
@@ -537,6 +538,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
// Event counts (last 24h)
if counts, err := s.store.CountEventsBySeverity(c.CustomerID, time.Now().Add(-24*time.Hour)); err == nil {
dc.EventCriticals = counts["critical"]
dc.EventErrors = counts["error"]
dc.EventWarnings = counts["warning"]
}
@@ -488,6 +488,7 @@
<section class="card">
<h2>Events
{{if .EventCounts}}
{{with mapGet .EventCounts "critical"}}<span class="severity-badge severity-critical">{{.}} critical</span>{{end}}
{{with mapGet .EventCounts "error"}}<span class="severity-badge severity-error">{{.}} error{{if gt . 1}}s{{end}}</span>{{end}}
{{with mapGet .EventCounts "warning"}}<span class="severity-badge severity-warning">{{.}} warning{{if gt . 1}}s{{end}}</span>{{end}}
{{end}}
+1 -1
View File
@@ -54,7 +54,7 @@
{{if eq .OverallStatus "ok"}}OK{{else if eq .OverallStatus "warn"}}WARN{{else if eq .OverallStatus "disabled"}}PAUSED{{else if eq .OverallStatus "pending"}}PENDING{{else}}DOWN{{end}}
</span>
</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{if gt (add .EventErrors .EventWarnings) 0}}{{if gt .EventErrors 0}}<span class="severity-badge severity-error">{{.EventErrors}}</span>{{end}}{{if gt .EventWarnings 0}}<span class="severity-badge severity-warning">{{.EventWarnings}}</span>{{end}}{{else}}<span class="text-muted"></span>{{end}}{{end}}</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{if gt (add (add .EventCriticals .EventErrors) .EventWarnings) 0}}{{if gt .EventCriticals 0}}<span class="severity-badge severity-critical">{{.EventCriticals}}</span>{{end}}{{if gt .EventErrors 0}}<span class="severity-badge severity-error">{{.EventErrors}}</span>{{end}}{{if gt .EventWarnings 0}}<span class="severity-badge severity-warning">{{.EventWarnings}}</span>{{end}}{{else}}<span class="text-muted"></span>{{end}}{{end}}</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{timeAgo .ReceivedAt}}{{end}}</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .CPUPercent}}%{{end}}</td>
<td>{{if eq .OverallStatus "pending"}}—{{else}}{{formatFloat .MemoryPercent}}%{{end}}</td>
+10
View File
@@ -645,6 +645,16 @@ code {
color: #3b82f6;
}
.severity-critical {
background: var(--crit-dim);
color: var(--crit);
}
.severity-ok {
background: var(--bg-2);
color: var(--text-2);
}
/* Event filter buttons */
.event-filter {
font-size: 0.8em;