27008086b0
terdut-server v0.12 made everything team-scoped and v0.20 is what this
client now targets. Against it the old client was wrong in three ways:
the schedule moved to /api/teams/{id}/schedule, GET /api/schedule/current
became a list with one entry per team, and users, incidents, alerts and
schedule entries all grew fields the client ignored.
T steps through all teams and then each of yours. The header names what
is showing, and incident and alert rows gain a Team column when more than
one team can appear. team: in config.yaml picks the team to start on, by
name or id; an unknown one is reported and falls back to all teams.
The schedule is one team's rota, so it shows the active team, or with
all teams showing the first one you own. Writes need an owner or an
administrator, and the picker offers only the team's members, since the
server answers 404 for anybody else. Both are checked up front and the
reason goes in the status bar, rather than surfacing as a 403 after the
user has picked somebody. Stats are not team-scoped by the server and
stay that way here.
Users shows an admin/disabled Flags column. Creating and deleting users
is administrators only, and topic, keys and password work on your own
row or on anyone's for an administrator; the server enforces the same
rule, this only explains it before the round trip.
The server has no version endpoint, so an older one is recognised by
GET /api/teams answering 404, and the TUI says it needs v0.20 or later.
Connecting now also loads /api/teams and /api/me with the key, which
means a wrong key fails on start instead of on the first list; /healthz
does not check it. There is no fallback to the pre-team paths.
Rebuilding a table whose column count changes under loaded rows panicked
inside bubbles, because it re-renders the old rows on SetColumns. The
rows are now cleared first and the cursor put back, so a refresh still
does not jump to the top.
Escalation ladders, invites, integrations and the admin settings are
left to the server's web UI. Checked against a real v0.20.1 server with
two teams, an administrator and a plain member.
Breaking: requires terdut-server v0.20.0 or later. Use terdut-tui v0.9.x
with servers before v0.12.
528 lines
16 KiB
Go
528 lines
16 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
apiKey string
|
|
}
|
|
|
|
func NewClient(baseURL, apiKey string) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
apiKey: apiKey,
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (c *Client) newRequest(method, path string) (*http.Request, error) {
|
|
req, err := http.NewRequest(method, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
// StatusError is a response the server answered with a 4xx or 5xx. Message is
|
|
// the server's own {"error": ...} text, empty when the body carried none.
|
|
type StatusError struct {
|
|
Code int
|
|
Message string
|
|
}
|
|
|
|
func (e *StatusError) Error() string {
|
|
if e.Message != "" {
|
|
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
|
}
|
|
return fmt.Sprintf("server returned %d", e.Code)
|
|
}
|
|
|
|
func (c *Client) do(req *http.Request, out any) error {
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
var e struct {
|
|
Error string `json:"error"`
|
|
}
|
|
_ = json.NewDecoder(resp.Body).Decode(&e)
|
|
return &StatusError{Code: resp.StatusCode, Message: e.Error}
|
|
}
|
|
|
|
if out != nil {
|
|
return json.NewDecoder(resp.Body).Decode(out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListAlerts fetches alerts. teamID limits them to one team; 0 means every team
|
|
// the caller belongs to. status may be "firing", "resolved", or "" for all.
|
|
// Set archived=true to fetch only archived alerts; false returns only non-archived.
|
|
//
|
|
// Alerts are read-only on the server — there is nothing to acknowledge or
|
|
// archive here. This is the raw feed, useful for checking what Alertmanager is
|
|
// actually sending; the work queue is ListIncidents.
|
|
func (c *Client) ListAlerts(teamID int64, status string, archived bool, limit int) ([]Alert, error) {
|
|
q := url.Values{}
|
|
if teamID > 0 {
|
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
|
}
|
|
if status != "" {
|
|
q.Set("status", status)
|
|
}
|
|
if archived {
|
|
q.Set("archived", "true")
|
|
}
|
|
if limit > 0 {
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
path := "/api/alerts"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
|
|
req, err := c.newRequest(http.MethodGet, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var alerts []Alert
|
|
return alerts, c.do(req, &alerts)
|
|
}
|
|
|
|
// GetAlertStats fetches aggregate alert counts.
|
|
func (c *Client) GetAlertStats() (*AlertStats, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var stats AlertStats
|
|
return &stats, c.do(req, &stats)
|
|
}
|
|
|
|
func (c *Client) newRequestWithBody(method, path string, body any) (*http.Request, error) {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
return req, nil
|
|
}
|
|
|
|
func (c *Client) GetAlert(id int64) (*Alert, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/alerts/%d", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var alert Alert
|
|
return &alert, c.do(req, &alert)
|
|
}
|
|
|
|
// ── Incidents ──────────────────────────────────────────────────────────────
|
|
|
|
// ListIncidents fetches the work queue. teamID limits it to one team; 0 means
|
|
// every team the caller belongs to. status may be "triggered",
|
|
// "acknowledged", "resolved", or "" for the server default of open incidents
|
|
// only. archived and snoozed each switch the list to that set rather than
|
|
// adding to it, matching the server's filters.
|
|
func (c *Client) ListIncidents(teamID int64, status string, archived, snoozed bool, limit int) ([]Incident, error) {
|
|
q := url.Values{}
|
|
if teamID > 0 {
|
|
q.Set("team_id", strconv.FormatInt(teamID, 10))
|
|
}
|
|
if status != "" {
|
|
q.Set("status", status)
|
|
}
|
|
if archived {
|
|
q.Set("archived", "true")
|
|
}
|
|
if snoozed {
|
|
q.Set("snoozed", "true")
|
|
}
|
|
if limit > 0 {
|
|
q.Set("limit", strconv.Itoa(limit))
|
|
}
|
|
path := "/api/incidents"
|
|
if len(q) > 0 {
|
|
path += "?" + q.Encode()
|
|
}
|
|
|
|
req, err := c.newRequest(http.MethodGet, path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incidents []Incident
|
|
return incidents, c.do(req, &incidents)
|
|
}
|
|
|
|
// GetIncident returns one incident with its member alerts inline.
|
|
func (c *Client) GetIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) GetIncidentTimeline(id int64) ([]IncidentEvent, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/incidents/%d/timeline", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var events []IncidentEvent
|
|
return events, c.do(req, &events)
|
|
}
|
|
|
|
func (c *Client) AcknowledgeIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnacknowledgeIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/acknowledge", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// ResolveIncident closes an incident by hand. This is terminal on the server: a
|
|
// later occurrence in the same group opens a new incident rather than reopening
|
|
// this one, and if the alert underneath never stops firing the incident stays
|
|
// closed. Use SnoozeIncident for "not now".
|
|
func (c *Client) ResolveIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/resolve", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) AssignIncident(id, userID int64) (*Incident, error) {
|
|
body := struct {
|
|
UserID int64 `json:"user_id"`
|
|
}{UserID: userID}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/assign", id), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
// SnoozeIncident hides an incident from the default queue for a duration,
|
|
// without closing it.
|
|
func (c *Client) SnoozeIncident(id int64, duration string) (*Incident, error) {
|
|
body := struct {
|
|
Duration string `json:"duration"`
|
|
}{Duration: duration}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/incidents/%d/snooze", id), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnsnoozeIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/snooze", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) ArchiveIncident(id int64) (*Incident, error) {
|
|
req, err := c.newRequest(http.MethodPost, fmt.Sprintf("/api/incidents/%d/archive", id))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var incident Incident
|
|
return &incident, c.do(req, &incident)
|
|
}
|
|
|
|
func (c *Client) UnarchiveIncident(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/incidents/%d/archive", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// AddNote appends a note to the incident's timeline.
|
|
func (c *Client) AddNote(incidentID int64, content string) (*IncidentEvent, error) {
|
|
req, err := c.newRequestWithBody(http.MethodPost,
|
|
fmt.Sprintf("/api/incidents/%d/notes", incidentID), map[string]string{"content": content})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var event IncidentEvent
|
|
return &event, c.do(req, &event)
|
|
}
|
|
|
|
// DeleteNote removes one of your own notes. Only notes are deletable — the rest
|
|
// of the timeline is a record of what happened.
|
|
func (c *Client) DeleteNote(incidentID, eventID int64) error {
|
|
req, err := c.newRequest(http.MethodDelete,
|
|
fmt.Sprintf("/api/incidents/%d/notes/%d", incidentID, eventID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) GetIncidentStats() (*IncidentStats, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/incidents")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var stats IncidentStats
|
|
return &stats, c.do(req, &stats)
|
|
}
|
|
|
|
// ── Statistics ─────────────────────────────────────────────────────────────
|
|
|
|
func (c *Client) GetTopAlerts(limit int) ([]TopAlert, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/stats/alerts/top?limit=%d", limit))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []TopAlert
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
func (c *Client) GetStatsByHour() ([]HourStat, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-hour")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []HourStat
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
func (c *Client) GetStatsByDay() ([]DayStat, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/stats/alerts/by-day")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result []DayStat
|
|
return result, c.do(req, &result)
|
|
}
|
|
|
|
// ── Teams ──────────────────────────────────────────────────────────────────
|
|
|
|
// ListTeams returns the teams the caller belongs to, with the caller's role in
|
|
// each. Everything else the server returns is scoped to these. A server that
|
|
// predates teams (v0.12) answers 404, which is how the TUI spots one.
|
|
func (c *Client) ListTeams() ([]Team, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/teams")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var teams []Team
|
|
return teams, c.do(req, &teams)
|
|
}
|
|
|
|
// ListTeamMembers returns who belongs to a team. A schedule can only be given to
|
|
// its own members, so this is the assignee list for one.
|
|
func (c *Client) ListTeamMembers(teamID int64) ([]TeamMember, error) {
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/members", teamID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var members []TeamMember
|
|
return members, c.do(req, &members)
|
|
}
|
|
|
|
// ── Schedule ───────────────────────────────────────────────────────────────
|
|
|
|
// GetSchedule returns a team's on-call entries between two YYYY-MM-DD dates.
|
|
func (c *Client) GetSchedule(teamID int64, from, to string) ([]ScheduleEntry, error) {
|
|
q := url.Values{"from": {from}, "to": {to}}
|
|
req, err := c.newRequest(http.MethodGet, fmt.Sprintf("/api/teams/%d/schedule?%s", teamID, q.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
// GetCurrentOnCall returns today's on-call entries, one per team that has
|
|
// somebody scheduled. It is empty, not an error, when nobody is.
|
|
func (c *Client) GetCurrentOnCall() ([]ScheduleEntry, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/schedule/current")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
// AssignSchedule puts one team member on call for the given dates. Only a team
|
|
// owner or an administrator may.
|
|
//
|
|
// The server holds one person per day and refuses a date somebody already has,
|
|
// so replace is what takes a shift off its current holder. It is all-or-nothing
|
|
// either way: a week of free and taken days moves as a unit, or not at all.
|
|
func (c *Client) AssignSchedule(teamID, userID int64, dates []string, replace bool) ([]ScheduleEntry, error) {
|
|
body := struct {
|
|
UserID int64 `json:"user_id"`
|
|
Dates []string `json:"dates"`
|
|
Replace bool `json:"replace,omitempty"`
|
|
}{UserID: userID, Dates: dates, Replace: replace}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/teams/%d/schedule", teamID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var entries []ScheduleEntry
|
|
return entries, c.do(req, &entries)
|
|
}
|
|
|
|
func (c *Client) DeleteScheduleEntry(teamID, id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/teams/%d/schedule/%d", teamID, id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// ── Users ──────────────────────────────────────────────────────────────────
|
|
|
|
func (c *Client) ListUsers() ([]User, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/users")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var users []User
|
|
return users, c.do(req, &users)
|
|
}
|
|
|
|
func (c *Client) CreateUser(username, email string) (*User, error) {
|
|
body := struct {
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
}{Username: username, Email: email}
|
|
req, err := c.newRequestWithBody(http.MethodPost, "/api/users", body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var user User
|
|
return &user, c.do(req, &user)
|
|
}
|
|
|
|
// SetUserNotifyTarget points a user's push notifications at an ntfy topic.
|
|
//
|
|
// An empty topic clears it: the server stores NULL, and that user's incidents
|
|
// page the shared fallback topic instead — which carries no Acknowledge button,
|
|
// because anyone subscribed to it could otherwise acknowledge as somebody else.
|
|
func (c *Client) SetUserNotifyTarget(userID int64, topic string) (*User, error) {
|
|
body := struct {
|
|
NtfyTopic string `json:"ntfy_topic"`
|
|
}{NtfyTopic: topic}
|
|
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/notify", userID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var user User
|
|
return &user, c.do(req, &user)
|
|
}
|
|
|
|
func (c *Client) DeleteUser(id int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d", id))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
func (c *Client) CreateAPIKey(userID int64, name string) (*APIKey, error) {
|
|
body := struct {
|
|
Name string `json:"name"`
|
|
}{Name: name}
|
|
req, err := c.newRequestWithBody(http.MethodPost, fmt.Sprintf("/api/users/%d/api-keys", userID), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var key APIKey
|
|
return &key, c.do(req, &key)
|
|
}
|
|
|
|
func (c *Client) DeleteAPIKey(userID, keyID int64) error {
|
|
req, err := c.newRequest(http.MethodDelete, fmt.Sprintf("/api/users/%d/api-keys/%d", userID, keyID))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// Me returns the user the API key belongs to, and whether they have a web UI
|
|
// password.
|
|
func (c *Client) Me() (*Me, error) {
|
|
req, err := c.newRequest(http.MethodGet, "/api/me")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var me Me
|
|
return &me, c.do(req, &me)
|
|
}
|
|
|
|
// SetPassword sets a user's web UI password. current is only checked by the
|
|
// server when a user changes their own existing password; pass "" otherwise.
|
|
func (c *Client) SetPassword(userID int64, password, current string) error {
|
|
body := struct {
|
|
Password string `json:"password"`
|
|
CurrentPassword string `json:"current_password,omitempty"`
|
|
}{Password: password, CurrentPassword: current}
|
|
req, err := c.newRequestWithBody(http.MethodPut, fmt.Sprintf("/api/users/%d/password", userID), body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.do(req, nil)
|
|
}
|
|
|
|
// HealthCheck calls GET /healthz, which is unauthenticated and does no database
|
|
// check, so it says the process is up, not that the API key works.
|
|
func (c *Client) HealthCheck() error {
|
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/healthz", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("health check failed: %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|