List alert sources with their status and last arrival on Team -> Sources
Like Team -> Switches, the page is now a table: a status badge (Active
if the key posted within a day, Quiet if it has but not lately, Never
used), when it last posted a webhook, when an alert last arrived on it,
how many distinct alerts it refreshed in the last 24 hours, and when it
was created. Adding a source moved into a "New source" sheet, and owners
can rename one from its row.
"Last alert" and the count needed alerts to remember which source they
came in on, which they never did, so migration 010 adds
alerts.integration_id and every accepted payload stamps it. Last sender
wins when two sources post the same fingerprint. It is not backfilled: a
NULL says "before this was recorded" rather than guessing, and it heals
by itself as Alertmanager re-sends each alert every repeat_interval.
Revoking a source keeps its alerts, unattributed.
Last webhook and last alert are separate on purpose: a payload with
nothing usable in it stamps the first and not the second. The Quiet
threshold is a fixed day, a colour and not an alarm, since silence that
should page is what dead man's switches are for.
The counts are indexed subqueries (alerts_integration_idx) rather than a
join, which would read every alert a source ever delivered.
API: the integrations list gains status, last_alert_at and alerts_24h,
and PATCH /api/teams/{id}/integrations/{id} renames. Both are additive;
terdut-tui needs nothing.
This commit is contained in:
+126
-19
@@ -355,8 +355,42 @@ func isLastTeamOwner(ctx context.Context, db *sql.DB, teamID, userID int64) (boo
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleListIntegrations lists a team's integrations. Never the keys: those
|
||||
// exist in plaintext only in the response that created them.
|
||||
// sourceQuietAfter is how long a source may go without posting before the
|
||||
// Sources page calls it quiet rather than active. A day is longer than any
|
||||
// repeat_interval worth having, so an Alertmanager that is up and has anything
|
||||
// firing never crosses it; a source with nothing firing may, and that is a
|
||||
// reason to look, not proof of a fault — which is why this is a colour and not
|
||||
// an alarm. Dead man's switches are where silence pages.
|
||||
const sourceQuietAfter = 24 * time.Hour
|
||||
|
||||
const (
|
||||
sourceActive = "active"
|
||||
sourceQuiet = "quiet"
|
||||
sourceNever = "never"
|
||||
)
|
||||
|
||||
// integrationStatus is an integration as the Sources page shows it.
|
||||
type integrationStatus struct {
|
||||
models.Integration
|
||||
|
||||
// Status is active when the key posted within sourceQuietAfter, quiet when
|
||||
// it has posted but not lately, never when it has not posted at all.
|
||||
Status string `json:"status"`
|
||||
|
||||
// LastAlertAt is when an alert last arrived on this source, which is not the
|
||||
// same as when it last posted: a payload with nothing usable in it stamps
|
||||
// last_used_at and not this. Absent until an alert has arrived since
|
||||
// migration 010 started recording it.
|
||||
LastAlertAt *time.Time `json:"last_alert_at,omitempty"`
|
||||
|
||||
// Alerts24h counts the distinct alerts this source refreshed in the last
|
||||
// day. An alert re-sent every few hours counts once, not once per re-send.
|
||||
Alerts24h int64 `json:"alerts_24h"`
|
||||
}
|
||||
|
||||
// handleListIntegrations lists a team's integrations with what each has been
|
||||
// delivering. Never the keys: those exist in plaintext only in the response that
|
||||
// created them.
|
||||
func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
@@ -367,28 +401,45 @@ func handleListIntegrations(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
rows, err := db.QueryContext(r.Context(), `
|
||||
SELECT id, team_id, kind, name, created_at, last_used_at
|
||||
FROM integrations
|
||||
WHERE team_id = $1
|
||||
ORDER BY id`, teamID)
|
||||
SELECT i.id, i.team_id, i.kind, i.name, i.created_at, i.last_used_at,
|
||||
-- Scalar subqueries, not a join and GROUP BY: each is a
|
||||
-- single range over alerts_integration_idx, where the join
|
||||
-- would read every alert a source ever delivered.
|
||||
(SELECT MAX(received_at) FROM alerts WHERE integration_id = i.id),
|
||||
(SELECT COUNT(*) FROM alerts
|
||||
WHERE integration_id = i.id AND received_at >= $2)
|
||||
FROM integrations i
|
||||
WHERE i.team_id = $1
|
||||
ORDER BY i.id`, teamID, now.Add(-sourceQuietAfter).Unix())
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
integrations := []models.Integration{}
|
||||
integrations := []integrationStatus{}
|
||||
for rows.Next() {
|
||||
var i models.Integration
|
||||
var i integrationStatus
|
||||
var created int64
|
||||
var lastUsed *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed); err != nil {
|
||||
var lastUsed, lastAlert *int64
|
||||
if err := rows.Scan(&i.ID, &i.TeamID, &i.Kind, &i.Name, &created, &lastUsed,
|
||||
&lastAlert, &i.Alerts24h); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
i.CreatedAt = time.Unix(created, 0).UTC()
|
||||
i.LastUsedAt = unixPtr(lastUsed)
|
||||
i.LastAlertAt = unixPtr(lastAlert)
|
||||
switch {
|
||||
case i.LastUsedAt == nil:
|
||||
i.Status = sourceNever
|
||||
case now.Sub(*i.LastUsedAt) > sourceQuietAfter:
|
||||
i.Status = sourceQuiet
|
||||
default:
|
||||
i.Status = sourceActive
|
||||
}
|
||||
integrations = append(integrations, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -457,6 +508,54 @@ func handleCreateIntegration(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// handleRenameIntegration renames a source. The key is untouched, so nothing
|
||||
// posting with it notices.
|
||||
func handleRenameIntegration(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !requireTeamOwner(w, r, teamID) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(chi.URLParam(r, "integrationID"), 10, 64)
|
||||
if err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid integration id"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respond(w, http.StatusBadRequest, errResp("name is required"))
|
||||
return
|
||||
}
|
||||
if len(req.Name) > 100 {
|
||||
respond(w, http.StatusBadRequest, errResp("name is too long"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := db.ExecContext(r.Context(),
|
||||
"UPDATE integrations SET name = $1 WHERE id = $2 AND team_id = $3", req.Name, id, teamID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
respond(w, http.StatusNotFound, errResp("not found"))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteIntegration(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
teamID, ok := teamParam(w, r)
|
||||
@@ -493,24 +592,32 @@ func integrationPath(key, kind string) string {
|
||||
return "/api/integrations/" + key + "/" + kind
|
||||
}
|
||||
|
||||
// teamIDForKey resolves an integration key to its team, and stamps the key's
|
||||
// alertSource is who an arriving webhook is from: the integration whose key it
|
||||
// used, and the team that integration puts its alerts in.
|
||||
type alertSource struct {
|
||||
integrationID int64
|
||||
teamID int64
|
||||
}
|
||||
|
||||
// sourceForKey resolves an integration key to its source, and stamps the key's
|
||||
// last use. An unknown key is not an error worth distinguishing: the caller is
|
||||
// told nothing beyond "no".
|
||||
func teamIDForKey(ctx context.Context, db *sql.DB, key string) (int64, error) {
|
||||
var teamID int64
|
||||
func sourceForKey(ctx context.Context, db *sql.DB, key string) (alertSource, error) {
|
||||
var src alertSource
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT team_id FROM integrations WHERE key_hash = $1", hashToken(key)).Scan(&teamID)
|
||||
"SELECT id, team_id FROM integrations WHERE key_hash = $1", hashToken(key)).
|
||||
Scan(&src.integrationID, &src.teamID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, errUnknownIntegration
|
||||
return alertSource{}, errUnknownIntegration
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return alertSource{}, err
|
||||
}
|
||||
// Best effort, like an API key's: a failed stamp must not reject an alert.
|
||||
db.ExecContext(ctx, //nolint:errcheck
|
||||
"UPDATE integrations SET last_used_at = $1 WHERE key_hash = $2",
|
||||
time.Now().Unix(), hashToken(key))
|
||||
return teamID, nil
|
||||
"UPDATE integrations SET last_used_at = $1 WHERE id = $2",
|
||||
time.Now().Unix(), src.integrationID)
|
||||
return src, nil
|
||||
}
|
||||
|
||||
var errUnknownIntegration = errors.New("unknown integration key")
|
||||
|
||||
Reference in New Issue
Block a user