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