Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b2609e98f | |||
| fa6d82d6e5 | |||
| 7efd1bbba7 | |||
| 3bf94a5d7f | |||
| 5c4e0bdd0e | |||
| 9e5b085d8b | |||
| 0050738ca0 | |||
| 42180948d1 | |||
| c83c7c2a8b | |||
| 43beda9a30 | |||
| 710521a73c | |||
| d9492913ed | |||
| 1770e5d945 | |||
| 497086cb51 | |||
| e3090d2779 | |||
| 91f03c21e8 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.34.0
|
version: 0.37.1
|
||||||
appVersion: "v0.34.0"
|
appVersion: "v0.37.1"
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ metadata:
|
|||||||
labels:
|
labels:
|
||||||
{{- include "terdut-server.labels" . | nindent 4 }}
|
{{- include "terdut-server.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: {{ .Values.replicaCount }}
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
|
||||||
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
|
# RollingUpdate, not Recreate: the sweeper, notifier and migration runner
|
||||||
# sweeper and the notifier are unsynchronised singletons, and two replicas
|
# each take a Postgres advisory lock around their own pass, and new-incident
|
||||||
# overlapping during a rollout would both page for the same incident.
|
# creation on the first webhook for a brand-new groupKey resolves its own
|
||||||
|
# insert conflict -- so two replicas overlapping during a rollout no longer
|
||||||
|
# double-page, race a migration, or drop a webhook payload (v0.36.0). No
|
||||||
|
# explicit maxUnavailable/maxSurge: the 25%/25% default rounds to 0/1 at
|
||||||
|
# replicaCount: 2, which is zero-downtime already.
|
||||||
strategy:
|
strategy:
|
||||||
type: Recreate
|
type: RollingUpdate
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
# Safe above 1 since v0.36.0: the sweeper, notifier and migration runner each
|
||||||
|
# take a Postgres advisory lock around their own pass, and a webhook that
|
||||||
|
# loses the race to open a brand-new incident attaches to the winner's row
|
||||||
|
# instead of dropping its payload. An image older than v0.36.0 does not have
|
||||||
|
# these guards -- do not raise this against one.
|
||||||
|
replicaCount: 2
|
||||||
|
|
||||||
networking:
|
networking:
|
||||||
hostname: "terdut.example.com"
|
hostname: "terdut.example.com"
|
||||||
servicePort: 8080
|
servicePort: 8080
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// This file is internal (package api, not api_test) because withAdvisoryLock is
|
||||||
|
// unexported and these tests exercise its locking semantics directly rather than
|
||||||
|
// through the full StartArchiver/StartNotifier loop, which would make the "does
|
||||||
|
// not run while held" case timing-dependent instead of deterministic. It opens a
|
||||||
|
// plain connection to TERDUT_TEST_DSN rather than reusing testdb_test.go's
|
||||||
|
// newTestDB, since that helper lives in the separate, already-compiled
|
||||||
|
// api_test package and a Postgres advisory lock needs no schema or migration
|
||||||
|
// to exercise.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// advisoryTestDB opens a plain, unmigrated connection to the test database. An
|
||||||
|
// unset DSN fails rather than skips, matching testdb_test.go's rationale: a
|
||||||
|
// suite that quietly tests nothing is worse than one that does not run.
|
||||||
|
func advisoryTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := os.Getenv("TERDUT_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
|
||||||
|
"Run `make test-db` for a local one, then\n" +
|
||||||
|
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAdvisoryLock_RunsWhenFree(t *testing.T) {
|
||||||
|
db := advisoryTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
ran := false
|
||||||
|
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||||
|
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("fn did not run although the lock was free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAdvisoryLock_SkipsWhileHeldElsewhere(t *testing.T) {
|
||||||
|
db := advisoryTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Hold the lock on a connection of our own, standing in for another
|
||||||
|
// replica mid-pass.
|
||||||
|
holder, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire holder connection: %v", err)
|
||||||
|
}
|
||||||
|
defer holder.Close()
|
||||||
|
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||||
|
t.Fatalf("pre-acquire lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ran := false
|
||||||
|
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||||
|
if ran {
|
||||||
|
t.Fatal("fn ran although another connection already held the lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey); err != nil {
|
||||||
|
t.Fatalf("release held lock: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now that the holder released it, the next caller should get it.
|
||||||
|
ran = false
|
||||||
|
withAdvisoryLock(ctx, db, archiverLockKey, "test", func() { ran = true })
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("fn did not run after the other connection released the lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAdvisoryLock_ReleasesAfterFnReturns(t *testing.T) {
|
||||||
|
db := advisoryTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() {})
|
||||||
|
|
||||||
|
// If the first call had leaked the lock, this one would see it held and
|
||||||
|
// skip, leaving ran false.
|
||||||
|
ran := false
|
||||||
|
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("fn did not run on a later call: the earlier call leaked its lock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithAdvisoryLock_KeysAreIndependent(t *testing.T) {
|
||||||
|
db := advisoryTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
holder, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire holder connection: %v", err)
|
||||||
|
}
|
||||||
|
defer holder.Close()
|
||||||
|
if _, err := holder.ExecContext(ctx, "SELECT pg_advisory_lock($1)", archiverLockKey); err != nil {
|
||||||
|
t.Fatalf("pre-acquire archiver lock: %v", err)
|
||||||
|
}
|
||||||
|
defer holder.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", archiverLockKey)
|
||||||
|
|
||||||
|
// Holding archiverLockKey must not block notifierLockKey.
|
||||||
|
ran := false
|
||||||
|
withAdvisoryLock(ctx, db, notifierLockKey, "test", func() { ran = true })
|
||||||
|
if !ran {
|
||||||
|
t.Fatal("fn did not run under a different key although only archiverLockKey was held")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -376,6 +376,17 @@ func incidentForGroup(ctx context.Context, tx *sql.Tx, notify NotifyConfig, team
|
|||||||
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
// its own. Hence the querier rather than a *sql.Tx. A nil severity leaves the
|
||||||
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
// column for refreshSeverity to fill from the member alerts; the sweeper passes
|
||||||
// one because its incidents have no members to derive it from.
|
// one because its incidents have no members to derive it from.
|
||||||
|
//
|
||||||
|
// Both callers get here only after their own SELECT found no open incident for
|
||||||
|
// this group_key — but on more than one replica, two webhook deliveries for the
|
||||||
|
// very first occurrence of a brand-new group_key can both pass that SELECT
|
||||||
|
// before either INSERTs. ON CONFLICT DO NOTHING against
|
||||||
|
// incidents_open_group_key_idx is what makes the loser's INSERT a no-op instead
|
||||||
|
// of a unique-violation error that would otherwise roll back its entire
|
||||||
|
// payload; existingOpenIncident then hands it the winner's row. Postgres
|
||||||
|
// resolves that conflict only once the winner's transaction has committed (or
|
||||||
|
// rolled back), so by the time this RETURNING comes back empty, the winner's
|
||||||
|
// row is guaranteed visible to that follow-up SELECT.
|
||||||
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID int64, groupKey, title string, groupLabels map[string]string, severity *string) (int64, error) {
|
||||||
onCall, err := currentOnCall(ctx, q, teamID)
|
onCall, err := currentOnCall(ctx, q, teamID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -391,10 +402,18 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
|||||||
err = q.QueryRowContext(ctx, `
|
err = q.QueryRowContext(ctx, `
|
||||||
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
INSERT INTO incidents (team_id, group_key, title, group_labels, signature, status, severity, triggered_at, assigned_to)
|
||||||
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
VALUES ($1, $2, $3, $4::jsonb, $5, 'triggered', $6, $7, $8)
|
||||||
|
ON CONFLICT (team_id, group_key) WHERE resolved_at IS NULL DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
teamID, groupKey, title, string(labelsJSON), incidentSignature(groupLabels, title), severity,
|
||||||
time.Now().Unix(), onCall).Scan(&id)
|
time.Now().Unix(), onCall).Scan(&id)
|
||||||
if err != nil {
|
switch {
|
||||||
|
case err == sql.ErrNoRows:
|
||||||
|
// Lost the race: someone else's incident for this group_key exists now.
|
||||||
|
// Everything below — the trigger event, assignment, page, escalation
|
||||||
|
// clock — already happened for that row when it was created; attach to
|
||||||
|
// it rather than fail this call (and the whole payload) outright.
|
||||||
|
return existingOpenIncident(ctx, q, teamID, groupKey)
|
||||||
|
case err != nil:
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +442,21 @@ func openIncident(ctx context.Context, q querier, notify NotifyConfig, teamID in
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// existingOpenIncident looks up the open incident openIncident's own INSERT just
|
||||||
|
// lost a conflict against — the same lookup incidentForGroup does before ever
|
||||||
|
// calling openIncident, repeated here for the caller that arrived second.
|
||||||
|
func existingOpenIncident(ctx context.Context, q querier, teamID int64, groupKey string) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := q.QueryRowContext(ctx,
|
||||||
|
"SELECT id FROM incidents WHERE team_id = $1 AND group_key = $2 AND resolved_at IS NULL",
|
||||||
|
teamID, groupKey,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
// linkAlert adds an alert to an incident, emitting a timeline entry only the
|
// linkAlert adds an alert to an incident, emitting a timeline entry only the
|
||||||
// first time. Re-sends of an already-linked alert are silent.
|
// first time. Re-sends of an already-linked alert are silent.
|
||||||
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
func linkAlert(ctx context.Context, tx *sql.Tx, incidentID, alertID int64) error {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident reproduces two
|
||||||
|
// replicas racing the very first webhook delivery for a brand-new group_key:
|
||||||
|
// both see no open incident yet (incidentForGroup's own SELECT finds
|
||||||
|
// nothing) and race openIncident's INSERT.
|
||||||
|
//
|
||||||
|
// The DB's own unique index already guarantees at most one incident either
|
||||||
|
// way, with or without this fix — so "exactly one incident" alone cannot
|
||||||
|
// tell a fixed run from a broken one. What ON CONFLICT handling actually
|
||||||
|
// changes is what happens to the *loser*: before it, the loser's INSERT hit
|
||||||
|
// incidents_open_group_key_idx's unique violation, which — since
|
||||||
|
// upsertAlerts ran earlier in that same transaction — rolled back its whole
|
||||||
|
// payload, alert insert included. ingest's error is only logged and
|
||||||
|
// receiveWebhook answers 200 regardless, so nothing ever retried it: the
|
||||||
|
// loser's alert silently never existed. That is the regression signal this
|
||||||
|
// test checks — every caller's fingerprint must show up in /api/alerts, not
|
||||||
|
// just the winner's.
|
||||||
|
func TestWebhook_ConcurrentFirstOccurrenceOpensOneIncident(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
|
||||||
|
const callers = 8
|
||||||
|
const groupKey = "race-group"
|
||||||
|
|
||||||
|
// Every caller needs its own fingerprint. A shared one would serialize all
|
||||||
|
// of them at upsertAlerts' own ON CONFLICT (team_id, fingerprint) row lock,
|
||||||
|
// long before any of them reached incidentForGroup — which would hide the
|
||||||
|
// very race this test exists to force.
|
||||||
|
bodies := make([][]byte, callers)
|
||||||
|
for i := range callers {
|
||||||
|
payload := map[string]any{
|
||||||
|
"version": "4",
|
||||||
|
"status": "firing",
|
||||||
|
"groupKey": groupKey,
|
||||||
|
"groupLabels": map[string]string{"alertname": "RaceAlert"},
|
||||||
|
"alerts": []map[string]any{amAlert(fmt.Sprintf("fp-race-%d", i), "RaceAlert", "firing",
|
||||||
|
"2026-05-20T10:00:00Z", "0001-01-01T00:00:00Z", nil)},
|
||||||
|
}
|
||||||
|
bodies[i], _ = json.Marshal(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A start line, so every request is fired as close to simultaneously as
|
||||||
|
// goroutine scheduling allows, rather than trickling out one dial at a
|
||||||
|
// time — the race window is the gap between incidentForGroup's SELECT and
|
||||||
|
// openIncident's INSERT, which a staggered start could easily miss.
|
||||||
|
var ready sync.WaitGroup
|
||||||
|
start := make(chan struct{})
|
||||||
|
statuses := make([]int, callers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range callers {
|
||||||
|
ready.Add(1)
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
ready.Done()
|
||||||
|
<-start
|
||||||
|
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
|
||||||
|
"application/json", bytes.NewReader(bodies[i]))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("post webhook #%d: %v", i, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
statuses[i] = resp.StatusCode
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
ready.Wait()
|
||||||
|
close(start)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, code := range statuses {
|
||||||
|
if code != http.StatusOK {
|
||||||
|
t.Errorf("webhook #%d returned %d, want 200", i, code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched []any
|
||||||
|
for _, inc := range listIncidents(t, s, "") {
|
||||||
|
if inc["group_key"] == groupKey {
|
||||||
|
matched = append(matched, inc["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(matched) != 1 {
|
||||||
|
t.Fatalf("expected exactly 1 incident for group_key %q after %d concurrent deliveries, got %d: %v",
|
||||||
|
groupKey, callers, len(matched), matched)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alerts []map[string]any
|
||||||
|
decode(t, s.req(t, http.MethodGet, "/api/alerts", nil), &alerts)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, a := range alerts {
|
||||||
|
if fp, ok := a["fingerprint"].(string); ok {
|
||||||
|
seen[fp] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range callers {
|
||||||
|
fp := fmt.Sprintf("fp-race-%d", i)
|
||||||
|
if !seen[fp] {
|
||||||
|
t.Errorf("alert %q is missing: its delivery's whole payload was silently rolled back "+
|
||||||
|
"when it lost the race for the incident", fp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ const (
|
|||||||
// expiryGrace absorbs clock skew and notification latency before an alert
|
// expiryGrace absorbs clock skew and notification latency before an alert
|
||||||
// whose ends_at watermark has passed is treated as stale.
|
// whose ends_at watermark has passed is treated as stale.
|
||||||
expiryGrace = 5 * time.Minute
|
expiryGrace = 5 * time.Minute
|
||||||
|
|
||||||
|
// archiverLockKey is the Postgres advisory lock the sweeper takes for the
|
||||||
|
// duration of each pass, so that running more than one replica does not run
|
||||||
|
// the sweep concurrently on all of them. Its value has no meaning beyond
|
||||||
|
// being distinct from notifierLockKey.
|
||||||
|
archiverLockKey int64 = 7265_0001
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
// StartArchiver runs the alert sweeper until ctx is cancelled, starting with an
|
||||||
@@ -23,15 +29,25 @@ const (
|
|||||||
// the fallback, not the setting: each pass reads the current value from the
|
// the fallback, not the setting: each pass reads the current value from the
|
||||||
// settings table, so an administrator's change takes effect on the next tick
|
// settings table, so an administrator's change takes effect on the next tick
|
||||||
// instead of at the next restart.
|
// instead of at the next restart.
|
||||||
|
//
|
||||||
|
// Each pass runs under archiverLockKey (see withAdvisoryLock), so that on more
|
||||||
|
// than one replica only whichever instance's tick takes the lock first actually
|
||||||
|
// sweeps; the rest skip that tick rather than racing the same pass.
|
||||||
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
func StartArchiver(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Duration, notify NotifyConfig) {
|
||||||
ticker := time.NewTicker(sweepInterval)
|
ticker := time.NewTicker(sweepInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
sweep := func() {
|
||||||
|
withAdvisoryLock(ctx, db, archiverLockKey, "sweeper", func() {
|
||||||
|
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
Sweep(ctx, db, archiveAfter, staleAfter, notify)
|
sweep()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -77,3 +80,39 @@ func decodeJSON(r *http.Request, v any) error {
|
|||||||
func errResp(msg string) map[string]string {
|
func errResp(msg string) map[string]string {
|
||||||
return map[string]string{"error": msg}
|
return map[string]string{"error": msg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withAdvisoryLock runs fn only if it can take the named Postgres advisory lock on a
|
||||||
|
// dedicated connection, and skips fn otherwise. This is what keeps the archiver and
|
||||||
|
// notifier safe to run on more than one replica: whichever instance's tick gets there
|
||||||
|
// first does the work; the rest see the lock held and simply wait for their next tick
|
||||||
|
// instead of running the same pass concurrently.
|
||||||
|
//
|
||||||
|
// pg_try_advisory_lock is session-scoped, so taking and releasing it must happen on the
|
||||||
|
// same connection, reserved via db.Conn rather than borrowed from the pool's shared
|
||||||
|
// connections fn itself may use — and released (unlocked, then closed) before returning,
|
||||||
|
// since a session lock otherwise outlives this call and leaks onto whatever reuses the
|
||||||
|
// pooled connection next.
|
||||||
|
func withAdvisoryLock(ctx context.Context, db *sql.DB, key int64, name string, fn func()) {
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("%s: advisory lock: acquire connection: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var locked bool
|
||||||
|
if err := conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", key).Scan(&locked); err != nil {
|
||||||
|
log.Printf("%s: advisory lock: %v", name, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
return // another replica is already running this pass
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", key); err != nil {
|
||||||
|
log.Printf("%s: advisory unlock: %v", name, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,14 +261,17 @@ func resolveIfSettled(ctx context.Context, q querier, incidentID int64) (bool, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// acknowledgeIncident records that userID has picked an incident up, and reports
|
// acknowledgeIncident records that userID has picked an incident up, and reports
|
||||||
// whether it changed anything — an already-resolved incident is left alone.
|
// whether it changed anything — an already-resolved or already-acknowledged
|
||||||
// Shared by the authenticated handler and the Acknowledge button in a push
|
// incident is left alone, so a second acknowledge (a retried request, or a
|
||||||
// notification, so both write the same state and the same timeline entry.
|
// stale push notification tapped after the web UI already acked it) is a
|
||||||
|
// no-op rather than a second "acknowledged" timeline entry. Shared by the
|
||||||
|
// authenticated handler and the Acknowledge button in a push notification,
|
||||||
|
// so both write the same state and the same timeline entry.
|
||||||
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
func acknowledgeIncident(ctx context.Context, q querier, incidentID, userID int64) (bool, error) {
|
||||||
res, err := q.ExecContext(ctx, `
|
res, err := q.ExecContext(ctx, `
|
||||||
UPDATE incidents
|
UPDATE incidents
|
||||||
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
SET status = 'acknowledged', acknowledged_by = $1, acknowledged_at = $2
|
||||||
WHERE id = $3 AND resolved_at IS NULL`,
|
WHERE id = $3 AND status = 'triggered'`,
|
||||||
userID, time.Now().Unix(), incidentID)
|
userID, time.Now().Unix(), incidentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|||||||
@@ -197,10 +197,20 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !acked {
|
if !acked {
|
||||||
if !incidentExists(w, r, db, id) {
|
// incidentIDParam above already confirmed the incident exists, so this
|
||||||
|
// is either resolved, or already acknowledged — the latter is now a
|
||||||
|
// no-op rather than an error, since the caller's desired state
|
||||||
|
// (acknowledged) already holds.
|
||||||
|
inc, err := fetchIncident(r.Context(), db, id)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
if inc.Status == "resolved" {
|
||||||
|
respond(w, http.StatusConflict, errResp("incident is resolved"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond(w, http.StatusOK, inc)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondIncident(w, r, db, id)
|
respondIncident(w, r, db, id)
|
||||||
|
|||||||
@@ -338,6 +338,48 @@ func TestIncident_Acknowledge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A second acknowledge — a retried request, or a stale push notification
|
||||||
|
// tapped after the web UI already acked it — must be a no-op: same state,
|
||||||
|
// no second "acknowledged" timeline entry. Regression test for the bug
|
||||||
|
// described in issue #26 ("two acknowledged entries look like a bug").
|
||||||
|
func TestIncident_AcknowledgeTwiceIsIdempotent(t *testing.T) {
|
||||||
|
s := newTS(t)
|
||||||
|
postWebhook(t, s, []map[string]any{
|
||||||
|
amAlert("fp-ack2", "Y", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("first acknowledge returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var first map[string]any
|
||||||
|
decode(t, resp, &first)
|
||||||
|
|
||||||
|
resp = s.req(t, http.MethodPost, "/api/incidents/1/acknowledge", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("second acknowledge returned %d, want 200 (idempotent)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var second map[string]any
|
||||||
|
decode(t, resp, &second)
|
||||||
|
if second["status"] != "acknowledged" {
|
||||||
|
t.Errorf("expected status still acknowledged, got %v", second["status"])
|
||||||
|
}
|
||||||
|
if second["acknowledged_by"] != first["acknowledged_by"] {
|
||||||
|
t.Errorf("expected the same acknowledged_by, got %v then %v", first["acknowledged_by"], second["acknowledged_by"])
|
||||||
|
}
|
||||||
|
|
||||||
|
types := eventTypes(timeline(t, s, 1))
|
||||||
|
n := 0
|
||||||
|
for _, ty := range types {
|
||||||
|
if ty == "acknowledged" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("expected exactly one acknowledged event, got %d in %v", n, types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIncident_ManualResolveIsTerminal(t *testing.T) {
|
func TestIncident_ManualResolveIsTerminal(t *testing.T) {
|
||||||
s := newTS(t)
|
s := newTS(t)
|
||||||
postWebhook(t, s, []map[string]any{
|
postWebhook(t, s, []map[string]any{
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ const (
|
|||||||
ackTokenTTL = 24 * time.Hour
|
ackTokenTTL = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// notifierLockKey is the Postgres advisory lock the notifier takes for the
|
||||||
|
// duration of each pass, so that running more than one replica does not
|
||||||
|
// deliver (or double-deliver) the same notification from more than one of
|
||||||
|
// them at once. Its value has no meaning beyond being distinct from
|
||||||
|
// archiverLockKey.
|
||||||
|
const notifierLockKey int64 = 7265_0002
|
||||||
|
|
||||||
// Notification kinds, recording why a push was sent.
|
// Notification kinds, recording why a push was sent.
|
||||||
const (
|
const (
|
||||||
notifyTriggered = "triggered"
|
notifyTriggered = "triggered"
|
||||||
@@ -97,6 +104,10 @@ var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
|||||||
|
|
||||||
// StartNotifier delivers queued notifications until ctx is cancelled, starting
|
// StartNotifier delivers queued notifications until ctx is cancelled, starting
|
||||||
// with an immediate pass so a restart flushes whatever the last one left behind.
|
// with an immediate pass so a restart flushes whatever the last one left behind.
|
||||||
|
//
|
||||||
|
// Each pass runs under notifierLockKey (see withAdvisoryLock), so that on more
|
||||||
|
// than one replica only whichever instance's tick takes the lock first actually
|
||||||
|
// delivers; the rest skip that tick rather than racing the same pass.
|
||||||
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
||||||
if !cfg.enabled() {
|
if !cfg.enabled() {
|
||||||
log.Print("notifier: disabled (no ntfy URL configured)")
|
log.Print("notifier: disabled (no ntfy URL configured)")
|
||||||
@@ -107,11 +118,17 @@ func StartNotifier(ctx context.Context, db *sql.DB, cfg NotifyConfig) {
|
|||||||
ticker := time.NewTicker(notifyInterval)
|
ticker := time.NewTicker(notifyInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
NotifySweep(ctx, db, cfg)
|
sweep := func() {
|
||||||
|
withAdvisoryLock(ctx, db, notifierLockKey, "notifier", func() {
|
||||||
|
NotifySweep(ctx, db, cfg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sweep()
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
NotifySweep(ctx, db, cfg)
|
sweep()
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,12 +66,19 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !acked {
|
if !acked {
|
||||||
// The incident closed between the page and the tap. Nothing to do,
|
// Either the incident closed between the page and the tap, or it was
|
||||||
// and nothing the responder did wrong — report the state, not an error,
|
// already acknowledged (e.g. from the web UI, or an earlier tap of
|
||||||
// so ntfy shows a success toast rather than a failure.
|
// the same button) — either way nothing the responder did wrong, so
|
||||||
|
// report the actual state rather than assuming "resolved", and let
|
||||||
|
// ntfy show a success toast rather than a failure.
|
||||||
|
inc, err := fetchIncident(r.Context(), db, incidentID)
|
||||||
|
if err != nil {
|
||||||
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||||
|
return
|
||||||
|
}
|
||||||
respond(w, http.StatusOK, map[string]any{
|
respond(w, http.StatusOK, map[string]any{
|
||||||
"incident_id": incidentID,
|
"incident_id": incidentID,
|
||||||
"status": "resolved",
|
"status": inc.Status,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,6 +408,47 @@ func TestNotify_AckButtonAcknowledgesIncident(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The ack token isn't single-use (it stays valid for a day, in case the
|
||||||
|
// first tap never reaches the server), so tapping the same notification's
|
||||||
|
// Acknowledge button twice is a real scenario, not just a retried request.
|
||||||
|
// It must report the incident's actual state, not assume "resolved" —
|
||||||
|
// see handleNotifyAck's !acked branch — and must not log a second
|
||||||
|
// "acknowledged" event.
|
||||||
|
func TestNotify_AckButtonTwiceIsIdempotent(t *testing.T) {
|
||||||
|
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
|
|
||||||
|
fireCritical(t, s)
|
||||||
|
s.sweepNotify(t)
|
||||||
|
|
||||||
|
ackURL := f.messages()[0].Actions[0].URL
|
||||||
|
path := ackURL[strings.Index(ackURL, "/api/notify/ack/"):]
|
||||||
|
|
||||||
|
for i := range 2 {
|
||||||
|
resp, err := http.Post(s.URL+path, "application/json", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ack %d: %v", i+1, err)
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
decode(t, resp, &body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("ack %d returned %d", i+1, resp.StatusCode)
|
||||||
|
}
|
||||||
|
if body["status"] != "acknowledged" {
|
||||||
|
t.Errorf("ack %d: expected status acknowledged, got %v", i+1, body["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
n := 0
|
||||||
|
for _, ty := range eventTypes(timeline(t, s, 1)) {
|
||||||
|
if ty == "acknowledged" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("expected exactly one acknowledged event after two taps, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotify_AckRejectsUnknownToken(t *testing.T) {
|
func TestNotify_AckRejectsUnknownToken(t *testing.T) {
|
||||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||||
fireCritical(t, s)
|
fireCritical(t, s)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -62,6 +63,16 @@ func Open(dsn string) (*sql.DB, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// migrationLockKey is the Postgres advisory lock Migrate holds for its whole
|
||||||
|
// run. Two replicas starting at once would otherwise race the check-then-apply
|
||||||
|
// loop below against schema_migrations: the loser could crash on a
|
||||||
|
// duplicate-key insert, or contend with the winner's uncommitted DDL. Blocking
|
||||||
|
// (pg_advisory_lock, not pg_try_advisory_lock as the archiver and notifier
|
||||||
|
// use): on boot there is no later tick to defer to, so the right behaviour is
|
||||||
|
// to wait for the other replica to finish migrating, not to skip ahead and
|
||||||
|
// start serving against an unmigrated schema.
|
||||||
|
const migrationLockKey int64 = 7265_0003
|
||||||
|
|
||||||
// Migrate applies every embedded migration that has not been applied yet, in
|
// Migrate applies every embedded migration that has not been applied yet, in
|
||||||
// filename order, recording each in schema_migrations.
|
// filename order, recording each in schema_migrations.
|
||||||
//
|
//
|
||||||
@@ -69,6 +80,22 @@ func Open(dsn string) (*sql.DB, error) {
|
|||||||
// migration that failed half way used to leave the schema in whatever state it
|
// migration that failed half way used to leave the schema in whatever state it
|
||||||
// had reached. Postgres has transactional DDL, so the rollback is real.
|
// had reached. Postgres has transactional DDL, so the rollback is real.
|
||||||
func Migrate(db *sql.DB) error {
|
func Migrate(db *sql.DB) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migrate: acquire connection: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_lock($1)", migrationLockKey); err != nil {
|
||||||
|
return fmt.Errorf("migrate: acquire advisory lock: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if _, err := conn.ExecContext(ctx, "SELECT pg_advisory_unlock($1)", migrationLockKey); err != nil {
|
||||||
|
log.Printf("migrate: release advisory lock: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version TEXT PRIMARY KEY,
|
version TEXT PRIMARY KEY,
|
||||||
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
applied_at BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM now()))::bigint
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ryuvia.com/niklas/terdut-server/internal/db"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TERDUT_TEST_DSN must point at a database the test role may create schemas
|
||||||
|
// in; see internal/api/testdb_test.go for the fuller rationale this mirrors.
|
||||||
|
// An unset DSN fails rather than skips, deliberately.
|
||||||
|
const testDSNEnv = "TERDUT_TEST_DSN"
|
||||||
|
|
||||||
|
// TestMigrate_ConcurrentCallersDoNotRace reproduces two replicas starting at
|
||||||
|
// once against a brand-new, unmigrated schema: both call db.Migrate at the
|
||||||
|
// same time. Before migrationLockKey, the loser could crash on a
|
||||||
|
// duplicate-key insert into schema_migrations, or contend with the winner's
|
||||||
|
// uncommitted DDL; with the advisory lock, one blocks until the other
|
||||||
|
// finishes and both return cleanly.
|
||||||
|
func TestMigrate_ConcurrentCallersDoNotRace(t *testing.T) {
|
||||||
|
dsn := os.Getenv(testDSNEnv)
|
||||||
|
if dsn == "" {
|
||||||
|
t.Fatalf("%s is not set: these tests need Postgres.\n"+
|
||||||
|
"Run `make test-db` for a local one, then\n"+
|
||||||
|
" export %s=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable",
|
||||||
|
testDSNEnv, testDSNEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
schema := fmt.Sprintf("migrate_race_%d", os.Getpid())
|
||||||
|
admin, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect to %s: %v", testDSNEnv, err)
|
||||||
|
}
|
||||||
|
defer admin.Close()
|
||||||
|
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||||
|
t.Fatalf("create schema %s: %v", schema, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cleanup, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cleanup.Close()
|
||||||
|
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
|
||||||
|
t.Logf("drop schema %s: %v", schema, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
scoped := withSearchPath(dsn, schema)
|
||||||
|
|
||||||
|
const callers = 2
|
||||||
|
errs := make([]error, callers)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range callers {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
database, err := db.Open(scoped)
|
||||||
|
if err != nil {
|
||||||
|
errs[i] = fmt.Errorf("open: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer database.Close()
|
||||||
|
errs[i] = db.Migrate(database)
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Migrate #%d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir("migrations")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migrations dir: %v", err)
|
||||||
|
}
|
||||||
|
var want int
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||||
|
want++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
check, err := sql.Open("pgx", scoped)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect for verification: %v", err)
|
||||||
|
}
|
||||||
|
defer check.Close()
|
||||||
|
|
||||||
|
var got int
|
||||||
|
if err := check.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&got); err != nil {
|
||||||
|
t.Fatalf("count schema_migrations: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("schema_migrations has %d row(s) after two concurrent Migrate calls, want %d (one per migration file, no duplicates)", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSearchPath pins a DSN to one schema. Copied from
|
||||||
|
// internal/api/testdb_test.go rather than shared: that helper lives in the
|
||||||
|
// api_test package, a separate compiled package this one cannot import.
|
||||||
|
func withSearchPath(dsn, schema string) string {
|
||||||
|
opt := "-csearch_path=" + schema
|
||||||
|
|
||||||
|
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err == nil {
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("options", opt)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dsn + " options='" + opt + "'"
|
||||||
|
}
|
||||||
+110
-21
@@ -47,12 +47,27 @@
|
|||||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* A ~4-size type scale, per issue #26's "use one type scale" ask. This
|
||||||
|
file still has a dozen one-off font-size values below; migrating the
|
||||||
|
low-risk, purely cosmetic ones (standalone titles with no dimensional
|
||||||
|
or functional constraint) onto these tokens is a start, not the whole
|
||||||
|
job — the rest (sizes tied to a fixed shape like the avatar circle, to
|
||||||
|
a deliberately prominent display like a stat tile or the on-call name,
|
||||||
|
or to a non-negotiable constraint like the 16px that stops iOS zooming
|
||||||
|
into an input) stay as either their own pixel value or a documented
|
||||||
|
exception, since guessing at those without seeing them render risks
|
||||||
|
trading one inconsistency for a worse one. */
|
||||||
|
--fs-xs: 12px;
|
||||||
|
--fs-sm: 13px;
|
||||||
|
--fs-base: 14px;
|
||||||
|
--fs-lg: 18px;
|
||||||
|
--fs-xl: 21px;
|
||||||
|
|
||||||
--topbar-h: 52px;
|
--topbar-h: 52px;
|
||||||
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
/* The phone-width bottom tab bar's height. Unused above 900px: the
|
||||||
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
desktop block overrides .nav/.view/.toast directly rather than reading
|
||||||
variable rather than deleted since .view, .toast and .nav's own height
|
this back down to 0. */
|
||||||
calc still read it. */
|
--tabbar-h: 58px;
|
||||||
--tabbar-h: 0px;
|
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
@@ -212,9 +227,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
}
|
}
|
||||||
#menu-btn { position: relative; }
|
|
||||||
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
|
||||||
|
|
||||||
.open-pill {
|
.open-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
padding: 3px 10px; border-radius: 999px;
|
padding: 3px 10px; border-radius: 999px;
|
||||||
@@ -226,11 +238,11 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.open-pill.has-triggered::before { background: var(--crit); }
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
.open-pill.all-acked::before { background: var(--warn); }
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
|
||||||
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||||
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
left sidebar from 900px, where the desktop block below redeclares display
|
||||||
left sidebar from 900px, where the desktop block below redeclares display. */
|
and shows every section flat, .nav-link-secondary included. */
|
||||||
.nav {
|
.nav {
|
||||||
display: none;
|
display: flex;
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
padding-bottom: var(--safe-bottom);
|
padding-bottom: var(--safe-bottom);
|
||||||
@@ -240,14 +252,24 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.nav-brand { display: none; }
|
.nav-brand { 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; }
|
||||||
.nav-link {
|
.nav-link {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
/* flex: 1 spreads the tabs evenly across the bar's width; the desktop
|
||||||
|
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;
|
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
||||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||||
/* min-width lets a column shrink below its label's natural width, which is
|
/* min-width lets a column shrink below its label's natural width, which is
|
||||||
what stops six tabs widening the bar past the screen. */
|
what stops six tabs widening the bar past the screen. */
|
||||||
min-width: 0; padding: 0 2px;
|
min-width: 0; padding: 0 2px;
|
||||||
}
|
}
|
||||||
|
/* Must come after .nav-link above: same specificity (one class each), so
|
||||||
|
whichever is later in the file wins for an element wearing both classes,
|
||||||
|
and this needs to beat .nav-link's display:flex here on the phone bar. */
|
||||||
|
.nav-link-secondary { display: none; }
|
||||||
.nav-label {
|
.nav-label {
|
||||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -276,6 +298,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
}
|
}
|
||||||
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; }
|
||||||
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
||||||
|
|
||||||
/* A team's identity colour — not a status, so never the severity palette. Six
|
/* A team's identity colour — not a status, so never the severity palette. Six
|
||||||
colours, then they repeat; teamColorClass() in format.js picks one by the
|
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||||
@@ -303,6 +326,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
/* ---------- chips ---------- */
|
/* ---------- chips ---------- */
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
|
position: relative;
|
||||||
display: flex; gap: 6px;
|
display: flex; gap: 6px;
|
||||||
padding: 12px 16px 8px;
|
padding: 12px 16px 8px;
|
||||||
overflow-x: auto; scrollbar-width: none;
|
overflow-x: auto; scrollbar-width: none;
|
||||||
@@ -318,6 +342,17 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
}
|
}
|
||||||
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
|
||||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||||
|
/* 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
|
||||||
|
item, its sticky offset interacted with the row's gap and its own
|
||||||
|
negative margin, landing short of the edge by about one gap's width. */
|
||||||
|
.chips-fade {
|
||||||
|
position: absolute; top: 0; right: 0; bottom: 0;
|
||||||
|
width: 24px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--bg));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- lists ---------- */
|
/* ---------- lists ---------- */
|
||||||
|
|
||||||
@@ -381,7 +416,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.empty {
|
.empty {
|
||||||
padding: 48px 16px; text-align: center; color: var(--muted);
|
padding: 48px 16px; text-align: center; color: var(--muted);
|
||||||
}
|
}
|
||||||
.empty strong { display: block; color: var(--text); font-size: 16px; margin-bottom: 4px; }
|
.empty strong { display: block; color: var(--text); font-size: var(--fs-lg); margin-bottom: 4px; }
|
||||||
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
|
.empty .icon { width: 36px; height: 36px; color: var(--ok); margin-bottom: 8px; }
|
||||||
|
|
||||||
.load-error {
|
.load-error {
|
||||||
@@ -434,8 +469,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.detail-head .copy { margin-left: auto; }
|
.detail-head .copy { margin-left: auto; }
|
||||||
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
.clip-buffer { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
|
||||||
.detail-title { font-size: 21px; font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
|
.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; }
|
.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 {
|
.card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -450,6 +489,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.facts dt { color: var(--muted); }
|
.facts dt { color: var(--muted); }
|
||||||
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
.facts .sub { color: var(--faint); }
|
.facts .sub { color: var(--faint); }
|
||||||
|
.facts dd.fact-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.fact-chip { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.fact-icon { width: 15px; height: 15px; color: var(--faint); }
|
||||||
|
|
||||||
.section { margin-top: 22px; }
|
.section { margin-top: 22px; }
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -481,6 +523,17 @@ details > summary::before { content: "▸ "; }
|
|||||||
details[open] > summary::before { content: "▾ "; }
|
details[open] > summary::before { content: "▾ "; }
|
||||||
details[open] > summary { margin-bottom: 8px; }
|
details[open] > summary { margin-bottom: 8px; }
|
||||||
|
|
||||||
|
/* One .tl-phase per status the incident has been through (see
|
||||||
|
timelinePhases() in incident.js) — each with its own .timeline <ol>, so
|
||||||
|
the existing :first-child/:last-child rail-capping below gives each phase
|
||||||
|
its own self-contained connecting line rather than one running through
|
||||||
|
the headings. */
|
||||||
|
.tl-phase + .tl-phase { border-top: 1px solid var(--border); }
|
||||||
|
.tl-phase-title {
|
||||||
|
padding: 10px 14px 0;
|
||||||
|
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
.timeline { list-style: none; margin: 0; padding: 4px 0; }
|
.timeline { list-style: none; margin: 0; padding: 4px 0; }
|
||||||
.tl-item {
|
.tl-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -513,7 +566,7 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
}
|
}
|
||||||
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||||
.similar { list-style: none; margin: 0; padding: 0; }
|
.similar { list-style: none; margin: 0; padding: 0; }
|
||||||
.similar-item { padding: 10px 0; }
|
.similar-item { padding: 10px 14px; }
|
||||||
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
.similar-item + .similar-item { border-top: 1px solid var(--border, var(--surface-2)); }
|
||||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||||
.note-actions { display: flex; justify-content: flex-end; }
|
.note-actions { display: flex; justify-content: flex-end; }
|
||||||
@@ -530,6 +583,10 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
}
|
}
|
||||||
.actionbar .btn { min-height: 48px; }
|
.actionbar .btn { min-height: 48px; }
|
||||||
.actionbar .btn-primary { flex: 1; font-size: 16px; }
|
.actionbar .btn-primary { flex: 1; font-size: 16px; }
|
||||||
|
/* The extra actions that only phones hide behind "More" — see actionBar() and
|
||||||
|
extraActions() in incident.js. Hidden by default; the desktop block below
|
||||||
|
shows them and hides the now-redundant More button instead. */
|
||||||
|
.action-extra { display: none; }
|
||||||
|
|
||||||
.detail-placeholder {
|
.detail-placeholder {
|
||||||
display: grid; place-items: center; height: 100%;
|
display: grid; place-items: center; height: 100%;
|
||||||
@@ -552,7 +609,7 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
|
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
|
||||||
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
|
.sheet-inner { padding: 8px 16px calc(16px + var(--safe-bottom)); }
|
||||||
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
|
.sheet-grab { width: 40px; height: 4px; margin: 0 auto 12px; border-radius: 2px; background: var(--border-strong); }
|
||||||
.sheet-title { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
|
.sheet-title { font-size: var(--fs-lg); font-weight: 700; margin: 0 0 4px; }
|
||||||
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
|
.sheet-text { color: var(--muted); margin: 0 0 14px; font-size: 14px; }
|
||||||
.sheet-form { display: grid; gap: 12px; }
|
.sheet-form { display: grid; gap: 12px; }
|
||||||
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
|
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
@@ -604,21 +661,37 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||||
.now-name { font-size: 20px; font-weight: 750; }
|
.now-name { font-size: 20px; font-weight: 750; }
|
||||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
.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 { display: flex; align-items: center; gap: 4px; }
|
||||||
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||||
.days { list-style: none; margin: 0; padding: 0; }
|
/* 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 { 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 + .day { border-top: 1px solid var(--border); }
|
||||||
.day-name { font-weight: 650; }
|
.day-name { font-weight: 650; }
|
||||||
.day-date { color: var(--faint); font-size: 13px; }
|
.day-date { color: var(--faint); font-size: 13px; }
|
||||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
.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 { background: var(--accent-soft); }
|
||||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
.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:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||||
.day.today .day-name { color: var(--accent); }
|
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
||||||
.day.past { opacity: 0.6; }
|
.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); }
|
||||||
|
|
||||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||||
@@ -675,9 +748,11 @@ kbd {
|
|||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
padding: 4px 10px 18px; font-size: 18px; font-weight: 750; letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.nav-team-selector { margin: -8px 10px 14px; width: calc(100% - 20px); }
|
.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-link {
|
.nav-link {
|
||||||
flex-direction: row; justify-content: flex-start; gap: 12px;
|
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
|
||||||
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
|
||||||
color: var(--muted); font-size: 14px;
|
color: var(--muted); font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -701,7 +776,16 @@ kbd {
|
|||||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||||
.pane-list .chip-sep { display: none; }
|
.pane-list .chip-sep { display: none; }
|
||||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
.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
|
||||||
|
around the placeholder text. Selecting an incident (.has-detail) drops
|
||||||
|
back to the base minmax(340,420) 1fr rule above. */
|
||||||
|
.view-queue:not(.has-detail) { grid-template-columns: 1fr; }
|
||||||
|
.view-queue:not(.has-detail) .pane-list { border-right: 0; }
|
||||||
|
/* Full width reads better capped than edge-to-edge on a very wide monitor,
|
||||||
|
matching .detail's own cap below. */
|
||||||
|
.view-queue:not(.has-detail) .list { max-width: 900px; margin: 0 auto; }
|
||||||
|
|
||||||
/* On desktop the list stays visible next to the detail. */
|
/* On desktop the list stays visible next to the detail. */
|
||||||
.app.detail-open .nav { display: flex; }
|
.app.detail-open .nav { display: flex; }
|
||||||
@@ -715,8 +799,12 @@ kbd {
|
|||||||
.actionbar {
|
.actionbar {
|
||||||
position: sticky; bottom: 0;
|
position: sticky; bottom: 0;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.actionbar .btn-primary { flex: 0 1 240px; }
|
.actionbar .btn-primary { flex: 0 1 240px; }
|
||||||
|
/* Room enough to show every action, so More is fully redundant here. */
|
||||||
|
.action-extra { display: inline-flex; }
|
||||||
|
.more-btn { display: none; }
|
||||||
|
|
||||||
.sheet {
|
.sheet {
|
||||||
width: min(440px, calc(100% - 32px));
|
width: min(440px, calc(100% - 32px));
|
||||||
@@ -966,6 +1054,7 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
|||||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
.overview-item p { margin: 4px 0 0; }
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
.overview-note-icon { width: 13px; height: 13px; vertical-align: -2px; color: var(--ok); }
|
||||||
|
|
||||||
/* ---------- stats page ---------- */
|
/* ---------- stats page ---------- */
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,10 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
|
<!-- Secondary: full-width in the desktop sidebar, folded into the
|
||||||
|
"More" tab's sheet on the phone-width bottom bar instead (see
|
||||||
|
.nav-link-secondary in app.css and openNavMenu in app.js). -->
|
||||||
|
<a class="nav-link nav-link-secondary" href="/stats" data-section="stats" aria-label="Stats">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
<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>
|
<span class="nav-label">Stats</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -113,22 +116,26 @@
|
|||||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
unhides it once /api/me says so. The server refuses every admin
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
<a class="nav-link" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
<a class="nav-link nav-link-secondary" href="/admin" data-section="admin" aria-label="Admin" id="nav-admin" hidden>
|
||||||
<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>
|
<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>
|
<span class="nav-label">Admin</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="nav-link" href="/more" data-section="more" aria-label="Account">
|
<a class="nav-link nav-link-secondary" 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>
|
<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-label">Account</span>
|
||||||
</a>
|
</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
|
||||||
|
bar has no room for. Not shown on the desktop sidebar, which lists
|
||||||
|
every section already. -->
|
||||||
|
<button class="nav-link nav-more-btn" id="nav-more-btn" type="button" aria-label="More sections" aria-haspopup="menu">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h.01M12 12h.01M19 12h.01"/></svg>
|
||||||
|
<span class="nav-label">More</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
|
||||||
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
|
||||||
</button>
|
|
||||||
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
<button class="team-selector-mobile" id="team-selector-mobile" type="button" hidden></button>
|
||||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ function render() {
|
|||||||
h('div', { class: 'card' }, shortcuts())),
|
h('div', { class: 'card' }, shortcuts())),
|
||||||
|
|
||||||
h('div', { class: 'page-head' }),
|
h('div', { class: 'page-head' }),
|
||||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
// Wrapped in a div: .btn is inline-flex, and only a block-level element
|
||||||
|
// picks up .view-page > *'s margin:auto centering (see app.css:316).
|
||||||
|
h('div', {}, h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function backLink() {
|
function backLink() {
|
||||||
return h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' }));
|
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||||
|
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||||
|
return h('div', {}, h('a', { class: 'back-link', href: '/admin/teams' }, icon('chevronLeft'), h('span', { text: 'Teams' })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- identity --------------------------------------------------------------
|
// --- identity --------------------------------------------------------------
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function backLink() {
|
function backLink() {
|
||||||
return h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' }));
|
// Wrapped in a div: .back-link is inline-flex, and only a block-level
|
||||||
|
// element picks up .view-page > *'s margin:auto centering (app.css:316).
|
||||||
|
return h('div', {}, h('a', { class: 'back-link', href: '/admin/users' }, icon('chevronLeft'), h('span', { text: 'Users' })));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- identity --------------------------------------------------------------
|
// --- identity --------------------------------------------------------------
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ function render() {
|
|||||||
let body;
|
let body;
|
||||||
if (error && !items) body = h('div', { class: 'load-error', text: error });
|
if (error && !items) body = h('div', { class: 'load-error', text: error });
|
||||||
else if (!items) body = spinner();
|
else if (!items) body = spinner();
|
||||||
else if (!items.length) body = emptyState(filter === 'firing' ? 'Nothing firing' : 'No alerts', '', filter === 'firing' ? 'checkCircle' : null);
|
else if (!items.length) {
|
||||||
|
body = filter === 'firing'
|
||||||
|
? emptyState('Nothing firing', 'No alerts are currently firing.', 'checkCircle')
|
||||||
|
: emptyState('No alerts', 'None match this filter.', 'bell');
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
body = h('div', { class: 'list' },
|
body = h('div', { class: 'list' },
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
|||||||
@@ -36,16 +36,17 @@ const SECTIONS = {
|
|||||||
device: { title: 'Sign in a terminal', view: device },
|
device: { title: 'Sign in a terminal', view: device },
|
||||||
};
|
};
|
||||||
|
|
||||||
// The mobile hamburger menu's contents — the same sections the desktop
|
// The desktop sidebar's .nav-link list in index.html, in the same order.
|
||||||
// sidebar's .nav-link list carries in index.html, in the same order.
|
// `secondary` marks the ones that fold into the phone bottom bar's "More"
|
||||||
|
// sheet (openNavMenu below) instead of getting a tab of their own there.
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||||
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||||
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
|
{ path: '/stats', section: 'stats', label: 'Stats', icon: 'chart', secondary: true },
|
||||||
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true, secondary: true },
|
||||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
{ path: '/more', section: 'more', label: 'Account', icon: 'user', secondary: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseRoute(pathname) {
|
function parseRoute(pathname) {
|
||||||
@@ -152,15 +153,16 @@ function render() {
|
|||||||
|
|
||||||
// ---------- nav menu ----------
|
// ---------- nav menu ----------
|
||||||
|
|
||||||
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
// The phone bottom bar's "More" sheet: same shape as the sheet-based action
|
||||||
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
// menus in incident.js (openSheet + a <ul class="menu"> of menu-item
|
||||||
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
// buttons), one item per secondary NAV_ITEMS entry — the ones the bar itself
|
||||||
|
// has no room for, since Queue/On-call/Alerts/Team already have their own
|
||||||
|
// tab and don't need to be reachable here too.
|
||||||
function openNavMenu() {
|
function openNavMenu() {
|
||||||
const current = SECTIONS[route.section].nav || route.section;
|
const current = SECTIONS[route.section].nav || route.section;
|
||||||
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
const items = NAV_ITEMS.filter((n) => n.secondary && (!n.adminOnly || state.me?.user?.is_admin));
|
||||||
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
|
||||||
ui.openSheet(() => [
|
ui.openSheet(() => [
|
||||||
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
ui.h('h2', { class: 'sheet-title', text: 'More' }),
|
||||||
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||||
ui.h('li', {}, ui.h('button', {
|
ui.h('li', {}, ui.h('button', {
|
||||||
class: 'menu-item',
|
class: 'menu-item',
|
||||||
@@ -171,7 +173,6 @@ function openNavMenu() {
|
|||||||
},
|
},
|
||||||
ui.icon(n.icon),
|
ui.icon(n.icon),
|
||||||
n.label,
|
n.label,
|
||||||
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
|
||||||
))),
|
))),
|
||||||
),
|
),
|
||||||
]).then((path) => {
|
]).then((path) => {
|
||||||
@@ -205,8 +206,8 @@ function updateBadges() {
|
|||||||
pill.classList.toggle('has-triggered', triggered > 0);
|
pill.classList.toggle('has-triggered', triggered > 0);
|
||||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||||
|
|
||||||
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
// Just the Queue tab's own badge now — phone bottom bar and desktop
|
||||||
// hamburger button (mobile) — only one of the two is ever visible at once.
|
// sidebar both read the same [data-badge] span on that one nav-link.
|
||||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||||
badge.hidden = triggered === 0;
|
badge.hidden = triggered === 0;
|
||||||
badge.textContent = String(triggered);
|
badge.textContent = String(triggered);
|
||||||
@@ -230,7 +231,7 @@ async function boot() {
|
|||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
$('signup-form').addEventListener('submit', onSignup);
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
$('menu-btn').addEventListener('click', openNavMenu);
|
$('nav-more-btn').addEventListener('click', openNavMenu);
|
||||||
teamselector.init();
|
teamselector.init();
|
||||||
ssoErrorCode = takeSSOError();
|
ssoErrorCode = takeSSOError();
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
import {
|
import {
|
||||||
ago, when, until, isFuture, severityClass, STATUS_LABEL,
|
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { myID, users } from './state.js';
|
import { myID, users } from './state.js';
|
||||||
import { back } from './app.js';
|
import { back } from './app.js';
|
||||||
@@ -86,6 +86,7 @@ function render() {
|
|||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
h('h1', { class: 'detail-title', text: inc.title }),
|
h('h1', { class: 'detail-title', text: inc.title }),
|
||||||
h('div', { class: 'detail-badges' }, statusBadges()),
|
h('div', { class: 'detail-badges' }, statusBadges()),
|
||||||
|
quickActions(),
|
||||||
facts(),
|
facts(),
|
||||||
groupLabels(),
|
groupLabels(),
|
||||||
alertsSection(),
|
alertsSection(),
|
||||||
@@ -125,6 +126,20 @@ function who(id, name) {
|
|||||||
function facts() {
|
function facts() {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
|
const add = (k, ...v) => rows.push(h('dt', { text: k }), h('dd', {}, ...v));
|
||||||
|
|
||||||
|
// Duration, severity and who's on it, in one scannable row up top — the
|
||||||
|
// rest of this card has each of those too, but spread across rows that
|
||||||
|
// take reading top to bottom to piece together.
|
||||||
|
const elapsedTo = inc.resolved_at ? Date.parse(inc.resolved_at) : Date.now();
|
||||||
|
const responsible = inc.assigned_to_id != null ? who(inc.assigned_to_id, inc.assigned_to)
|
||||||
|
: inc.acknowledged_by_id != null ? who(inc.acknowledged_by_id, inc.acknowledged_by)
|
||||||
|
: '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)}`),
|
||||||
|
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
|
||||||
|
));
|
||||||
|
|
||||||
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
add('Triggered', when(inc.triggered_at), h('span', { class: 'sub', text: ` · ${ago(inc.triggered_at)}` }));
|
||||||
if (inc.acknowledged_at) {
|
if (inc.acknowledged_at) {
|
||||||
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
add('Acknowledged', `${who(inc.acknowledged_by_id, inc.acknowledged_by)} · ${when(inc.acknowledged_at)}`);
|
||||||
@@ -148,9 +163,12 @@ function facts() {
|
|||||||
function groupLabels() {
|
function groupLabels() {
|
||||||
const entries = Object.entries(inc.group_labels || {});
|
const entries = Object.entries(inc.group_labels || {});
|
||||||
if (!entries.length) return null;
|
if (!entries.length) return null;
|
||||||
|
// Collapsed by default, the same disclosure alertItem() below uses for an
|
||||||
|
// alert's own labels — this is background, not something to scan past.
|
||||||
return h('section', { class: 'section' },
|
return h('section', { class: 'section' },
|
||||||
h('h2', { class: 'section-title', text: 'Grouped by' }),
|
h('details', {},
|
||||||
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
|
h('summary', { text: `Grouped by (${entries.length})` }),
|
||||||
|
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v)))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,12 +224,20 @@ function eventText(ev, named = false) {
|
|||||||
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
|
||||||
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
case 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||||
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
|
||||||
|
// Falls through to the generic `${ev.type}: ${ev.detail}` below otherwise
|
||||||
|
// — this just capitalises it and drops the redundant "escalated:" prefix
|
||||||
|
// from detail (already "level 2: alice, bob" or "escalation exhausted: …").
|
||||||
|
case 'escalated': return [`Escalated — ${ev.detail}`];
|
||||||
case 'note': return [strong(person), ' added a note'];
|
case 'note': return [strong(person), ' added a note'];
|
||||||
case 'resolution_note': return [strong(person), ' noted what fixed it'];
|
case 'resolution_note': return [strong(person), ' noted what fixed it'];
|
||||||
case 'notified': {
|
case 'notified': {
|
||||||
const to = person ? strong(person) : 'the fallback topic';
|
const to = person ? strong(person) : 'the fallback topic';
|
||||||
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
if (ev.detail === 'reminder') return ['Reminder sent to ', to];
|
||||||
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
|
if (ev.detail === 'resolved') return ['Resolution sent to ', to];
|
||||||
|
// 'escalated' is a second, later page — the next level firing, not the
|
||||||
|
// same page landing twice — so it reads as a bug unless told apart
|
||||||
|
// from the initial 'triggered' page below.
|
||||||
|
if (ev.detail === 'escalated') return ['Escalation paged ', to];
|
||||||
return ['Paged ', to];
|
return ['Paged ', to];
|
||||||
}
|
}
|
||||||
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
|
case 'notify_failed': return ['Notification failed', ev.detail ? `: ${ev.detail}` : ''];
|
||||||
@@ -236,8 +262,34 @@ function similarSection() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Splits the already-sorted timeline on the status transitions that matter —
|
||||||
|
// first acknowledged, then resolved — so a long incident reads as "before
|
||||||
|
// anyone had it" / "while someone did" / "after it closed" instead of one
|
||||||
|
// undifferentiated list. A later re-acknowledge (after an unacknowledge)
|
||||||
|
// doesn't open a second "Acknowledged" phase; it's still the same spell of
|
||||||
|
// somebody owning it.
|
||||||
|
function timelinePhases(sorted) {
|
||||||
|
const phases = [{ label: 'Triggered', events: [] }];
|
||||||
|
let acked = false;
|
||||||
|
for (const ev of sorted) {
|
||||||
|
if (ev.type === 'acknowledged' && !acked) {
|
||||||
|
phases.push({ label: 'Acknowledged', events: [] });
|
||||||
|
acked = true;
|
||||||
|
} else if (ev.type === 'resolved') {
|
||||||
|
phases.push({ label: 'Resolved', events: [] });
|
||||||
|
}
|
||||||
|
phases[phases.length - 1].events.push(ev);
|
||||||
|
}
|
||||||
|
return phases.filter((p) => p.events.length);
|
||||||
|
}
|
||||||
|
|
||||||
function timelineSection() {
|
function timelineSection() {
|
||||||
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
const sorted = [...events].sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at) || a.id - b.id);
|
||||||
|
const phases = timelinePhases(sorted);
|
||||||
|
// A single phase (the common case: most incidents are acked once and
|
||||||
|
// resolved) names nothing extra — only a split timeline needs the
|
||||||
|
// headings to make sense of.
|
||||||
|
const named = phases.length > 1;
|
||||||
return h('section', { class: 'section' },
|
return h('section', { class: 'section' },
|
||||||
h('h2', { class: 'section-title' },
|
h('h2', { class: 'section-title' },
|
||||||
h('span', { text: 'Timeline' }),
|
h('span', { text: 'Timeline' }),
|
||||||
@@ -245,8 +297,10 @@ function timelineSection() {
|
|||||||
icon('note'), 'Add note')),
|
icon('note'), 'Add note')),
|
||||||
h('div', { class: 'card' },
|
h('div', { class: 'card' },
|
||||||
sorted.length
|
sorted.length
|
||||||
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
|
? phases.map((p) => h('div', { class: 'tl-phase' },
|
||||||
: emptyState('No events yet', '')),
|
named && h('div', { class: 'tl-phase-title', text: p.label }),
|
||||||
|
h('ol', { class: 'timeline' }, p.events.map(timelineItem))))
|
||||||
|
: emptyState('No events yet', 'Nothing has happened on this incident yet.', 'clock')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,27 +419,59 @@ async function copyIncident() {
|
|||||||
const isOpen = () => inc.status !== 'resolved';
|
const isOpen = () => inc.status !== 'resolved';
|
||||||
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
||||||
|
|
||||||
function actionBar() {
|
// primaryAction and secondaryAction are factories, not shared nodes — a
|
||||||
let primary;
|
// button can only live in one place, and quickActions() below needs its own
|
||||||
let secondary;
|
// copy of the primary one rather than the actionbar's.
|
||||||
|
function primaryAction() {
|
||||||
if (inc.status === 'triggered') {
|
if (inc.status === 'triggered') {
|
||||||
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||||
} else if (inc.status === 'acknowledged') {
|
|
||||||
primary = h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
|
||||||
} else {
|
|
||||||
primary = inc.archived_at
|
|
||||||
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
|
|
||||||
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
|
|
||||||
}
|
}
|
||||||
|
if (inc.status === 'acknowledged') {
|
||||||
|
return h('button', { class: 'btn btn-primary', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve');
|
||||||
|
}
|
||||||
|
return inc.archived_at
|
||||||
|
? h('button', { class: 'btn btn-primary', type: 'button', onclick: unarchive }, icon('undo'), 'Unarchive')
|
||||||
|
: h('button', { class: 'btn btn-primary', type: 'button', onclick: archive }, icon('archive'), 'Archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondaryAction() {
|
||||||
if (isOpen()) {
|
if (isOpen()) {
|
||||||
secondary = isSnoozed()
|
return isSnoozed()
|
||||||
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
||||||
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
|
||||||
} else {
|
|
||||||
secondary = h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
|
||||||
}
|
}
|
||||||
const more = h('button', { class: 'btn btn-icon', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'));
|
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
||||||
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop has room to show what a phone folds into the More sheet below — see
|
||||||
|
// the .action-extra/.more-btn rules in app.css. Resolved/archived incidents
|
||||||
|
// already say everything via primaryAction()/secondaryAction(), so there is
|
||||||
|
// nothing extra to surface for them.
|
||||||
|
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')
|
||||||
|
: h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: resolve }, icon('checkCircle'), 'Resolve…'));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionBar() {
|
||||||
|
const more = h('button', { class: 'btn more-btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
||||||
|
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), ...extraActions(), more);
|
||||||
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// every team the viewer is in, because somebody on two rotas wants both.
|
// every team the viewer is in, because somebody on two rotas wants both.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, icon, spinner } from './ui.js';
|
import { h, clear, badge, icon, spinner } from './ui.js';
|
||||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
|
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
||||||
import { myID, currentTeam } from './state.js';
|
import { myID, currentTeam } from './state.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-oncall');
|
const view = () => document.getElementById('view-oncall');
|
||||||
@@ -68,7 +68,7 @@ function render() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function you(userID) {
|
function you(userID) {
|
||||||
return userID === myID() ? h('span', { class: 'you', text: 'you' }) : null;
|
return userID === myID() ? badge('you', 'plain st-oncall you-badge') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One card per team with somebody on call, and a single empty card when there
|
// One card per team with somebody on call, and a single empty card when there
|
||||||
@@ -99,20 +99,48 @@ function nowCard() {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
function weekCard() {
|
function weekCard() {
|
||||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||||
const today = isoDate(new Date());
|
const today = isoDate(new Date());
|
||||||
const days = [];
|
const mine = myID();
|
||||||
for (let i = 0; i < 7; i++) {
|
const days = weekRuns(byDate).map((r) => {
|
||||||
const d = addDays(weekStart, i);
|
const single = r.from === r.to;
|
||||||
const key = isoDate(d);
|
const cls = [
|
||||||
const e = byDate.get(key);
|
'day',
|
||||||
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
|
single ? '' : 'range',
|
||||||
h('span', { class: 'day-name', text: dayName.format(d) }),
|
r.from <= today && today <= r.to ? 'today' : '',
|
||||||
h('span', { class: 'day-date', text: dayDate.format(d) }),
|
r.to < today ? 'past' : '',
|
||||||
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
|
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 thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
return [
|
return [
|
||||||
h('div', { class: 'page-head' },
|
h('div', { class: 'page-head' },
|
||||||
@@ -121,12 +149,12 @@ function weekCard() {
|
|||||||
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
|
||||||
icon('chevronLeft')),
|
icon('chevronLeft')),
|
||||||
h('button', {
|
h('button', {
|
||||||
class: 'btn btn-ghost label',
|
class: 'btn btn-ghost week-label',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
title: 'Back to this week',
|
title: 'Back to this week',
|
||||||
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
|
||||||
text: `Week ${isoWeek(weekStart)}`,
|
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) },
|
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
|
||||||
icon('chevronRight')),
|
icon('chevronRight')),
|
||||||
),
|
),
|
||||||
@@ -135,7 +163,9 @@ function weekCard() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// myShifts groups your upcoming dates into runs of consecutive days.
|
// myShifts groups your upcoming dates into runs of consecutive days, then
|
||||||
|
// splits off the one you're already in — listing it again under "Next
|
||||||
|
// shifts" told people they hadn't started a shift they were already on.
|
||||||
function myShifts() {
|
function myShifts() {
|
||||||
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
|
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
|
||||||
const runs = [];
|
const runs = [];
|
||||||
@@ -145,16 +175,34 @@ function myShifts() {
|
|||||||
else runs.push({ from: date, to: date });
|
else runs.push({ from: date, to: date });
|
||||||
}
|
}
|
||||||
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
|
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
|
||||||
return [
|
const label = (r) => (r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}`);
|
||||||
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
|
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
const current = runs[0] && runs[0].from <= today ? runs[0] : null;
|
||||||
|
const next = current ? runs.slice(1) : runs;
|
||||||
|
|
||||||
|
const currentCard = current ? [
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: 'Current shift' })),
|
||||||
h('div', { class: 'card' },
|
h('div', { class: 'card' },
|
||||||
runs.length
|
h('ul', { class: 'shift-list' }, h('li', {},
|
||||||
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
|
h('span', { text: label(current) }),
|
||||||
|
h('span', { class: 'muted', text: `ends in ${duration(addDays(parse(current.to), 1) - Date.now())}` })))),
|
||||||
|
] : [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
...currentCard,
|
||||||
|
h('div', { class: 'page-head' }, h('h2', { text: current ? 'Next shifts' : 'Your next shifts' })),
|
||||||
|
h('div', { class: 'card' },
|
||||||
|
next.length
|
||||||
|
? h('ul', { class: 'shift-list' }, next.slice(0, 8).map((r) =>
|
||||||
h('li', {},
|
h('li', {},
|
||||||
h('span', { text: r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}` }),
|
h('span', { text: label(r) }),
|
||||||
h('span', { class: 'muted', text: days(r) })),
|
h('span', { class: 'muted', text: days(r) })),
|
||||||
))
|
))
|
||||||
: h('div', { class: 'empty', text: 'Nothing scheduled in the next 60 days.' })),
|
: h('div', {
|
||||||
|
class: 'empty',
|
||||||
|
text: current ? 'Nothing else scheduled in the next 60 days.' : 'Nothing scheduled in the next 60 days.',
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
||||||
import { age, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js';
|
||||||
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
import { state, myID, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import * as onboarding from './onboarding.js';
|
import * as onboarding from './onboarding.js';
|
||||||
import { navigate } from './app.js';
|
import { navigate } from './app.js';
|
||||||
@@ -18,12 +18,12 @@ const FILTERS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const EMPTY = {
|
const EMPTY = {
|
||||||
open: ['All clear', 'Nothing open right now.'],
|
open: ['All clear', 'Nothing open right now.', 'checkCircle'],
|
||||||
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
|
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'],
|
||||||
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
|
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.', 'checkCircle'],
|
||||||
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
|
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.', 'clock'],
|
||||||
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
|
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null],
|
||||||
archived: ['Nothing archived', ''],
|
archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'],
|
||||||
};
|
};
|
||||||
|
|
||||||
onboarding.onRerender(() => renderList());
|
onboarding.onRerender(() => renderList());
|
||||||
@@ -87,6 +87,10 @@ export async function refresh({ fresh = false } = {}) {
|
|||||||
if (requested !== filter) return;
|
if (requested !== filter) return;
|
||||||
error = err.message;
|
error = err.message;
|
||||||
}
|
}
|
||||||
|
// state.open (what the chip counts read) has just been refreshed too, by
|
||||||
|
// whichever caller updated it before calling here — app.js's poll, or the
|
||||||
|
// `cached` branch above.
|
||||||
|
renderChips();
|
||||||
renderList();
|
renderList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,18 +105,32 @@ function setFilter(id) {
|
|||||||
refresh({ fresh: true });
|
refresh({ fresh: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Counts for the three chips derivable from the open list already fetched
|
||||||
|
// for the badges — Snoozed/Resolved/Archived would need a request of their
|
||||||
|
// own, so those chips stay count-less for now.
|
||||||
|
function chipCount(id) {
|
||||||
|
const open = state.selectedTeamID == null
|
||||||
|
? state.open
|
||||||
|
: state.open.filter((i) => i.team_id === state.selectedTeamID);
|
||||||
|
if (id === 'open') return open.length;
|
||||||
|
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
|
||||||
|
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function renderChips() {
|
function renderChips() {
|
||||||
const el = document.getElementById('queue-filters');
|
const el = document.getElementById('queue-filters');
|
||||||
const chips = FILTERS.map((f) =>
|
const chips = FILTERS.map((f) => {
|
||||||
h('button', {
|
const count = chipCount(f.id);
|
||||||
|
return h('button', {
|
||||||
class: 'chip',
|
class: 'chip',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
role: 'tab',
|
role: 'tab',
|
||||||
'aria-selected': String(f.id === filter),
|
'aria-selected': String(f.id === filter),
|
||||||
onclick: () => setFilter(f.id),
|
onclick: () => setFilter(f.id),
|
||||||
text: f.label,
|
text: f.label,
|
||||||
}),
|
}, count != null && h('span', { class: 'count', text: String(count) }));
|
||||||
);
|
});
|
||||||
|
|
||||||
// Somebody in one team has nothing to choose between, so the row of team
|
// 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
|
// chips appears only when there is more than one. The default is all of
|
||||||
@@ -140,6 +158,12 @@ function renderChips() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
||||||
|
|
||||||
clear(el, chips);
|
clear(el, chips);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +179,8 @@ function renderList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
const [title, text] = EMPTY[filter];
|
const [title, text, iconName] = EMPTY[filter];
|
||||||
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
clear(el, checklist, emptyState(title, text, iconName));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(el,
|
clear(el,
|
||||||
@@ -201,9 +225,12 @@ function row(inc, index) {
|
|||||||
dataset: { index: String(index) },
|
dataset: { index: String(index) },
|
||||||
},
|
},
|
||||||
h('div', { class: 'row-title', text: inc.title }),
|
h('div', { class: 'row-title', text: inc.title }),
|
||||||
h('div', { class: 'row-age', title: inc.triggered_at, text: age(inc.triggered_at) }),
|
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
||||||
h('div', { class: 'row-meta' },
|
h('div', { class: 'row-meta' },
|
||||||
status,
|
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)}`),
|
||||||
assignee,
|
assignee,
|
||||||
team,
|
team,
|
||||||
labels && h('span', { class: 'labels', text: labels }),
|
labels && h('span', { class: 'labels', text: labels }),
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function render() {
|
|||||||
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||||
else if (!data) body = spinner();
|
else if (!data) body = spinner();
|
||||||
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
||||||
body = emptyState('No data in this range', '', 'chart');
|
body = emptyState('No data in this range', 'Nothing happened in this window.', 'chart');
|
||||||
} else {
|
} else {
|
||||||
body = h('div', { class: 'stats' },
|
body = h('div', { class: 'stats' },
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function subnav() {
|
|||||||
// to the global team selector in the nav, which is what onTeamChange above
|
// to the global team selector in the nav, which is what onTeamChange above
|
||||||
// reacts to.
|
// reacts to.
|
||||||
function teamPicker() {
|
function teamPicker() {
|
||||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
return h('h1', { class: 'detail-title', text: data.team.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- overview --------------------------------------------------------------
|
// --- overview --------------------------------------------------------------
|
||||||
@@ -204,18 +204,18 @@ function overview() {
|
|||||||
menuCard('/team/rota', 'Rota', null,
|
menuCard('/team/rota', 'Rota', null,
|
||||||
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||||||
menuCard('/team/members', 'Members', (data.members || []).length,
|
menuCard('/team/members', 'Members', (data.members || []).length,
|
||||||
owners === 1 ? 'One owner.' : `${owners} owners.`),
|
owners === 1 ? '1 owner, full access.' : `${owners} owners, full access.`),
|
||||||
menuCard('/team/escalation', 'Escalation', levels || null,
|
menuCard('/team/escalation', 'Escalation', levels || null,
|
||||||
levels
|
levels
|
||||||
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
? `${levels} escalation level${levels === 1 ? '' : 's'}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
||||||
: 'No ladder — nobody but the first person is woken.'),
|
: 'No ladder — nobody but the first person is woken.'),
|
||||||
menuCard('/team/sources', 'Alert sources', keys || null,
|
menuCard('/team/sources', 'Alert sources', keys || null,
|
||||||
keys
|
keys
|
||||||
? (unused ? `${unused} of them never used.` : 'All in use.')
|
? (unused ? `${unused} of ${keys} never used.` : `${keys} key${keys === 1 ? '' : 's'}, all used recently.`)
|
||||||
: 'No key yet, so nothing can reach this team.'),
|
: 'No key yet, so nothing can reach this team.'),
|
||||||
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||||||
switches
|
switches
|
||||||
? (dead ? `${dead} of them silent.` : 'All quiet, as they should be.')
|
? (dead ? `${dead} of ${switches} silent.` : [icon('checkCircle', 'icon overview-note-icon'), ' Dead man’s switch: healthy.'])
|
||||||
: 'Nothing watched.'),
|
: 'Nothing watched.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// between, the same rule every other team-aware control in this app follows;
|
// between, the same rule every other team-aware control in this app follows;
|
||||||
// see state.js's currentTeam() for why nobody with just one ever has to.
|
// see state.js's currentTeam() for why nobody with just one ever has to.
|
||||||
|
|
||||||
import { h, openSheet, closeSheet } from './ui.js';
|
import { h, icon, openSheet, closeSheet } from './ui.js';
|
||||||
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
|
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
|
||||||
import { teamColorClass } from './format.js';
|
import { teamColorClass } from './format.js';
|
||||||
|
|
||||||
@@ -36,6 +36,9 @@ export function render() {
|
|||||||
btn.replaceChildren(
|
btn.replaceChildren(
|
||||||
h('span', { class: dotClass }),
|
h('span', { class: dotClass }),
|
||||||
h('span', { class: 'team-selector-label', text: label }),
|
h('span', { class: 'team-selector-label', text: label }),
|
||||||
|
// Without this the pill reads as a tag rather than something you can
|
||||||
|
// open; the chevron is the only thing that says "dropdown" here.
|
||||||
|
icon('chevronDown', 'icon team-selector-chevron'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const ICONS = {
|
|||||||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||||
chevronRight: ['M9 6l6 6-6 6'],
|
chevronRight: ['M9 6l6 6-6 6'],
|
||||||
|
chevronDown: ['M6 9l6 6 6-6'],
|
||||||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||||||
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
||||||
};
|
};
|
||||||
@@ -186,12 +187,16 @@ export function labelChip(k, v) {
|
|||||||
// One entry in a section's overview: a card that is a link, carrying the count
|
// One entry in a section's overview: a card that is a link, carrying the count
|
||||||
// only that section can state. Both the Admin tab and the Team tab open on one
|
// only that section can state. Both the Admin tab and the Team tab open on one
|
||||||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||||||
|
// note is usually just a string, but can be an array of children instead —
|
||||||
|
// deadman's "healthy" note below pairs text with a status icon.
|
||||||
export function menuCard(href, label, count, note) {
|
export function menuCard(href, label, count, note) {
|
||||||
return h('a', { class: 'card overview-item', href },
|
return h('a', { class: 'card overview-item', href },
|
||||||
h('div', { class: 'overview-head' },
|
h('div', { class: 'overview-head' },
|
||||||
h('strong', { text: label }),
|
h('strong', { text: label }),
|
||||||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||||||
h('p', { class: 'muted small', text: note }),
|
typeof note === 'string'
|
||||||
|
? h('p', { class: 'muted small', text: note })
|
||||||
|
: h('p', { class: 'muted small' }, note),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user