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:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user