Filter the queue by cluster

A team with a cluster per alert source can now narrow the queue to one
cluster. A dropdown under the status chips lists the clusters, and appears
once there are two or more to choose between, the rule the team selector
follows. The choice is kept per browser, like the selected team, and dropped
if the server no longer knows the cluster rather than leaving an empty list
with no explanation. The status chip counts follow it, and a row leaves out
its cluster chip once the queue is narrowed to one.

The filter is on the server. The list is capped at 50 rows, so filtering what
is on screen would quietly miss older incidents in the Resolved and Archived
lists. GET /api/incidents takes ?cluster=<value>, matched against the
incident's `cluster` group label, and GET /api/incidents/clusters lists the
distinct values from the last 90 days (optionally for one team), so the
dropdown is not limited to what the current page happens to show. Both are
additive: no existing parameter or JSON shape changed, so terdut-tui keeps
working unchanged and has nothing it must mirror.

An incident carries the label only when `cluster` is in Alertmanager's
group_by, so the filter only sees those; the README says so.
This commit is contained in:
Niklas Ye
2026-10-08 18:23:28 +02:00
parent db474ca909
commit a955356821
9 changed files with 218 additions and 12 deletions
+52
View File
@@ -13,6 +13,52 @@ import (
"github.com/go-chi/chi/v5"
)
// handleListClusters answers GET /api/incidents/clusters: the distinct values of
// the origin label across the caller's incidents from the last 90 days, sorted,
// so the queue can offer them as a filter. Optional team_id narrows it to one
// team. Empty when nothing carries the label, which is how the UI knows to show
// no filter at all.
func handleListClusters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
args := &sqlArgs{}
where := []string{
"team_id = ANY(" + args.add(callerTeamIDs(r.Context())) + ")",
"triggered_at >= " + args.add(time.Now().AddDate(0, 0, -90).Unix()),
}
if team := r.URL.Query().Get("team_id"); team != "" {
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
where = append(where, "team_id = "+args.add(n))
}
}
label := args.add(originLabel)
rows, err := db.QueryContext(r.Context(),
"SELECT DISTINCT group_labels->>"+label+" AS v FROM incidents WHERE "+
strings.Join(where, " AND ")+" AND group_labels->>"+label+" <> '' ORDER BY v LIMIT 200",
args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
clusters := []string{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
clusters = append(clusters, v)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, clusters)
}
}
func handleListIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
@@ -53,6 +99,12 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
if severity := q.Get("severity"); severity != "" {
where = append(where, "i.severity = "+args.add(severity))
}
// Where it came from: the value of the origin label (originLabel, by
// convention "cluster") among the incident's group labels. An incident has
// it only when the label is in Alertmanager's group_by.
if cluster := q.Get("cluster"); cluster != "" {
where = append(where, "i.group_labels->>"+args.add(originLabel)+" = "+args.add(cluster))
}
if assignee := q.Get("assigned_to"); assignee != "" {
if n, err := strconv.ParseInt(assignee, 10, 64); err == nil {
where = append(where, "i.assigned_to = "+args.add(n))
+35
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
"testing"
"time"
@@ -972,3 +973,37 @@ func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
}
}
}
// The queue can be narrowed to one cluster, and the distinct clusters are
// offered so the filter has something to list.
func TestIncidents_FilterByCluster(t *testing.T) {
s := newTS(t)
for _, c := range []string{"prod-eu", "prod-us"} {
fireGroupedAs(t, s, "fp-"+c,
map[string]string{"alertname": "PodRestarting", "cluster": c, "namespace": "n"},
`{}:{alertname="PodRestarting",cluster="`+c+`",namespace="n"}`)
}
// A cluster-less incident exists too, and must never match a cluster filter.
fireGroupedAs(t, s, "fp-none", map[string]string{"alertname": "DiskFull"}, `{}:{alertname="DiskFull"}`)
if got := len(listIncidents(t, s, "")); got != 3 {
t.Fatalf("expected 3 open incidents, got %d", got)
}
eu := listIncidents(t, s, "?cluster=prod-eu")
if len(eu) != 1 {
t.Fatalf("expected 1 incident for prod-eu, got %d", len(eu))
}
if labels, _ := eu[0]["group_labels"].(map[string]any); labels["cluster"] != "prod-eu" {
t.Errorf("filtered to the wrong cluster: %v", eu[0]["group_labels"])
}
if got := len(listIncidents(t, s, "?cluster=nowhere")); got != 0 {
t.Errorf("an unknown cluster should match nothing, got %d", got)
}
var clusters []string
decode(t, s.req(t, http.MethodGet, "/api/incidents/clusters", nil), &clusters)
if want := "prod-eu,prod-us"; strings.Join(clusters, ",") != want {
t.Errorf("clusters = %v, want %s", clusters, want)
}
}
+8 -1
View File
@@ -168,10 +168,17 @@ func fireCritical(t *testing.T, s *ts) {
// fireGrouped posts one critical alert whose Alertmanager group carries the
// given labels, the way group_by puts them on the webhook.
func fireGrouped(t *testing.T, s *ts, groupLabels map[string]string, groupKey string) {
t.Helper()
fireGroupedAs(t, s, "fp-grouped", groupLabels, groupKey)
}
// fireGroupedAs is fireGrouped with its own alert fingerprint, for a test that
// needs several alerts open at once.
func fireGroupedAs(t *testing.T, s *ts, fingerprint string, groupLabels map[string]string, groupKey string) {
t.Helper()
payload := map[string]any{
"version": "4", "status": "firing", "groupKey": groupKey, "groupLabels": groupLabels,
"alerts": []map[string]any{amAlert("fp-grouped", "PodRestarting", "firing",
"alerts": []map[string]any{amAlert(fingerprint, "PodRestarting", "firing",
"2026-05-20T10:00:00Z", zeroTime, map[string]string{"severity": "critical"})},
}
data, _ := json.Marshal(payload)
+1
View File
@@ -145,6 +145,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/alerts/{id}", handleGetAlert(db))
r.Get("/api/incidents", handleListIncidents(db))
r.Get("/api/incidents/clusters", handleListClusters(db))
r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))