Compare commits

..

13 Commits

Author SHA1 Message Date
Niklas Ye 7665e5e52f Set the chart's placeholder version to 0.41.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 26s
CI / test (push) Successful in 5m42s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 39s
Release / image (push) Successful in 1m22s
Release / scan-image (push) Successful in 27s
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 3a96b20 (0.41.1) and 065b557 (0.41.0) before it, because a
tree heading for v0.41.2 that still says 0.41.1 tells its reader
something false.
2026-10-08 14:53:15 +02:00
Niklas Ye bcf1a3e99b Give the sidebar groups, and tidy the tables, disclosures and tab bar
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team
and Admin; then Account at the foot, shown as the signed-in person with an
avatar and their name. The active item has a bar as well as a tint. The
queue's row of team chips is gone: the team selector is the one place the
team is chosen, and the chips offered the same choice a second time.

Tables get 16px between columns, so a right-aligned count no longer touches
the text beside it. An empty SSO group reads "Not configured" instead of a
dash, and the main action on each of those pages (New source, New switch,
Add member, Assign, and the admin Add, Create invite and Create) is a filled
button, with Edit and Cancel staying secondary. The bare triangles on
"Grouped by" and the label lists are real disclosure buttons: a chevron
that turns and a 44px target on a touch screen.

On a phone the More tab was a <button> that kept the browser's grey box, so
it looked highlighted next to four plain links. It now matches them, the
labels are 12px and the open tab's icon is filled.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:51:50 +02:00
Niklas Ye 3a96b20cbe Set the chart's placeholder version to 0.41.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 27s
CI / test (push) Successful in 5m55s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 41s
Release / image (push) Successful in 1m19s
Release / scan-image (push) Successful in 1s
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 065b557 (0.41.0) and ead5df1 (0.40.0) before it, because a
tree heading for v0.41.1 that still says 0.41.0 tells its reader
something false.
2026-10-08 14:19:12 +02:00
Niklas Ye 7b9d309d13 Draw the rota as shift bars, and the escalation chain as a ladder
The rota put an initial on every day, so one person covering a week was
seven identical chips and a legend to decode them. A shift is now one bar
across its days with the person's name on it; a flat end with a chevron
means it carries on across the row break. Days with nobody on call are a
hatched amber bar while they can still be fixed and a quiet grey one once
they are history, and the footer is a green banner when the month is
covered or an amber one when it is not. Tapping a day still opens it: the
bars ignore the pointer, so the tap reaches the cell underneath. The month
title was monospace because its button borrowed .label, which belongs to
label chips; it has its own class now, and a Today button sits beside the
arrows.

The escalation card was a five-column table. It is now the ladder it
describes: a numbered step per level with its status and who it pages, the
wait before the next level on its own line, and what happens after the last
one at the bottom. With no fallback topic that last step is an amber
callout, with an Add fallback button for owners that opens the editor on the
field. That gap is what incident #36 hit.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:17:49 +02:00
Niklas Ye 7412456c5a Pad the settings cards, and make status and timeline readable at a glance
The Team settings cards (Escalation, Sources, Members, Switches, Single
sign-on) printed their text flush against the border with the button in the
corner: .card has never had padding and these never added any. A card with
a header row now pads itself, with the title left, the button right and a
divider before the content.

The incident page no longer carries the primary action twice. The copy up
by the status is gone and the sticky bar keeps it; Note is the timeline's
link, with Copy in the bar for a resolved incident, and the phone's More
sheet drops what the bar already shows. In the queue, a row's title wraps
to two lines so the namespace that tells rows apart is no longer cut off,
zero counts on the filter chips are dimmed, and a row omits the status the
filter already states and the team once the queue is narrowed to one.

Status colours failed 4.5:1 against their own fill in the light theme
(warning 3.6, info 4.1, critical 4.4, ok 4.45, snooze 4.49), so the light
tokens are darker; the dark theme already passed and is unchanged. Severity
badges now carry a shape as well as a colour, and each timeline event has an
icon. An escalation that ran out of levels, a failed notification and a
silent heartbeat stand out in amber or red.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 14:07:24 +02:00
Niklas Ye 065b557860 Set the chart's placeholder version to 0.41.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 38s
CI / test (push) Successful in 6m4s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 44s
Release / image (push) Successful in 1m19s
Release / scan-image (push) Successful in 33s
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 ead5df1 (0.40.0) and 0f88574 (0.39.0) before it, because a
tree heading for v0.41.0 that still says 0.40.0 tells its reader
something false.
2026-10-08 13:24:46 +02:00
Niklas Ye a8dc89e23d Make the web UI easier to read, and add a theme toggle
Secondary text was too dim to read: --faint sat at about 4.0:1 in the dark
theme and 3.3:1 in the light one, and it carries row ages, hints and labels.
It now clears 4.5:1 in both. The dark surfaces and borders are a step
further apart so cards stand out from the page, and the light borders a
touch stronger.

Account has an Appearance section with System, Light and Dark. System is
the old behaviour. The choice is per browser, kept in localStorage, and is
applied by a small js/theme.js loaded from <head> so there is no flash of
the other theme; the CSP allows no inline script, hence a file of its own.

On-call is redesigned: a hero card for who is on call now, with when the
shift ends for the selected team, and the week as seven day cells instead of
grouped rows. Today is marked with a neutral tint and a bar rather than the
accent colour, which now means only things you can act on; your own days are
marked by the "you" badge, not a fill. On a wide screen the hero and week sit
beside your shifts under a page title. The page stays read-only: shifts are
still edited in the TUI.

Smaller fixes: the team switcher no longer appears in the phone's bottom bar
as well as the top bar (a shared rule overrode the one that hides it); the
Team and Admin tab strips fade at the edge and scroll the open section into
view, so "Sources" is no longer cut off; and "All clear" is a green status
badge with a check instead of a grey pill that looked like a button.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 13:24:41 +02:00
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
24 changed files with 1099 additions and 416 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.41.2
appVersion: "v0.41.2"
+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"`
+299 -84
View File
@@ -10,33 +10,33 @@
--surface: #ffffff;
--surface-2: #eff1f4;
--surface-hover: #f7f8fa;
--border: #e2e5ea;
--border-strong: #cfd3da;
--border: #d9dde4;
--border-strong: #c5cad3;
--text: #16181d;
--muted: #5b626e;
--faint: #8a909b;
--faint: #676d79;
--accent: #2f5bd3;
--accent-text: #ffffff;
--accent-soft: #e8eefc;
--crit: #d0342c;
--crit: #c22d26;
--crit-soft: #fdecea;
--warn: #b86e00;
--warn: #8f5500;
--warn-soft: #fdf3e1;
--info: #2f6fdf;
--info: #245fc7;
--info-soft: #e9f0fd;
--ok: #1d7f4c;
--ok: #1a7445;
--ok-soft: #e6f5ec;
--snooze: #6b5bd2;
--snooze: #6050c8;
--snooze-soft: #efedfb;
/* Two hues that mean nothing on their own. The rota needs six colours to
tell six people apart and the palette above only has four that are not
already an alarm. */
--teal: #0f7d8c;
--teal: #0c7180;
--teal-soft: #e3f4f6;
--pink: #b3427e;
--pink: #a63b73;
--pink-soft: #fbe8f2;
--radius: 10px;
@@ -72,17 +72,21 @@
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
/* Dark palette. Applied by the OS preference unless the user chose Light in
Account (data-theme="light" on <html>, set by js/theme.js), and always when
they chose Dark. The two blocks must stay identical: CSS has no way to share
a declaration list between a media query and an attribute selector. */
@media (prefers-color-scheme: dark) {
:root {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--surface: #171a20;
--surface-2: #1f232b;
--surface-hover: #1c2027;
--border: #2a2f38;
--border-strong: #394050;
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
--border: #343b49;
--border-strong: #444d5f;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #737a87;
--faint: #8a92a0;
--accent: #6d8ff0;
--accent-text: #0b0d12;
@@ -108,6 +112,42 @@
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
}
:root[data-theme="dark"] {
--bg: #0f1115;
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
--border: #343b49;
--border-strong: #444d5f;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #8a92a0;
--accent: #6d8ff0;
--accent-text: #0b0d12;
--accent-soft: #1d2640;
--crit: #ff6b61;
--crit-soft: #3a1c1b;
--warn: #f0b140;
--warn-soft: #362a14;
--info: #74a3ff;
--info-soft: #1a2640;
--ok: #4cc488;
--ok-soft: #15301f;
--snooze: #a89bff;
--snooze-soft: #262245;
--teal: #4fc2d4;
--teal-soft: #0f2e33;
--pink: #f07fb8;
--pink-soft: #3a1c2d;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
*, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; }
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
.open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); }
/* "All clear" is a status, not an action: green with a check, no dot. */
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
.open-pill.all-clear::before { content: none; }
.open-pill .icon { width: 14px; height: 14px; }
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
left sidebar from 900px, where the desktop block below redeclares display
@@ -252,6 +296,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border);
}
.nav-brand { display: none; }
.nav-sep, .nav-avatar { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; }
@@ -261,7 +306,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
block below cancels it back to a natural-width row item. */
flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600;
color: var(--muted); font-size: 12px; font-weight: 600;
/* The More tab is a <button>; without this it keeps the browser's grey box
and reads as a highlighted tab beside four plain links. */
background: none; border: 0; font-family: inherit; cursor: pointer;
/* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px;
@@ -276,6 +324,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-link[aria-current="page"] { color: var(--accent); }
/* The open tab's icon is filled, so it is not told apart by colour alone. */
.nav-link[aria-current="page"] svg { fill: currentColor; fill-opacity: 0.16; }
.nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px;
@@ -290,13 +340,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-team-selector,
.team-selector-mobile {
display: inline-flex; align-items: center; gap: 8px;
align-items: center; gap: 8px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; cursor: pointer;
padding: 4px 12px; max-width: 100%;
}
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
@@ -340,8 +390,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; cursor: pointer;
}
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.theme-picker { padding: 0 0 8px; }
.chip .count { margin-left: 4px; opacity: 0.7; }
/* Nothing in it: step back so the chips that have something stand out. */
.chip .count.zero { opacity: 0.4; }
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
box stays flush with its real right edge regardless of scroll position,
which turned out not to be true of position:sticky here — as a flex
@@ -377,7 +430,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--sev, var(--border-strong));
}
.row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
/* Two lines, not one: titles differ at the end ("PodRestarting
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
}
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta {
@@ -390,7 +446,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team {
padding: 1px 6px; border-radius: 4px;
@@ -436,6 +491,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; }
.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
@@ -471,10 +527,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
/* A copy of the sticky actionbar's primary button, right under the status
it responds to — see quickActions() in incident.js. */
.detail-quick-actions { margin-bottom: 14px; }
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
.card {
background: var(--surface);
@@ -517,11 +569,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; }
/* A disclosure is a button: a chevron that turns, and a 44px target on a
touch screen (the bare triangle it replaces was a few pixels wide). */
details > summary {
display: flex; align-items: center; gap: 8px;
min-height: 44px; margin: 0 -6px; padding: 0 6px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--muted); font-size: 14px; font-weight: 600; list-style: none;
}
details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; }
details[open] > summary::before { content: "▾ "; }
details > summary::before {
content: ""; flex: none; width: 7px; height: 7px; margin: 0 5px 0 3px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); transition: transform 0.12s;
}
details[open] > summary::before { transform: rotate(45deg); }
details > summary:hover { background: var(--surface-2); color: var(--text); }
details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
details[open] > summary { margin-bottom: 8px; }
@media (hover: hover) and (pointer: fine) { details > summary { min-height: 32px; } }
@media (prefers-reduced-motion: reduce) { details > summary::before { transition: none; } }
/* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
@@ -543,18 +609,26 @@ details[open] > summary { margin-bottom: 8px; }
.tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
}
.tl-item:first-child::before { top: 16px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); }
.tl-item:first-child::before { top: 18px; }
.tl-item:last-child::before { bottom: calc(100% - 18px); }
.tl-dot {
position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--faint);
display: grid; place-items: center;
width: 20px; height: 20px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
}
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); }
/* An event with no icon keeps the old plain dot. */
.tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
.tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
.tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
.tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; }
@@ -650,7 +724,6 @@ details[open] > summary { margin-bottom: 8px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.avatar {
flex: none; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%;
@@ -658,40 +731,62 @@ details[open] > summary { margin-bottom: 8px; }
font-weight: 750; font-size: 17px; text-transform: uppercase;
}
.avatar.none { background: var(--surface-2); color: var(--faint); }
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
.now-name { font-size: 20px; font-weight: 750; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
is easier to spot there than this plain accent-coloured text. */
.you-badge { margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
.week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the
ISO week number as secondary text inside it — a separate class from
.label above (team.js's month-nav uses that one) so its <small> isn't
caught by the unrelated .label > span styling meant for label chips. */
.week-nav .week-label { font-size: 14px; font-weight: 650; min-width: 11.5em; text-align: center; white-space: nowrap; }
.week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
.days { list-style: none; margin: 0 auto; padding: 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
.day + .day { border-top: 1px solid var(--border); }
.day-name { font-weight: 650; }
.day-date { color: var(--faint); font-size: 13px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; }
/* A run of several days held by the same person (or left empty), replacing
what used to be one identical row per day — see weekRuns() in oncall.js. */
.day.range { grid-template-columns: 1fr auto; }
.day-range { font-weight: 650; }
.day.today { background: var(--accent-soft); }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
.day.today .day-name, .day.today .day-range { color: var(--accent); }
.day.past { opacity: 0.6; }
/* Highlights whichever row is yours, same soft tint as .today — they already
read fine layered (today's own row is almost always one of yours too). */
.day.mine { background: var(--accent-soft); }
.oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
.oncall-title p { margin: 2px 0 0; font-size: 14px; }
.oncall-grid { display: grid; gap: 0; }
.oncall-main, .oncall-side { min-width: 0; }
.hero-list { display: grid; gap: 12px; }
.hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
.hero-top { display: flex; align-items: center; gap: 16px; }
.hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
.hero-avatar { width: 64px; height: 64px; font-size: 24px; }
.hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
.week-card { margin-top: 16px; padding: 16px 12px 12px; }
.week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
.week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
/* Bordered, so the arrows read as buttons. 40px: a touch target without
crowding the date between them. */
.week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.strip-day {
position: relative; min-width: 0;
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
}
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.strip-date { font-size: 15px; font-weight: 650; }
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.strip-who.nobody { font-style: italic; color: var(--faint); }
.strip-day.past { opacity: 0.6; }
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
reads as a marker and not as a pressed button. The accent stays for things
you can act on. */
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
.strip-day.today::before {
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
border-radius: 0 0 3px 3px; background: var(--text);
}
.strip-day.today .strip-name { color: var(--text); }
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
@@ -751,6 +846,17 @@ kbd {
.nav-team-selector { display: inline-flex; margin: -8px 10px 14px; width: calc(100% - 20px); }
.nav-link-secondary { display: flex; }
.nav-more-btn { display: none; }
.nav-sep { display: block; height: 1px; margin: 8px 10px; background: var(--border); }
/* The account link sits at the foot of the sidebar, as the signed-in person. */
.nav-sep-account { margin-top: auto; }
.nav-account .nav-avatar {
display: grid; place-items: center; flex: none;
width: 28px; height: 28px; margin: -4px 0 -4px -4px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-size: 13px; font-weight: 750; text-transform: uppercase;
}
.nav-account .nav-avatar:not([hidden]) ~ .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-account svg:has(~ .nav-avatar:not([hidden])) { display: none; }
.nav-link {
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -758,6 +864,11 @@ kbd {
}
.nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
/* A 3px bar on the active item, as well as the tint. */
.nav-link[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px;
border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; }
@@ -775,7 +886,6 @@ kbd {
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.chips-fade { display: none; }
/* With nothing selected there is no detail to show next to, so the list
takes the whole row instead of leaving the second column as dead space
@@ -825,9 +935,12 @@ kbd {
.admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border);
padding: 4px 16px 4px 0; border-bottom: 1px solid var(--border);
}
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
/* 16px between columns, so a right-aligned count never touches the
left-aligned text beside it ("5" against "16d ago"). */
.admin-table td { padding: 10px 16px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table th:last-child, .admin-table td:last-child { padding-right: 0; }
.admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
@@ -840,11 +953,26 @@ kbd {
Six columns do not fit a phone, so the table scrolls inside its card rather
than the page. A heartbeat under a switch with several is indented, the way
the escalation ladder indents its levels. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
/* A card with a header row (the Team and Admin settings cards) pads itself:
plain .card has no padding, so these used to print their text flush against
the border with the button jammed in the corner. The divider separates the
title row from the content below it. */
.card:has(> .card-head) { padding: 20px; }
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
}
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
.table-scroll { overflow-x: auto; margin-top: 12px; }
.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; }
@@ -908,23 +1036,32 @@ kbd {
too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; }
.rota-grid { padding: 10px 12px 4px; }
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
.rota-wd {
padding-bottom: 4px; text-align: center;
padding-bottom: 6px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em;
}
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
.rota-row + .rota-row { border-top: 1px solid var(--border); }
/* A day is a cell that spans both of its row's lines: the number on the first,
the shift bar drawn over the second. The bar ignores the pointer, so a tap
anywhere in the cell reaches the button. */
.rota-day {
display: flex; flex-direction: column; align-items: center; gap: 4px;
min-height: 52px; padding: 6px 0 8px;
grid-row: 1 / span 2;
display: flex; justify-content: center; align-items: flex-start;
min-width: 0; padding-top: 2px;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; color: inherit;
}
button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); }
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */
.rota-week {
grid-column: 1; grid-row: 1 / span 2;
display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
@@ -932,18 +1069,43 @@ button.rota-day:hover { background: var(--surface-2); }
}
button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; }
.rota-week.current { color: var(--text); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.rota-day.today { background: var(--accent-soft); }
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; }
.rota-day.past { opacity: 0.55; }
.rota-num {
min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
text-align: center; line-height: 22px;
color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
}
/* Today is a filled number, a marker and not the accent, which means "you can
act on this". */
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
/* The days either side of the month are real days and are drawn, but they
belong to the month you are not looking at. */
.rota-day.outside { opacity: 0.35; }
.rota-day.outside .rota-num { opacity: 0.4; }
/* A shift: a run of consecutive days held by one person, in that person's
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
a flat end with a chevron carries on across the row break. */
.rota-bar {
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
border-radius: 6px;
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rota-bar.past { opacity: 0.55; }
.rota-bar.outside { opacity: 0.4; }
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
.rota-bar.gap {
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
border: 1px dashed var(--warn); color: var(--warn);
}
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
.rota-chip {
display: grid; place-items: center;
@@ -963,14 +1125,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rc5 { background: var(--teal-soft); color: var(--teal); }
.rc6 { background: var(--pink-soft); color: var(--pink); }
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; }
.rota-key { display: inline-flex; align-items: center; gap: 6px; }
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; }
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; }
.rota-note:first-child { margin-top: 0; }
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); }
.rota-bulk .stacked-form { margin-top: 4px; }
.rota-banner {
display: flex; align-items: center; gap: 10px;
padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
}
.rota-banner .icon { width: 20px; height: 20px; }
.rota-banner.ok { color: var(--ok); }
.rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
.rota-foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 12px 14px; border-top: 1px solid var(--border);
}
.rota-bulk-form { padding: 0 14px 14px; }
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.ladder-level {
@@ -983,6 +1149,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
/* A target that would not wake anybody says why, in place: it is the reason a
level is red, and the thing to go and fix. */
/* The escalation ladder: a numbered step per level, the wait between levels as
its own line, and what happens after the last one at the bottom. */
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
.step-rail { display: flex; flex-direction: column; align-items: center; }
.step-node {
display: grid; place-items: center; flex: none;
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--border-strong); background: var(--surface);
font-size: 14px; font-weight: 750;
}
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
.step-node.end { border-style: dashed; color: var(--muted); }
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node .icon { width: 16px; height: 16px; }
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
.step-body { min-width: 0; padding-bottom: 4px; }
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
.step-waiting a { font-weight: 650; }
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.step-gap .icon { width: 14px; height: 14px; }
.callout {
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
background: var(--warn-soft);
}
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
.callout strong { color: var(--warn); }
.callout p { margin: 2px 0 10px; font-size: 14px; }
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.ladder-facts p { margin: 0; }
.ladder-facts p + p { margin-top: 4px; }
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
@@ -1033,6 +1236,11 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
overflow-x: auto; scrollbar-width: none;
}
.subnav::-webkit-scrollbar { display: none; }
/* A fade on the right edge says "there is more" where the strip overflows;
the desktop width fits every entry, so it is phone-only. */
@media (max-width: 899px) {
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
}
.subnav-link {
flex: none;
padding: 8px 12px; margin-bottom: -1px;
@@ -1092,3 +1300,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
@media (min-width: 900px) {
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
}
/* On-call, wide: the hero and the week on the left, your shifts on the right.
.view-page > * caps its children at 760px, so the grid lifts that. */
@media (min-width: 900px) {
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
#view-oncall > .oncall-title { max-width: 1080px; }
}
+11 -2
View File
@@ -14,6 +14,7 @@
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css">
<script src="/js/theme.js"></script>
<script type="module" src="/js/app.js"></script>
</head>
<body>
@@ -109,6 +110,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span>
</a>
<!-- Dividers between the groups (Queue, On-call, Alerts, Stats | Team,
Admin | Account). Desktop sidebar only; the phone bar has no room. -->
<span class="nav-sep" aria-hidden="true"></span>
<a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span>
@@ -120,9 +124,14 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span>
</a>
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account">
<span class="nav-sep nav-sep-account" aria-hidden="true"></span>
<!-- At the foot of the desktop sidebar, as the signed-in person: app.js
fills the avatar and the name from /api/me, and until then it reads
"Account". -->
<a class="nav-link nav-link-secondary nav-account" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span>
<span class="nav-avatar" id="nav-avatar" aria-hidden="true" hidden></span>
<span class="nav-label" id="nav-account-label">Account</span>
</a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom
+20
View File
@@ -28,6 +28,9 @@ function render() {
...passwordSection(user, hasPassword),
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
themePicker(),
h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
h('div', { class: 'card' }, shortcuts())),
@@ -39,6 +42,23 @@ function render() {
);
}
// System / Light / Dark. Per browser, not per account: it lives in
// localStorage (see js/theme.js), the same place the selected team does.
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
function themePicker() {
const theme = window.terdutTheme;
const buttons = THEMES.map(([value, text]) => h('button', {
class: 'chip', type: 'button', role: 'radio', text,
onclick: () => { theme.set(value); sync(); },
}));
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
sync();
return h('div', { class: 'card card-pad' },
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator.
+7 -2
View File
@@ -102,13 +102,18 @@ function section() {
// buttons, because these are four URLs: app.js intercepts the click, the
// browser's Back walks them, and a reload lands where you were.
function subnav() {
return h('nav', { class: 'subnav', 'aria-label': 'Administration' },
const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
TABS.map((t) => h('a', {
class: 'subnav-link',
href: t.path,
text: t.label,
'aria-current': t.tab === tab ? 'page' : null,
})));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
}
// --- overview --------------------------------------------------------------
@@ -165,7 +170,7 @@ function teamsCard() {
function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Create' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+8 -5
View File
@@ -152,16 +152,19 @@ function identityCard() {
// membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'),
fact('OIDC owner group', t.oidc_owner_group || '—'),
fact('OIDC member group', t.oidc_member_group || notConfigured()),
fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
] : []),
),
form, err, ok,
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
// value is text or a node.
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
return [h('dt', { text: label }), h('dd', {}, value)];
}
// --- members ---------------------------------------------------------------
@@ -207,7 +210,7 @@ function membersCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
@@ -238,7 +241,7 @@ function invitesCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (busy) return;
+1 -1
View File
@@ -197,7 +197,7 @@ function teamsCard() {
h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
+3 -3
View File
@@ -2,8 +2,8 @@
// incident it belongs to, which is where anything can be done about it.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
import { age, labelSummary } from './format.js';
const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
@@ -87,7 +87,7 @@ function row(a) {
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' },
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`),
sev && severityBadge(sev),
summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }),
),
+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
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js';
import * as device from './device.js';
import { initial } from './format.js';
const $ = (id) => document.getElementById(id);
@@ -202,7 +203,8 @@ function updateBadges() {
const pill = $('open-pill');
pill.hidden = false;
pill.textContent = open ? `${open} open` : 'All clear';
pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
pill.classList.toggle('all-clear', open === 0);
pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
@@ -466,7 +468,19 @@ export async function signOut() {
showLogin();
}
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
function showApp() {
renderAccountNav();
ssoErrorCode = null;
$('boot').hidden = true;
$('login').hidden = true;
+46 -25
View File
@@ -4,10 +4,10 @@
import * as api from './api.js';
import * as poll from './poll.js';
import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js';
import {
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
ago, when, until, duration, isFuture, STATUS_LABEL,
} from './format.js';
import { myID, users } from './state.js';
import { back } from './app.js';
@@ -86,7 +86,6 @@ function render() {
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()),
quickActions(),
facts(),
groupLabels(),
alertsSection(),
@@ -100,7 +99,7 @@ function render() {
function statusBadges() {
const out = [];
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`));
if (inc.severity) out.push(severityBadge(inc.severity));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
@@ -145,7 +144,7 @@ function facts() {
: ackActorLabel() || 'Unassigned';
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' },
h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
inc.severity && severityBadge(inc.severity),
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
));
@@ -225,6 +224,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 +247,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'];
@@ -325,10 +338,30 @@ function timelineSection() {
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
// One icon per kind of event, so the rail says what happened before the text
// does. Anything not listed keeps a plain dot.
const EVENT_ICON = {
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
alert_added: 'plus', alert_resolved: 'check',
archived: 'archive', unarchived: 'undo',
note: 'note', resolution_note: 'note',
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
};
// An escalation that ran out of levels, or of anywhere to send the page, is
// the one event on the rail that means "nobody is being told": it gets the
// warning treatment instead of reading like any other step.
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
function timelineItem(ev) {
const mine = isNote(ev) && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` },
h('span', { class: 'tl-dot' }),
const problem = isProblem(ev);
const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
h('div', { class: 'tl-body' },
h('div', { class: 'tl-text' }, eventText(ev)),
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
@@ -438,9 +471,8 @@ async function copyIncident() {
const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
// primaryAction and secondaryAction are factories, not shared nodes — a
// button can only live in one place, and quickActions() below needs its own
// copy of the primary one rather than the actionbar's.
// primaryAction and secondaryAction are factories, not shared nodes: a button
// can only live in one place, and the actionbar is rebuilt on every render.
function primaryAction() {
if (inc.status === 'triggered') {
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
@@ -459,17 +491,7 @@ function secondaryAction() {
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
}
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
}
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
// right away, next to the status it responds to. The sticky actionbar below
// keeps carrying every action, primary included, for whenever the page has
// been scrolled past it.
function quickActions() {
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
return div;
return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
}
// Desktop has room to show what a phone folds into the More sheet below — see
@@ -480,7 +502,6 @@ function extraActions() {
if (!isOpen()) return [];
const out = [
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: addNote }, icon('note'), 'Add note…'),
];
out.push(inc.status === 'acknowledged'
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
@@ -676,9 +697,9 @@ async function moreMenu() {
items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else {
// The bar already carries the primary action (Archive/Unarchive) and
// Copy, so the sheet adds only what it lacks.
items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
}
const fn = await openSheet(() => [
+72 -66
View File
@@ -1,5 +1,6 @@
// On-call: who is on duty now, the week around it, and your own next shifts.
// Read-only for now; the TUI edits the schedule.
// Read-only for now; the TUI edits the schedule, so there is no add or swap
// button here.
//
// One team's rota at a time — the viewer's first team, since a viewer in one
// team has nothing to choose between. "On call now" is the exception and shows
@@ -59,11 +60,17 @@ function render() {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return;
}
const team = currentTeam();
clear(view(),
// The top bar carries the title on a phone; the desktop has none.
h('div', { class: 'only-desktop oncall-title' },
h('h1', { text: 'On-call' }),
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
nowCard(),
weekCard(),
myShifts(),
h('div', { class: 'oncall-grid' },
h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
h('div', { class: 'oncall-side' }, myShifts()),
),
);
}
@@ -78,75 +85,73 @@ function nowCard() {
const entries = data.now || [];
const showTeam = entries.length > 1;
if (entries.length === 0) {
return h('div', { class: 'card now-card' },
h('div', { class: 'avatar none', text: '–' }),
h('div', {},
h('div', { class: 'now-label', text: 'On call now' }),
h('div', { class: 'now-name', text: 'Nobody' }),
),
);
return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar none', text: '–' }),
h('div', { class: 'hero-name', text: 'Nobody' })));
}
return h('div', {}, ...entries.map((n) =>
h('div', { class: 'card now-card' },
h('div', { class: 'avatar', text: initial(n.username) }),
return h('div', { class: 'hero-list' }, ...entries.map((n) => {
const until = shiftEnd(n);
return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
h('div', {},
h('div', {
class: 'now-label',
text: showTeam ? `On call now · ${n.team_name}` : 'On call now',
}),
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
),
)));
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
until && h('div', { class: 'hero-until', text: until }))));
}));
}
// Groups the week's 7 days into runs held by the same person (or the same
// empty slot) — the week's own version of the consecutive-day grouping
// myShifts does for a single person's own dates, below. Seven identical rows
// for one person all week collapses to the one bar this way.
function weekRuns(byDate) {
const runs = [];
for (let i = 0; i < 7; i++) {
const date = isoDate(addDays(weekStart, i));
const e = byDate.get(date) || null;
const uid = e ? e.user_id : null;
const last = runs[runs.length - 1];
if (last && last.uid === uid) last.to = date;
else runs.push({ uid, entry: e, from: date, to: date });
}
return runs;
// "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
// end is read off the rota (the run of consecutive days from today held by the
// same person), and that is only loaded for one team. The same midnight
// boundary the "Current shift" row below uses.
function shiftEnd(n) {
const team = currentTeam();
if (!team || n.team_id !== team.id) return null;
const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
let day = new Date();
if (mine.get(isoDate(day)) !== n.user_id) return null;
while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
// Still holding the last day loaded: the shift may run on past it.
if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
const end = addDays(parse(isoDate(day)), 1);
return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
}
// A stable colour per person from the six-colour rcN palette. The week page
// has no member list to take an index from (team.js does), so the id decides.
const personClass = (userID) => `rc${(userID % 6) + 1}`;
// The week as seven cells, Monday to Sunday, each showing who holds that day.
// A hand-over in the middle of the week is visible without reading anything.
function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date());
const mine = myID();
const days = weekRuns(byDate).map((r) => {
const single = r.from === r.to;
const cls = [
'day',
single ? '' : 'range',
r.from <= today && today <= r.to ? 'today' : '',
r.to < today ? 'past' : '',
r.uid === mine ? 'mine' : '',
].filter(Boolean).join(' ');
const label = single
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
: [h('span', {
class: 'day-range',
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
})];
return h('li', { class: cls },
...label,
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
);
});
const seen = new Map();
const cells = [];
for (let i = 0; i < 7; i++) {
const d = addDays(weekStart, i);
const date = isoDate(d);
const e = byDate.get(date) || null;
if (e) seen.set(e.user_id, e.username);
cells.push(h('li', {
class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
'aria-current': date === today ? 'date' : null,
},
h('span', { class: 'strip-name', text: dayName.format(d) }),
h('span', { class: 'strip-date', text: String(d.getDate()) }),
h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
}
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [
h('div', { class: 'page-head' },
return h('div', { class: 'card week-card' },
h('div', { class: 'week-head' },
h('h2', { text: thisWeek ? 'This week' : 'Week' }),
h('div', { class: 'week-nav' },
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')),
h('button', {
class: 'btn btn-ghost week-label',
@@ -155,12 +160,13 @@ function weekCard() {
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')),
),
),
h('ul', { class: 'card days' }, days),
];
h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')))),
h('ul', { class: 'strip' }, cells),
seen.size > 0 && h('div', { class: 'strip-legend' },
[...seen].map(([id, name]) => h('span', {},
h('i', { class: `strip-dot ${personClass(id)}` }),
id === mine ? `${name} (you)` : name))));
}
// myShifts groups your upcoming dates into runs of consecutive days, then
+16 -37
View File
@@ -1,9 +1,9 @@
// The incident queue: filter chips and a list of incident rows.
import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary } from './format.js';
import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js';
import { navigate } from './app.js';
@@ -129,39 +129,12 @@ function renderChips() {
'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id),
text: f.label,
}, count != null && h('span', { class: 'count', text: String(count) }));
}, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
});
// Somebody in one team has nothing to choose between, so the row of team
// chips appears only when there is more than one. The default is all of
// them: the combined queue is the point.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
// A scroll hint for the phone-width row, where the chips can run off the
// right edge with nothing to suggest there's more; the desktop sidebar
// wraps instead of scrolling (see .pane-list .chips), so this fades out
// there via CSS rather than being left out here.
// There are no team chips here: the team selector in the sidebar (and in the
// phone's top bar) is the one place the team is chosen, and chips for it
// would be the same choice offered twice.
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
clear(el, chips);
@@ -194,6 +167,10 @@ function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved';
// A filter that already says what the status is (Triggered, Resolved,
// Archived) would only have every row repeat it.
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
let status;
if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
@@ -214,7 +191,9 @@ function row(inc, index) {
// The team is shown only to somebody who is in more than one. For everybody
// else it is the same word on every row, which is noise rather than
// information.
const team = state.teams.length > 1 && inc.team_name
// ...and not when the queue is already narrowed to one team: that team is
// the same word on every row there too.
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name })
: null;
@@ -227,10 +206,10 @@ function row(inc, index) {
h('div', { class: 'row-title', text: inc.title }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' },
status,
!impliedStatus && status,
// The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity.
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`),
inc.severity && severityBadge(inc.severity),
assignee,
team,
labels && h('span', { class: 'labels', text: labels }),
+316 -158
View File
@@ -168,13 +168,18 @@ function section() {
// buttons, because these are six URLs: app.js intercepts the click, the
// browser's Back walks them, and a reload lands where you were.
function subnav() {
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
TABS.map((t) => h('a', {
class: 'subnav-link',
href: t.path,
text: t.label,
'aria-current': t.tab === tab ? 'page' : null,
})));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
}
// Names which team's settings the six sections below belong to. It used to be
@@ -229,8 +234,8 @@ function overview() {
// A month of it, as a grid. It used to be thirty rows of "date — username",
// which is a rota spelled out one day at a time: the question asked of it is
// "who has which stretch", and thirty names down a column is the one shape
// that answer cannot be read in. So each day carries a coloured initial
// instead, the legend says whose, and a shift becomes a run of one colour.
// that answer cannot be read in. So a shift is drawn as one bar across its
// days with the person's name on it.
//
// The same month laid out the same way as the on-call page's week, because it
// is the same rota — heading and arrows outside the card, days inside it.
@@ -269,22 +274,8 @@ function scheduleCard() {
const today = isoDate(new Date());
const month = monthStart.getMonth();
// Whose colours to explain, in the order the month meets them. Only the days
// of this month count: a name that appears solely in the overhang belongs to
// the month next door and would be explaining a chip nobody asked about.
const seen = new Map();
const cells = [];
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const rows = [];
for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) {
@@ -292,38 +283,104 @@ function scheduleCard() {
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
}
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away until asked for.
let bulk = null;
if (isOwner()) {
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
const toggle = h('button', {
class: 'btn', type: 'button', 'aria-expanded': 'false',
onclick: () => {
form.hidden = !form.hidden;
toggle.setAttribute('aria-expanded', String(!form.hidden));
},
}, icon('calendar'), 'Assign a range of days');
bulk = [
h('div', { class: 'rota-foot' },
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
form,
];
}
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
return [
h('div', { class: 'page-head' },
h('h2', { text: 'On-call rota' }),
h('div', { class: 'week-nav' },
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
}, icon('chevronLeft')),
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
h('button', {
class: 'btn btn-ghost label', type: 'button',
title: 'Back to this month',
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
text: monthFmt.format(monthStart),
}),
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Next month', onclick: () => shiftMonth(1),
}, icon('chevronRight')),
h('button', {
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
}),
),
),
h('div', { class: 'card' },
h('div', { class: 'rota-grid' }, heads, cells),
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)),
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away under the month it edits.
isOwner() && h('details', { class: 'rota-bulk' },
h('summary', { text: 'Assign a range of days' }),
assignForm()),
h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
coverNote(byDate),
bulk,
),
];
}
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
// shifts drawn over the days as bars. A bar is a run of consecutive days held
// by the same person, so a week handed to one person is one bar with one name
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
function weekRow(monday, byDate, today, month) {
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const entries = keys.map((k) => byDate.get(k) || null);
const uid = (i) => (entries[i] ? entries[i].user_id : null);
const before = byDate.get(isoDate(addDays(monday, -1)));
const after = byDate.get(isoDate(addDays(monday, 7)));
const cells = [weekCell(monday, byDate, today)];
days.forEach((d, i) => {
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
cell.style.gridColumn = String(i + 2);
cells.push(cell);
});
for (let i = 0; i < 7;) {
let j = i;
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
const entry = entries[i];
// A flat end says the shift carries on past this row, not that it stops.
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
i = j + 1;
}
return h('div', { class: 'rota-row' }, cells);
}
function shiftBar(entry, i, j, o) {
const span = j - i + 1;
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
.filter(Boolean).join(' ');
// One day is too narrow for a name on a phone; the initial stands in and the
// full name is in the day's own label underneath.
const name = entry
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
: (span >= 3 ? 'Nobody on call' : '');
const bar = h('div', {
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
text: entry && span === 1 ? initial(entry.username) : name,
});
bar.style.gridColumn = `${i + 2} / span ${span}`;
return bar;
}
// The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days.
@@ -342,15 +399,10 @@ function weekCell(monday, byDate, today) {
}
function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past']
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
.filter(Boolean).join(' ');
const label = `${key} · ${e ? e.username : 'nobody'}`;
const body = [
h('span', { class: 'rota-num', text: String(d.getDate()) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }),
];
const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
// A member sees the same grid without the affordance, the way every other
// control on this page is hidden rather than shown and refused.
return isOwner()
@@ -369,17 +421,7 @@ function colorClass(userID) {
return `rc${((i < 0 ? userID : i) % 6) + 1}`;
}
function legend(seen) {
if (!seen.size) return null;
return h('div', { class: 'rota-legend' },
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
h('span', { text: name }),
id === myID() && h('span', { class: 'you', text: 'you' }),
)));
}
// The gap count, which is the one thing the grid states only by omission. Days
// The gap count, which is the one thing the bars state only by omission. Days
// already past are not counted: an empty Tuesday last week is history, not a
// hole somebody still has to fill.
function coverNote(byDate) {
@@ -390,10 +432,14 @@ function coverNote(byDate) {
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
if (key >= today && !byDate.has(key)) gaps++;
}
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' });
return h('p', { class: 'rota-note' },
if (gaps === 0) {
return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
h('span', { text: 'Every day left this month has somebody on call.' }));
}
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
h('span', {},
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.');
' left this month with nobody on call.'));
}
// One week, in the sheet: who holds each day of it, and one person to put on
@@ -518,7 +564,7 @@ function assignForm() {
// API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Assign' }));
form.addEventListener('submit', (e) => {
e.preventDefault();
@@ -562,23 +608,49 @@ function escalationCard() {
const esc = data.escalation || {};
const levels = esc.levels || [];
const rows = levels.map((l) => h('tr', {},
h('td', {}, h('strong', { text: `Level ${l.position}` })),
h('td', {}, levelBadge(l.status)),
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)),
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }),
h('td', { class: 'small' }, l.waiting?.length
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])
: h('span', { class: 'muted', text: '—' })),
));
// The ladder as steps, top to bottom: each level, the wait before the next,
// and what happens when the last one is not answered. The wait belongs to the
// level above it ("then after 5m"), which is how the API states it.
const steps = [];
levels.forEach((l) => {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' },
h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
h('span', { class: 'step-line' })),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
steps.push(h('li', { class: 'step step-wait' },
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
});
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
}, icon('plus'), 'Add fallback'))))));
}
const facts = [];
if (levels.length) {
const n = esc.repeat_count || 0;
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`);
facts.push(esc.fallback_topic
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.']
: 'No fallback topic: after the last level the chain just ends.');
facts.push(n
? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
: 'Nothing repeats: the ladder runs once.');
facts.push(esc.last_escalated_at
? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
@@ -590,23 +662,18 @@ function escalationCard() {
h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }),
isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor,
class: 'btn btn-primary', type: 'button', onclick: () => openLadderEditor(),
text: levels.length ? 'Edit ladder' : 'Set up ladder',
})),
h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
levels.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
h('tbody', {}, rows)))
? h('ol', { class: 'ladder' }, steps)
: h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'),
...facts.map((f) => h('p', { class: 'muted small' }, f)),
facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
);
}
@@ -614,7 +681,7 @@ function escalationCard() {
// it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit.
function openLadderEditor() {
function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {};
const draft = {
repeat_count: esc.repeat_count || 0,
@@ -626,6 +693,7 @@ function openLadderEditor() {
};
const body = h('div', { class: 'ladder-editor' });
let fallbackInput = null;
const problem = h('p', { class: 'load-error', hidden: true });
const paint = () => {
@@ -665,7 +733,7 @@ function openLadderEditor() {
value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
});
const fallback = h('input', {
const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; },
});
@@ -696,6 +764,7 @@ function openLadderEditor() {
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save),
]);
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
}
function targetRow(level, target, index, repaint) {
@@ -735,8 +804,7 @@ function minutesInput(seconds, onChange) {
// --- integrations ----------------------------------------------------------
function integrationsCard() {
const rows = (data.integrations || []).map((i) =>
h('tr', {},
const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
h('td', {}, sourceBadge(i.status)),
h('td', { class: 'wrap' },
h('strong', { text: i.name }),
@@ -748,29 +816,13 @@ function integrationsCard() {
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));
},
}))),
));
), () => openSourceDetail(i)));
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource,
class: 'btn btn-primary', type: 'button', text: 'New source', onclick: openNewSource,
})),
h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ',
@@ -780,9 +832,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 +896,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 +979,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 +1016,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 +1032,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 +1044,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 btn-primary', 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 +1053,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 +1063,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 +1093,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 +1116,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 ---------------------------------------------------------------
@@ -1050,23 +1196,27 @@ function shiftCell(m) {
function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' };
// With nothing set, Edit is the one thing to do here, so it leads.
const unset = !g.member_group && !g.owner_group;
return h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})),
h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'),
fact('Owner group', g.owner_group || '—'),
fact('Member group', g.member_group || notConfigured()),
fact('Owner group', g.owner_group || notConfigured()),
),
);
}
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })];
}
@@ -1114,9 +1264,7 @@ 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', {},
const rows = members.map((m) => clickableRow(h('tr', {},
h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' },
h('strong', { text: m.username }),
@@ -1127,20 +1275,52 @@ function membersCard() {
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',
), () => openMemberDetail(m, owners)));
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
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: () => openEditMember(m),
onclick: () => { closeSheet(); openEditMember(m); },
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
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 '
@@ -1150,29 +1330,7 @@ function membersCard() {
}))) return;
act(() => api.removeTeamMember(teamID, m.user_id));
},
}))),
);
});
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? 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('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
)];
}));
}
// One sheet for both jobs a member's row has: who, and as what. Adding is
+45
View File
@@ -0,0 +1,45 @@
// Theme override: System (no attribute, the OS decides), Light or Dark.
//
// A classic script loaded from <head>, not a module, so the saved choice is on
// <html> before the first paint and a Dark user on a light OS never sees a
// flash. The CSP allows no inline script, hence a file of its own. Account
// (account.js) reads and writes the choice through window.terdutTheme.
(function () {
var KEY = 'terdut.theme';
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
function get() {
try {
var v = localStorage.getItem(KEY);
return v === 'light' || v === 'dark' ? v : 'system';
} catch (e) {
return 'system'; // storage unavailable
}
}
// The browser chrome colour follows the choice too; the two <meta>s are
// media-keyed to the OS, so a forced theme sets both to the same colour.
function apply(theme) {
var root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
var metas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < metas.length; i++) {
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
}
}
function set(theme) {
try {
if (theme === 'system') localStorage.removeItem(KEY);
else localStorage.setItem(KEY, theme);
} catch (e) {
/* storage unavailable: applies for this page view only */
}
apply(theme);
}
window.terdutTheme = { get: get, set: set };
apply(get());
})();
+17
View File
@@ -1,5 +1,7 @@
// DOM helpers, the bottom sheet, confirmation and toasts.
import { severityClass } from './format.js';
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) {
@@ -32,6 +34,11 @@ export function clear(el, ...children) {
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = {
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'],
plus: ['M12 5v14M5 12h14'],
back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'],
@@ -171,6 +178,16 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text });
}
// The severity as a badge with a shape as well as a colour: a triangle for
// critical, a circle with ! for warning, a circle with i for anything else, so
// the coloured bar and badge are not the only thing that says it.
const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' };
export function severityBadge(severity) {
const cls = severityClass(severity);
return h('span', { class: `badge plain ${cls}` },
icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity);
}
// Access granted by the identity provider's groups. The server refuses to edit
// it by hand (it would be undone at the next sign-in), so the controls that
// would try are disabled with this as their reason.