289eca8076
CI / test (push) Successful in 2m15s
The module path, the container image, the Helm chart and the CI pipeline all named GitHub. They now name the Gitea instance everything else already runs on. The workflows are rewritten rather than translated. Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS action runs there at all -- actions/checkout@v4 dies with a SyntaxError before it does anything. Every step is shell, checkout is a plain clone (this repo is public, so it needs no credential), and the jobs that need docker or helm run in host mode because the dind bridge a `container:` job gets cannot reach github.com or get.helm.sh. Two consequences worth naming: - upload-artifact/download-artifact are also JS actions, and there is no artifact store here, so the job that builds the binaries is the job that publishes them. Nothing is passed between jobs. - setup-qemu-action is gone with the rest, and the runner has no binfmt registration. The Dockerfile's builder stage now runs on $BUILDPLATFORM and cross-compiles from TARGETARCH instead, which is what keeps the arm64 image buildable -- and makes it native rather than emulated. The chart moves from a GitHub Pages index to an OCI artifact in Gitea's registry. Publishing stays tag-only for the reason recorded in release.yaml: a workflow triggered by the branch push cannot know the version it is about to be tagged with. The GitHub repository is left in place and untouched. Nothing pushes to it any more, but its existing release downloads and chart index keep resolving.
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
)
|
|
|
|
// alertSelectFrom is the shared SELECT … FROM … clause used by all alert queries.
|
|
// The subquery resolves the alert's most recent incident: membership is kept in
|
|
// incident_alerts rather than as a column here, because one alert row is reused
|
|
// across occurrences and belongs to a different incident each time.
|
|
const alertSelectFrom = `
|
|
SELECT a.id, a.fingerprint, a.name, a.status,
|
|
a.labels, a.annotations,
|
|
a.starts_at, a.ends_at, a.generator_url, a.received_at,
|
|
(SELECT ia.incident_id
|
|
FROM incident_alerts ia
|
|
JOIN incidents i ON i.id = ia.incident_id
|
|
WHERE ia.alert_id = a.id
|
|
ORDER BY i.triggered_at DESC, i.id DESC
|
|
LIMIT 1),
|
|
a.resolution_source, a.archived_at
|
|
FROM alerts a`
|
|
|
|
func handleListAlerts(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
|
|
where := []string{}
|
|
args := []any{}
|
|
|
|
if status := q.Get("status"); status != "" {
|
|
where = append(where, "a.status = ?")
|
|
args = append(args, status)
|
|
}
|
|
if name := q.Get("name"); name != "" {
|
|
where = append(where, "a.name = ?")
|
|
args = append(args, name)
|
|
}
|
|
if archived := q.Get("archived"); archived == "true" {
|
|
where = append(where, "a.archived_at IS NOT NULL")
|
|
} else {
|
|
where = append(where, "a.archived_at IS NULL")
|
|
}
|
|
if incidentID := q.Get("incident_id"); incidentID != "" {
|
|
if n, err := strconv.ParseInt(incidentID, 10, 64); err == nil {
|
|
where = append(where, "a.id IN (SELECT alert_id FROM incident_alerts WHERE incident_id = ?)")
|
|
args = append(args, n)
|
|
}
|
|
}
|
|
|
|
if from := q.Get("from"); from != "" {
|
|
if t, err := time.Parse("2006-01-02", from); err == nil {
|
|
where = append(where, "a.received_at >= ?")
|
|
args = append(args, t.UTC().Unix())
|
|
}
|
|
}
|
|
if to := q.Get("to"); to != "" {
|
|
if t, err := time.Parse("2006-01-02", to); err == nil {
|
|
where = append(where, "a.received_at < ?")
|
|
args = append(args, t.UTC().AddDate(0, 0, 1).Unix())
|
|
}
|
|
}
|
|
|
|
limit := 50
|
|
if l := q.Get("limit"); l != "" {
|
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 500 {
|
|
limit = n
|
|
}
|
|
}
|
|
|
|
clause := "1=1"
|
|
if len(where) > 0 {
|
|
clause = strings.Join(where, " AND ")
|
|
}
|
|
args = append(args, limit)
|
|
|
|
rows, err := db.QueryContext(r.Context(),
|
|
fmt.Sprintf("%s WHERE %s ORDER BY a.received_at DESC LIMIT ?", alertSelectFrom, clause),
|
|
args...)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
alerts := []models.Alert{}
|
|
for rows.Next() {
|
|
a, err := scanAlert(rows)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
alerts = append(alerts, a)
|
|
}
|
|
respond(w, http.StatusOK, alerts)
|
|
}
|
|
}
|
|
|
|
func handleGetAlert(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid alert id"))
|
|
return
|
|
}
|
|
a, err := fetchAlert(r.Context(), db, id)
|
|
if err == sql.ErrNoRows {
|
|
respond(w, http.StatusNotFound, errResp("alert not found"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, a)
|
|
}
|
|
}
|
|
|
|
// fetchAlert loads a single alert by ID using the shared query.
|
|
func fetchAlert(ctx context.Context, db *sql.DB, id int64) (models.Alert, error) {
|
|
return scanAlert(db.QueryRowContext(ctx, alertSelectFrom+" WHERE a.id = ?", id))
|
|
}
|
|
|
|
// scanner is satisfied by both *sql.Row and *sql.Rows.
|
|
type scanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanAlert(s scanner) (models.Alert, error) {
|
|
var a models.Alert
|
|
var labelsJSON, annotationsJSON string
|
|
var startsAtUnix, receivedAtUnix int64
|
|
var endsAtUnix, archivedAtUnix *int64
|
|
|
|
if err := s.Scan(
|
|
&a.ID, &a.Fingerprint, &a.Name, &a.Status,
|
|
&labelsJSON, &annotationsJSON,
|
|
&startsAtUnix, &endsAtUnix,
|
|
&a.GeneratorURL, &receivedAtUnix,
|
|
&a.IncidentID,
|
|
&a.ResolutionSource, &archivedAtUnix,
|
|
); err != nil {
|
|
return a, err
|
|
}
|
|
|
|
json.Unmarshal([]byte(labelsJSON), &a.Labels) //nolint:errcheck
|
|
json.Unmarshal([]byte(annotationsJSON), &a.Annotations) //nolint:errcheck
|
|
a.StartsAt = time.Unix(startsAtUnix, 0).UTC()
|
|
a.ReceivedAt = time.Unix(receivedAtUnix, 0).UTC()
|
|
a.EndsAt = unixPtr(endsAtUnix)
|
|
a.ArchivedAt = unixPtr(archivedAtUnix)
|
|
return a, nil
|
|
}
|