Compare commits

...

6 Commits

Author SHA1 Message Date
Niklas Ye ead5df1574 Set the chart's placeholder version to 0.40.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 24s
CI / test (push) Successful in 5m47s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 31s
Release / image (push) Successful in 1m7s
Release / scan-image (push) Successful in 26s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 0f88574 (0.39.0) and 584d344 (0.38.0) before it, because a
tree heading for v0.40.0 that still says 0.39.0 tells its reader
something false.
2026-10-08 11:46:09 +02:00
Niklas Ye 3ced069134 Open switches, sources and members in a details sheet
The three team lists carried bare buttons on every row (Remove, Rename,
Revoke, Edit), which crowds a row that is meant for scanning and puts a
destructive action one stray click from every entry. A row now opens a
sheet with the facts the row has no room for, and the actions live
there: Edit and Delete for a switch, Rename and Revoke for a source,
Edit and Remove for a member. The guards are unchanged: owners only, the
last owner and SSO-managed members still cannot be removed, and every
destructive action still asks first.

Switches can finally be edited in place. The PUT endpoint has existed
since the operator needed it; the UI simply never called it, so changing
a timeout meant deleting the switch and losing its history. The edit
form is the add form, prefilled.

The first column of all three lists is now headed Name. Web UI only: no
change to any endpoint or JSON shape, so nothing to mirror in the TUI.
2026-10-08 11:46:09 +02:00
Niklas Ye 0f88574a41 Set the chart's placeholder version to 0.39.0
CI / chart (push) Successful in 4s
CI / security (push) Successful in 45s
CI / test (push) Successful in 6m1s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m12s
Release / scan-image (push) Successful in 6s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 584d344 (0.38.0) and df83adf (0.37.2) before it, because a
tree heading for v0.39.0 that still says 0.38.0 tells its reader
something false.
2026-10-08 09:02:07 +02:00
Niklas Ye 3613fd5732 Check rows.Err() in the remaining Next() loops (#27)
Incident list, the three stats breakdowns and the user list could return a
truncated result as if complete when the scan failed partway.
2026-10-08 09:00:54 +02:00
Niklas Ye dc62278788 Check rows.Err() after the timeline scan loop (#27) 2026-10-08 08:58:19 +02:00
Niklas Ye 0aaea8efb5 Record the actor on assign, archive and unarchive (#35)
Assign logged only the assignee (user_id), archive/unarchive logged nothing.
Migration 018 adds actor_user_id/actor_service_account_id to incident_events
for 'assigned'; archive/unarchive now log archived/unarchived events with the
caller via callerActorIDs. Timeline JSON gains actor_* fields; web timeline
renders them. Service accounts are still not assignable.
2026-10-08 08:53:04 +02:00
13 changed files with 400 additions and 105 deletions
+5 -3
View File
@@ -984,9 +984,11 @@ name: degrade unknown values to "resolved, reason unknown".
| `created_at` | timestamp | |
Types written today: `triggered`, `alert_added`, `alert_resolved`,
`acknowledged`, `unacknowledged`, `assigned`, `snoozed`, `unsnoozed`, `resolved`,
`note`, `notified`, `notify_failed`, `deadman_silent`. On an `assigned` event
`user_id` is the **assignee**, not the actor. New types may be added; render
`acknowledged`, `unacknowledged`, `assigned`, `archived`, `unarchived`, `snoozed`,
`unsnoozed`, `resolved`, `note`, `notified`, `notify_failed`, `deadman_silent`. On an
`assigned` event `user_id` is the **assignee**, not the actor; the actor is in
`actor_user_id`/`actor_username` or `actor_service_account_id`/`actor_service_account_name`
(absent on assignments made before they were recorded). New types may be added; render
unknown ones generically rather than dropping them.
On `notified` and `notify_failed`, `detail` carries the notification kind
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing.
version: 0.38.0
appVersion: "v0.38.0"
version: 0.40.0
appVersion: "v0.40.0"
+13
View File
@@ -32,6 +32,8 @@ const (
evAcknowledged = "acknowledged"
evUnacknowledged = "unacknowledged"
evAssigned = "assigned"
evArchived = "archived"
evUnarchived = "unarchived"
evSnoozed = "snoozed"
evUnsnoozed = "unsnoozed"
evResolved = "resolved"
@@ -143,6 +145,17 @@ func logEvent(ctx context.Context, q querier, incidentID int64, evType string, u
return err
}
// logAssignedEvent records an assignment: user_id is the assignee, and the
// caller who performed it goes in the actor_* columns (migration 018), since
// user_id cannot hold both.
func logAssignedEvent(ctx context.Context, q querier, incidentID, assigneeID int64, actorUserID, actorServiceAccountID *int64) error {
_, err := q.ExecContext(ctx, `
INSERT INTO incident_events (incident_id, type, user_id, actor_user_id, actor_service_account_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
incidentID, evAssigned, assigneeID, actorUserID, actorServiceAccountID, time.Now().Unix())
return err
}
// todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day.
func todayUTC() string {
return time.Now().UTC().Format("2006-01-02")
+28 -2
View File
@@ -100,6 +100,10 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
}
incidents = append(incidents, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, incidents)
}
}
@@ -158,10 +162,14 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), `
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
e.service_account_id, sa.name,
e.actor_user_id, au.username,
e.actor_service_account_id, asa.name,
e.alert_id, e.detail, e.created_at
FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
LEFT JOIN users au ON au.id = e.actor_user_id
LEFT JOIN service_accounts asa ON asa.id = e.actor_service_account_id
WHERE e.incident_id = $1
ORDER BY e.created_at ASC, e.id ASC`, id)
if err != nil {
@@ -176,6 +184,8 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
var ts int64
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
&e.ServiceAccountID, &e.ServiceAccountName,
&e.ActorUserID, &e.ActorUsername,
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
&e.AlertID, &e.Detail, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
@@ -183,6 +193,10 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
e.CreatedAt = time.Unix(ts, 0).UTC()
events = append(events, e)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, events)
}
}
@@ -315,8 +329,10 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
req.UserID, id) {
return
}
// On an "assigned" event user_id is the assignee, not the actor.
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil, nil); err != nil {
// On an "assigned" event user_id is the assignee; the actor goes in
// the actor_* columns.
actorUserID, actorSAID := callerActorIDs(r.Context())
if err := logAssignedEvent(r.Context(), db, id, req.UserID, actorUserID, actorSAID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
@@ -417,6 +433,11 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusNotFound, errResp("incident not found"))
return
}
userID, saID := callerActorIDs(r.Context())
if err := logEvent(r.Context(), db, id, evArchived, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respondIncident(w, r, db, id)
}
}
@@ -437,6 +458,11 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusNotFound, errResp("incident not found"))
return
}
userID, saID := callerActorIDs(r.Context())
if err := logEvent(r.Context(), db, id, evUnarchived, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
+102
View File
@@ -870,3 +870,105 @@ func contains(haystack []string, needle string) bool {
}
return false
}
// ---------------------------------------------------------------------------
// Actor on assign / archive / unarchive (terdut-server#35)
// ---------------------------------------------------------------------------
// lastEvent returns the newest timeline event of the given type.
func lastEvent(t *testing.T, events []map[string]any, typ string) map[string]any {
t.Helper()
for i := len(events) - 1; i >= 0; i-- {
if events[i]["type"] == typ {
return events[i]
}
}
t.Fatalf("no %q event in %v", typ, eventTypes(events))
return nil
}
func TestIncident_AssignRecordsHumanActor(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-asg-actor", "Assignable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 2}).Body.Close()
ev := lastEvent(t, timeline(t, s, 1), "assigned")
if ev["username"] != "alice" {
t.Errorf("expected the assignee alice in username, got %v", ev["username"])
}
if ev["actor_user_id"] == nil || ev["actor_username"] == nil {
t.Errorf("expected the assigning human in actor_*, got %v", ev)
}
if ev["actor_service_account_id"] != nil {
t.Errorf("expected no service-account actor, got %v", ev["actor_service_account_id"])
}
}
func TestIncident_ArchiveUnarchiveRecordHumanActor(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-arc-actor", "Archivable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
s.req(t, http.MethodDelete, "/api/incidents/1/archive", nil).Body.Close()
events := timeline(t, s, 1)
for _, typ := range []string{"archived", "unarchived"} {
ev := lastEvent(t, events, typ)
if ev["user_id"] == nil || ev["service_account_id"] != nil {
t.Errorf("%s: expected only the human actor, got %v", typ, ev)
}
}
}
func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
var integration struct {
Key string `json:"key"`
}
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
map[string]string{"name": "test"}), &integration)
postToIntegration(t, s, integration.Key, "fp-sa-35", "SA35") // incident 1
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 1})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("service account assign: %d", resp.StatusCode)
}
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
resp.Body.Close()
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/archive", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("service account archive: %d", resp.StatusCode)
}
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/archive", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("service account unarchive: %d", resp.StatusCode)
}
var events []map[string]any
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/incidents/1/timeline", nil), &events)
asg := lastEvent(t, events, "assigned")
if asg["actor_service_account_id"] == nil || asg["actor_user_id"] != nil {
t.Errorf("assigned: expected only the service-account actor, got %v", asg)
}
if asg["user_id"] == nil {
t.Errorf("assigned: user_id must stay the assignee, got %v", asg)
}
for _, typ := range []string{"archived", "unarchived"} {
ev := lastEvent(t, events, typ)
if ev["service_account_id"] == nil || ev["user_id"] != nil {
t.Errorf("%s: expected only the service-account actor, got %v", typ, ev)
}
}
}
+12
View File
@@ -73,6 +73,10 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
}
result = append(result, e)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, result)
}
}
@@ -104,6 +108,10 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
}
counts[hr] = cnt
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
type entry struct {
Hour int `json:"hour"`
@@ -146,6 +154,10 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
}
counts[dow] = cnt
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
type entry struct {
+4
View File
@@ -116,6 +116,10 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
u.DisabledAt = unixPtr(disabled)
users = append(users, u)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, users)
}
}
@@ -0,0 +1,21 @@
-- Who performed an assignment (terdut-server#35). On an 'assigned' event
-- incident_events.user_id is the assignee, so the actor needs columns of its
-- own. Only populated for 'assigned' events; every other event type keeps
-- using user_id/service_account_id for the actor. Older 'assigned' rows stay
-- NULL (the actor was never recorded). Same shape as migration 015: nullable,
-- mutually exclusive, ON DELETE SET NULL.
--
-- assigned_to_service_account_id is still deliberately not added: making
-- service accounts assignable is a separate change (request body, assignee
-- picker, notifier, filters).
ALTER TABLE incident_events
ADD COLUMN actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN actor_service_account_id BIGINT REFERENCES service_accounts(id) ON DELETE SET NULL;
ALTER TABLE incident_events
ADD CONSTRAINT incident_events_assign_actor_xor_chk CHECK (
actor_user_id IS NULL OR actor_service_account_id IS NULL
);
CREATE INDEX incident_events_actor_user_id_idx ON incident_events(actor_user_id);
CREATE INDEX incident_events_actor_service_account_id_idx ON incident_events(actor_service_account_id);
+10 -1
View File
@@ -74,7 +74,8 @@ type Incident struct {
// and is the only history this server keeps — alert rows are mutated in place.
//
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. UserID and
// unacknowledged, assigned, archived, unarchived, snoozed, unsnoozed,
// resolved, note. UserID and
// ServiceAccountID are mutually exclusive; both nil means the server acted
// rather than any caller.
type IncidentEvent struct {
@@ -92,6 +93,14 @@ type IncidentEvent struct {
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
ServiceAccountName *string `json:"service_account_name,omitempty"`
// Actor* name who performed an 'assigned' event, whose UserID is the
// assignee. Mutually exclusive; unset on every other event type and on
// assignments made before migration 018. See terdut-server#35.
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorUsername *string `json:"actor_username,omitempty"`
ActorServiceAccountID *int64 `json:"actor_service_account_id,omitempty"`
ActorServiceAccountName *string `json:"actor_service_account_name,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"`
Detail *string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"`
+5
View File
@@ -845,6 +845,11 @@ kbd {
.status-table th, .status-table td { white-space: nowrap; }
.status-table td.wrap { white-space: normal; min-width: 12em; }
.status-table .source-row td { border-bottom-style: dashed; }
.status-table tr.clickable { cursor: pointer; }
.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); }
.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; }
.switch-facts dt { opacity: .7; }
.switch-facts dd { margin: 0; }
.status-table .source-row td:first-child { padding-left: 16px; }
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
+2
View File
@@ -150,6 +150,8 @@ export const deleteIntegration = (id, integrationID) =>
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
export const createDeadmanSwitch = (id, body) =>
call('POST', `/teams/${id}/deadman/switches`, { body });
export const updateDeadmanSwitch = (id, switchID, body) =>
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
export const deleteDeadmanSwitch = (id, switchID) =>
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
+15 -1
View File
@@ -225,6 +225,15 @@ function actorLabel(ev, named = false) {
return null;
}
// assignerLabel is actorLabel for an 'assigned' event, whose user_id is the
// assignee: the person who made the assignment is in the actor_* fields
// (null for assignments from before they were recorded).
function assignerLabel(ev, named = false) {
if (ev.actor_user_id != null) return named ? (ev.actor_username || 'someone') : who(ev.actor_user_id, ev.actor_username);
if (ev.actor_service_account_id != null) return ev.actor_service_account_name || 'a service account';
return null;
}
// named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) {
const person = actorLabel(ev, named);
@@ -239,7 +248,12 @@ function eventText(ev, named = false) {
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
case 'acknowledged': return [strong(person), ' acknowledged'];
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
case 'assigned': return ['Assigned to ', strong(person)];
case 'assigned': {
const by = assignerLabel(ev, named);
return by ? ['Assigned to ', strong(person), ' by ', strong(by)] : ['Assigned to ', strong(person)];
}
case 'archived': return [strong(person), ' archived the incident'];
case 'unarchived': return [strong(person), ' unarchived the incident'];
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
case 'unsnoozed': return [strong(person), ' ended the snooze'];
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
+181 -96
View File
@@ -735,36 +735,19 @@ function minutesInput(seconds, onChange) {
// --- integrations ----------------------------------------------------------
function integrationsCard() {
const rows = (data.integrations || []).map((i) =>
h('tr', {},
h('td', {}, sourceBadge(i.status)),
h('td', { class: 'wrap' },
h('strong', { text: i.name }),
h('div', { class: 'muted small', text: i.kind })),
// When the key last posted, and when an alert last arrived on it. They
// differ: a payload with nothing usable in it stamps only the first.
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
text: String(i.alerts_24h ?? 0) }),
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: async () => {
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
}))),
));
const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
h('td', {}, sourceBadge(i.status)),
h('td', { class: 'wrap' },
h('strong', { text: i.name }),
h('div', { class: 'muted small', text: i.kind })),
// When the key last posted, and when an alert last arrived on it. They
// differ: a payload with nothing usable in it stamps only the first.
h('td', { class: 'muted small' }, timeCell(i.last_used_at)),
h('td', { class: 'muted small' }, timeCell(i.last_alert_at)),
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
text: String(i.alerts_24h ?? 0) }),
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
), () => openSourceDetail(i)));
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
@@ -780,9 +763,9 @@ function integrationsCard() {
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Source' }),
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))),
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
);
@@ -844,6 +827,34 @@ function openNameSheet({ title, submit, value, run }) {
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
}
function openSourceDetail(i) {
openDetailSheet(i.name, [
sheetFact('Status', sourceBadge(i.status)),
sheetFact('Kind', i.kind),
sheetFact('Last webhook', timeCell(i.last_used_at)),
sheetFact('Last alert', timeCell(i.last_alert_at)),
sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)),
sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })),
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Rename',
onclick: () => { closeSheet(); openRenameSource(i); },
}),
isOwner() && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Revoke',
onclick: async () => {
closeSheet();
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
}));
}
function openNewSource() {
openNameSheet({
title: 'New source', submit: 'Add source', value: '',
@@ -899,6 +910,34 @@ const triggeredCell = (iso, incidentID) => {
: h('span', { title: when(iso), text: ago(iso) });
};
// A list row that opens its details: the list is for finding the thing, the
// sheet it opens is where the buttons are. Links inside the row keep working.
function clickableRow(tr, open) {
tr.classList.add('clickable');
tr.tabIndex = 0;
tr.addEventListener('click', (e) => {
if (!e.target.closest('a')) open();
});
tr.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target === tr) open();
});
return tr;
}
// Label/value rows for a details sheet, and the sheet itself: facts above, then
// Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out.
const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)];
function openDetailSheet(title, facts, ...actions) {
openSheet(() => [
h('h2', { class: 'sheet-title', text: title }),
h('dl', { class: 'switch-facts' }, facts),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }),
...actions),
]);
}
function switchRows(sw) {
const main = h('tr', {},
h('td', {}, switchBadge(sw.status)),
@@ -908,19 +947,8 @@ function switchRows(sw) {
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
if (!(await confirm({
title: `Remove ${sw.name}?`,
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
},
})),
);
clickableRow(main, () => openSwitchDetail(sw));
// One heartbeat is the switch's own times; several are worth telling apart,
// since a live cluster must not hide a dead one.
@@ -935,7 +963,7 @@ function switchRows(sw) {
&& h('code', { class: 'small', text: src.fingerprint })),
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
h('td'), h('td')))
h('td')))
: [];
return [main, ...sources];
}
@@ -947,7 +975,7 @@ function deadmanCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch,
class: 'btn', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
})),
h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
@@ -956,9 +984,9 @@ function deadmanCard() {
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Switch' }),
h('th', { text: 'Status' }), h('th', { text: 'Name' }),
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
h('th', { text: 'Silent after' }), h('th'))),
h('th', { text: 'Silent after' }))),
h('tbody', {}, switches.flatMap(switchRows))))
: h('p', { class: 'muted', text: 'Nothing watched.' }),
);
@@ -966,16 +994,22 @@ function deadmanCard() {
// The form lives in the sheet, not on the page: most visits are to look at the
// list, and a form that is always open is the page this replaced.
function openNewSwitch() {
function openSwitchForm(existing) {
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
const matcher = h('input', {
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
});
const timeout = h('input', {
type: 'number', min: '1', value: '15', class: 'setting-value', required: true,
type: 'number', min: '1', step: 'any', value: '15', class: 'setting-value', required: true,
});
const severity = h('select', {},
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
if (existing) {
name.value = existing.name;
matcher.value = existing.matcher;
timeout.value = String(existing.timeout_seconds / 60);
severity.value = existing.severity;
}
const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' },
@@ -990,17 +1024,20 @@ function openNewSwitch() {
problem,
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' })));
h('button', { class: 'btn btn-primary', type: 'submit', text: existing ? 'Save' : 'Add switch' })));
form.addEventListener('submit', async (e) => {
e.preventDefault();
try {
await api.createDeadmanSwitch(teamID, {
const body = {
name: name.value.trim(),
matcher: matcher.value.trim(),
timeout_seconds: Math.round(Number(timeout.value) * 60),
severity: severity.value,
});
};
await (existing
? api.updateDeadmanSwitch(teamID, existing.id, body)
: api.createDeadmanSwitch(teamID, body));
} catch (err) {
problem.textContent = err.message;
problem.hidden = false;
@@ -1010,7 +1047,47 @@ function openNewSwitch() {
refresh();
});
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]);
openSheet(() => [
h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]);
}
// What the list row has no room for, and where Edit and Delete live.
function openSwitchDetail(sw) {
const sources = sw.sources.length > 1
? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' },
switchBadge(src.status),
...Object.entries(src.labels || {})
.filter(([k]) => k !== 'alertname')
.map(([k, v]) => labelChip(k, v)),
h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))]
: [];
openDetailSheet(sw.name, [
sheetFact('Status', switchBadge(sw.status)),
sheetFact('Matcher', h('code', { text: sw.matcher })),
sheetFact('Silent after', duration(sw.timeout_seconds * 1000)),
sheetFact('Severity', sw.severity),
sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)),
sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
sources,
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit',
onclick: () => { closeSheet(); openSwitchForm(sw); },
}),
isOwner() && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Delete',
onclick: async () => {
closeSheet();
if (!(await confirm({
title: `Remove ${sw.name}?`,
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
},
}));
}
// --- members ---------------------------------------------------------------
@@ -1114,45 +1191,18 @@ function membersCard() {
const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length;
const rows = members.map((m) => {
const lastOwner = m.role === 'owner' && owners === 1;
return h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
m.problem && h('div', { class: 'target-problem', text: m.problem })),
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
h('td', { class: 'muted small' }, shiftCell(m)),
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
h('button', {
class: 'btn-sm', type: 'button', text: 'Edit',
// The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => openEditMember(m),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
disabled: lastOwner || m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => {
if (!(await confirm({
title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not '
+ 'changed, so reassign those from the Rota tab.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}))),
);
});
const rows = members.map((m) => clickableRow(h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
m.user_id === myID() && h('span', { class: 'muted small', text: ' (you)' }),
m.problem && h('div', { class: 'target-problem', text: m.problem })),
h('td', { class: 'muted small' }, m.role, m.source === 'oidc' && ssoBadge()),
h('td', { class: 'muted small' }, shiftCell(m)),
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
), () => openMemberDetail(m, owners)));
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
@@ -1167,14 +1217,49 @@ function membersCard() {
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
h('th'))),
h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
)];
}
function openMemberDetail(m, owners) {
const lastOwner = m.role === 'owner' && owners === 1;
openDetailSheet(m.username, [
sheetFact('Status', memberBadge(m)),
sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())),
sheetFact('Rota', shiftCell(m)),
sheetFact('Last active', timeCell(m.last_active_at)),
sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })),
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit',
// The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => { closeSheet(); openEditMember(m); },
}),
isOwner() && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Remove',
disabled: lastOwner || m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => {
closeSheet();
if (!(await confirm({
title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not '
+ 'changed, so reassign those from the Rota tab.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}));
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
// choosing a person and a role; editing is the same with the person fixed. The
// API is one call either way — POST upserts the role.