Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ead5df1574 | |||
| 3ced069134 | |||
| 0f88574a41 | |||
| 3613fd5732 | |||
| dc62278788 | |||
| 0aaea8efb5 |
@@ -984,9 +984,11 @@ name: degrade unknown values to "resolved, reason unknown".
|
|||||||
| `created_at` | timestamp | |
|
| `created_at` | timestamp | |
|
||||||
|
|
||||||
Types written today: `triggered`, `alert_added`, `alert_resolved`,
|
Types written today: `triggered`, `alert_added`, `alert_resolved`,
|
||||||
`acknowledged`, `unacknowledged`, `assigned`, `snoozed`, `unsnoozed`, `resolved`,
|
`acknowledged`, `unacknowledged`, `assigned`, `archived`, `unarchived`, `snoozed`,
|
||||||
`note`, `notified`, `notify_failed`, `deadman_silent`. On an `assigned` event
|
`unsnoozed`, `resolved`, `note`, `notified`, `notify_failed`, `deadman_silent`. On an
|
||||||
`user_id` is the **assignee**, not the actor. New types may be added; render
|
`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.
|
unknown ones generically rather than dropping them.
|
||||||
|
|
||||||
On `notified` and `notify_failed`, `detail` carries the notification kind
|
On `notified` and `notify_failed`, `detail` carries the notification kind
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# 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
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.38.0
|
version: 0.40.0
|
||||||
appVersion: "v0.38.0"
|
appVersion: "v0.40.0"
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ const (
|
|||||||
evAcknowledged = "acknowledged"
|
evAcknowledged = "acknowledged"
|
||||||
evUnacknowledged = "unacknowledged"
|
evUnacknowledged = "unacknowledged"
|
||||||
evAssigned = "assigned"
|
evAssigned = "assigned"
|
||||||
|
evArchived = "archived"
|
||||||
|
evUnarchived = "unarchived"
|
||||||
evSnoozed = "snoozed"
|
evSnoozed = "snoozed"
|
||||||
evUnsnoozed = "unsnoozed"
|
evUnsnoozed = "unsnoozed"
|
||||||
evResolved = "resolved"
|
evResolved = "resolved"
|
||||||
@@ -143,6 +145,17 @@ func logEvent(ctx context.Context, q querier, incidentID int64, evType string, u
|
|||||||
return err
|
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.
|
// todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day.
|
||||||
func todayUTC() string {
|
func todayUTC() string {
|
||||||
return time.Now().UTC().Format("2006-01-02")
|
return time.Now().UTC().Format("2006-01-02")
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
incidents = append(incidents, i)
|
incidents = append(incidents, i)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respond(w, http.StatusOK, incidents)
|
respond(w, http.StatusOK, incidents)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,10 +162,14 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
|||||||
rows, err := db.QueryContext(r.Context(), `
|
rows, err := db.QueryContext(r.Context(), `
|
||||||
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
|
SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
|
||||||
e.service_account_id, sa.name,
|
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
|
e.alert_id, e.detail, e.created_at
|
||||||
FROM incident_events e
|
FROM incident_events e
|
||||||
LEFT JOIN users u ON u.id = e.user_id
|
LEFT JOIN users u ON u.id = e.user_id
|
||||||
LEFT JOIN service_accounts sa ON sa.id = e.service_account_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
|
WHERE e.incident_id = $1
|
||||||
ORDER BY e.created_at ASC, e.id ASC`, id)
|
ORDER BY e.created_at ASC, e.id ASC`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,6 +184,8 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
|||||||
var ts int64
|
var ts int64
|
||||||
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
|
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
|
||||||
&e.ServiceAccountID, &e.ServiceAccountName,
|
&e.ServiceAccountID, &e.ServiceAccountName,
|
||||||
|
&e.ActorUserID, &e.ActorUsername,
|
||||||
|
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
|
||||||
&e.AlertID, &e.Detail, &ts); err != nil {
|
&e.AlertID, &e.Detail, &ts); err != nil {
|
||||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
@@ -183,6 +193,10 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
|
|||||||
e.CreatedAt = time.Unix(ts, 0).UTC()
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
||||||
events = append(events, e)
|
events = append(events, e)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respond(w, http.StatusOK, events)
|
respond(w, http.StatusOK, events)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,8 +329,10 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
|
|||||||
req.UserID, id) {
|
req.UserID, id) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// On an "assigned" event user_id is the assignee, not the actor.
|
// On an "assigned" event user_id is the assignee; the actor goes in
|
||||||
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil, nil); err != nil {
|
// 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"))
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -417,6 +433,11 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
return
|
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)
|
respondIncident(w, r, db, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,6 +458,11 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
|
|||||||
respond(w, http.StatusNotFound, errResp("incident not found"))
|
respond(w, http.StatusNotFound, errResp("incident not found"))
|
||||||
return
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -870,3 +870,105 @@ func contains(haystack []string, needle string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
result = append(result, e)
|
result = append(result, e)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respond(w, http.StatusOK, result)
|
respond(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +108,10 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
counts[hr] = cnt
|
counts[hr] = cnt
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Hour int `json:"hour"`
|
Hour int `json:"hour"`
|
||||||
@@ -146,6 +154,10 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
counts[dow] = cnt
|
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"}
|
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
|
||||||
type entry struct {
|
type entry struct {
|
||||||
|
|||||||
@@ -116,6 +116,10 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
u.DisabledAt = unixPtr(disabled)
|
u.DisabledAt = unixPtr(disabled)
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respond(w, http.StatusOK, users)
|
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);
|
||||||
@@ -74,7 +74,8 @@ type Incident struct {
|
|||||||
// and is the only history this server keeps — alert rows are mutated in place.
|
// and is the only history this server keeps — alert rows are mutated in place.
|
||||||
//
|
//
|
||||||
// Type is one of: triggered, alert_added, alert_resolved, acknowledged,
|
// 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
|
// ServiceAccountID are mutually exclusive; both nil means the server acted
|
||||||
// rather than any caller.
|
// rather than any caller.
|
||||||
type IncidentEvent struct {
|
type IncidentEvent struct {
|
||||||
@@ -92,6 +93,14 @@ type IncidentEvent struct {
|
|||||||
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
|
ServiceAccountID *int64 `json:"service_account_id,omitempty"`
|
||||||
ServiceAccountName *string `json:"service_account_name,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"`
|
AlertID *int64 `json:"alert_id,omitempty"`
|
||||||
Detail *string `json:"detail,omitempty"`
|
Detail *string `json:"detail,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
@@ -845,6 +845,11 @@ kbd {
|
|||||||
.status-table th, .status-table td { white-space: nowrap; }
|
.status-table th, .status-table td { white-space: nowrap; }
|
||||||
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
.status-table td.wrap { white-space: normal; min-width: 12em; }
|
||||||
.status-table .source-row td { border-bottom-style: dashed; }
|
.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; }
|
.status-table .source-row td:first-child { padding-left: 16px; }
|
||||||
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,8 @@ export const deleteIntegration = (id, integrationID) =>
|
|||||||
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
|
||||||
export const createDeadmanSwitch = (id, body) =>
|
export const createDeadmanSwitch = (id, body) =>
|
||||||
call('POST', `/teams/${id}/deadman/switches`, { 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) =>
|
export const deleteDeadmanSwitch = (id, switchID) =>
|
||||||
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
|
||||||
|
|
||||||
|
|||||||
@@ -225,6 +225,15 @@ function actorLabel(ev, named = false) {
|
|||||||
return null;
|
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.
|
// named spells users out instead of "you", for text that leaves this page.
|
||||||
function eventText(ev, named = false) {
|
function eventText(ev, named = false) {
|
||||||
const person = actorLabel(ev, named);
|
const person = actorLabel(ev, named);
|
||||||
@@ -239,7 +248,12 @@ function eventText(ev, named = false) {
|
|||||||
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
|
case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
|
||||||
case 'acknowledged': return [strong(person), ' acknowledged'];
|
case 'acknowledged': return [strong(person), ' acknowledged'];
|
||||||
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
|
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 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
||||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||||
|
|||||||
+160
-75
@@ -735,8 +735,7 @@ function minutesInput(seconds, onChange) {
|
|||||||
// --- integrations ----------------------------------------------------------
|
// --- integrations ----------------------------------------------------------
|
||||||
|
|
||||||
function integrationsCard() {
|
function integrationsCard() {
|
||||||
const rows = (data.integrations || []).map((i) =>
|
const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
|
||||||
h('tr', {},
|
|
||||||
h('td', {}, sourceBadge(i.status)),
|
h('td', {}, sourceBadge(i.status)),
|
||||||
h('td', { class: 'wrap' },
|
h('td', { class: 'wrap' },
|
||||||
h('strong', { text: i.name }),
|
h('strong', { text: i.name }),
|
||||||
@@ -748,23 +747,7 @@ function integrationsCard() {
|
|||||||
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
|
||||||
text: String(i.alerts_24h ?? 0) }),
|
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', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
|
||||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
), () => openSourceDetail(i)));
|
||||||
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));
|
|
||||||
},
|
|
||||||
}))),
|
|
||||||
));
|
|
||||||
|
|
||||||
return h('div', { class: 'card' },
|
return h('div', { class: 'card' },
|
||||||
h('div', { class: 'card-head' },
|
h('div', { class: 'card-head' },
|
||||||
@@ -780,9 +763,9 @@ function integrationsCard() {
|
|||||||
? h('div', { class: 'table-scroll' },
|
? h('div', { class: 'table-scroll' },
|
||||||
h('table', { class: 'admin-table status-table' },
|
h('table', { class: 'admin-table status-table' },
|
||||||
h('thead', {}, h('tr', {},
|
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', { 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('tbody', {}, rows)))
|
||||||
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
: 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]);
|
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() {
|
function openNewSource() {
|
||||||
openNameSheet({
|
openNameSheet({
|
||||||
title: 'New source', submit: 'Add source', value: '',
|
title: 'New source', submit: 'Add source', value: '',
|
||||||
@@ -899,6 +910,34 @@ const triggeredCell = (iso, incidentID) => {
|
|||||||
: h('span', { title: when(iso), text: ago(iso) });
|
: 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) {
|
function switchRows(sw) {
|
||||||
const main = h('tr', {},
|
const main = h('tr', {},
|
||||||
h('td', {}, switchBadge(sw.status)),
|
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' }, timeCell(sw.last_heartbeat_at)),
|
||||||
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
|
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', { 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,
|
// One heartbeat is the switch's own times; several are worth telling apart,
|
||||||
// since a live cluster must not hide a dead one.
|
// 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('code', { class: 'small', text: src.fingerprint })),
|
||||||
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
|
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', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
|
||||||
h('td'), h('td')))
|
h('td')))
|
||||||
: [];
|
: [];
|
||||||
return [main, ...sources];
|
return [main, ...sources];
|
||||||
}
|
}
|
||||||
@@ -947,7 +975,7 @@ function deadmanCard() {
|
|||||||
h('div', { class: 'card-head' },
|
h('div', { class: 'card-head' },
|
||||||
h('h2', { text: 'Dead man’s switches' }),
|
h('h2', { text: 'Dead man’s switches' }),
|
||||||
isOwner() && h('button', {
|
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' },
|
h('p', { class: 'muted small' },
|
||||||
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||||||
@@ -956,9 +984,9 @@ function deadmanCard() {
|
|||||||
? h('div', { class: 'table-scroll' },
|
? h('div', { class: 'table-scroll' },
|
||||||
h('table', { class: 'admin-table status-table' },
|
h('table', { class: 'admin-table status-table' },
|
||||||
h('thead', {}, h('tr', {},
|
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: '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('tbody', {}, switches.flatMap(switchRows))))
|
||||||
: h('p', { class: 'muted', text: 'Nothing watched.' }),
|
: 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
|
// 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.
|
// 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 name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
|
||||||
const matcher = h('input', {
|
const matcher = h('input', {
|
||||||
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
|
||||||
});
|
});
|
||||||
const timeout = h('input', {
|
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', {},
|
const severity = h('select', {},
|
||||||
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
|
...['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 problem = h('p', { class: 'load-error', hidden: true });
|
||||||
|
|
||||||
const form = h('form', { class: 'stacked-form' },
|
const form = h('form', { class: 'stacked-form' },
|
||||||
@@ -990,17 +1024,20 @@ function openNewSwitch() {
|
|||||||
problem,
|
problem,
|
||||||
h('div', { class: 'sheet-actions' },
|
h('div', { class: 'sheet-actions' },
|
||||||
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
|
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) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
await api.createDeadmanSwitch(teamID, {
|
const body = {
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
matcher: matcher.value.trim(),
|
matcher: matcher.value.trim(),
|
||||||
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
timeout_seconds: Math.round(Number(timeout.value) * 60),
|
||||||
severity: severity.value,
|
severity: severity.value,
|
||||||
});
|
};
|
||||||
|
await (existing
|
||||||
|
? api.updateDeadmanSwitch(teamID, existing.id, body)
|
||||||
|
: api.createDeadmanSwitch(teamID, body));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
problem.textContent = err.message;
|
problem.textContent = err.message;
|
||||||
problem.hidden = false;
|
problem.hidden = false;
|
||||||
@@ -1010,7 +1047,47 @@ function openNewSwitch() {
|
|||||||
refresh();
|
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 ---------------------------------------------------------------
|
// --- members ---------------------------------------------------------------
|
||||||
@@ -1114,9 +1191,7 @@ function membersCard() {
|
|||||||
const members = data.members || [];
|
const members = data.members || [];
|
||||||
const owners = members.filter((m) => m.role === 'owner').length;
|
const owners = members.filter((m) => m.role === 'owner').length;
|
||||||
|
|
||||||
const rows = members.map((m) => {
|
const rows = members.map((m) => clickableRow(h('tr', {},
|
||||||
const lastOwner = m.role === 'owner' && owners === 1;
|
|
||||||
return h('tr', {},
|
|
||||||
h('td', {}, memberBadge(m)),
|
h('td', {}, memberBadge(m)),
|
||||||
h('td', { class: 'wrap' },
|
h('td', { class: 'wrap' },
|
||||||
h('strong', { text: m.username }),
|
h('strong', { text: m.username }),
|
||||||
@@ -1127,32 +1202,7 @@ function membersCard() {
|
|||||||
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
|
||||||
h('td', { class: 'muted small' },
|
h('td', { class: 'muted small' },
|
||||||
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
|
||||||
h('td', {}, isOwner() && h('div', { class: 'row-actions' },
|
), () => openMemberDetail(m, owners)));
|
||||||
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));
|
|
||||||
},
|
|
||||||
}))),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return [oidcGroupsCard(), h('div', { class: 'card' },
|
return [oidcGroupsCard(), h('div', { class: 'card' },
|
||||||
h('div', { class: 'card-head' },
|
h('div', { class: 'card-head' },
|
||||||
@@ -1167,14 +1217,49 @@ function membersCard() {
|
|||||||
? h('div', { class: 'table-scroll' },
|
? h('div', { class: 'table-scroll' },
|
||||||
h('table', { class: 'admin-table status-table' },
|
h('table', { class: 'admin-table status-table' },
|
||||||
h('thead', {}, h('tr', {},
|
h('thead', {}, h('tr', {},
|
||||||
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
|
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('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))),
|
||||||
h('th'))),
|
|
||||||
h('tbody', {}, rows)))
|
h('tbody', {}, rows)))
|
||||||
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
|
: 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
|
// 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
|
// 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.
|
// API is one call either way — POST upserts the role.
|
||||||
|
|||||||
Reference in New Issue
Block a user