Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1770e5d945 | |||
| 497086cb51 | |||
| e3090d2779 | |||
| 91f03c21e8 | |||
| 4358e84b24 | |||
| f45dc2f925 | |||
| 774fdfcaa8 | |||
| fd26fef1ba | |||
| a9d788cc83 |
+108
-31
@@ -119,38 +119,100 @@ new key, revoke the old one," not "recreate the account."
|
||||
|
||||
### Auth middleware
|
||||
|
||||
`internal/api/middleware.go`'s existing dual resolution (`Authorization: Bearer`
|
||||
→ `apiKeyUser()`, or session cookie → `sessionUser()`, both landing on the same
|
||||
`models.User` + team-membership context) gains a third path: a bearer token that
|
||||
hashes to a `service_account_keys.key_hash` resolves to a distinct principal
|
||||
type, not a synthesized `models.User`. `requireTeamMember`/`requireTeamOwner`
|
||||
treat a matching team-scoped service account as owner-equivalent for that one
|
||||
team (satisfies the same checks a real team owner would), and an instance-scoped
|
||||
one as satisfying `AdminOnly` for team-creation/listing purposes **and** for
|
||||
minting a `team`-scoped service account against any team (`POST
|
||||
/api/service-accounts {"scope":"team","teamID":...}`) — this second permission
|
||||
is what lets an operator-style caller create a team, then immediately mint that
|
||||
team its own narrower credential, without a human in the loop for every team.
|
||||
Neither permission extends to user-management endpoints (`POST /api/users`,
|
||||
`PUT /api/users/{id}/admin`, etc.), which stay human-admin-only. Anywhere
|
||||
identity is recorded for a human (incident timeline
|
||||
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
|
||||
principal is stored and displayed distinctly, e.g. `service-account:terdut-operator`,
|
||||
never coerced into a `user_id` FK.
|
||||
**Revised** (this section originally described an aspiration that didn't
|
||||
match what shipped — `TEAM-LOOKUP.md` already caught one instance of that,
|
||||
and a fuller audit found three more; this is the corrected, as-built
|
||||
description, not the original proposal).
|
||||
|
||||
**Team scope, as implemented, is owner-equivalent for every `requireTeamOwner`
|
||||
endpoint, membership and invites included — nothing server-side carves those
|
||||
two out.** That's broader than what `terdut-operator`'s CRDs actually need
|
||||
(escalation/deadman/integrations/OIDC-bindings only; membership is explicitly
|
||||
never gitops-managed, see its DESIGN.md §4.2), a gap acknowledged rather than
|
||||
closed here: narrowing this to exclude
|
||||
`POST/DELETE /api/teams/{teamID}/members*` and
|
||||
`.../invites*` specifically for a service-account caller is a small, isolated
|
||||
follow-up (special-case those handlers rather than `requireTeamOwner` itself,
|
||||
which every other owner-gated endpoint still wants shared). Until then, what
|
||||
actually keeps membership out of automation's hands is that no operator built
|
||||
against this scope should ever call those two endpoints — not a server-side
|
||||
refusal.
|
||||
`internal/api/middleware.go`'s dual resolution (`Authorization: Bearer` →
|
||||
`apiKeyUser()`, or session cookie → `sessionUser()`) and the service-account
|
||||
path (`serviceAccountFor()`) both resolve into one `Caller` type
|
||||
(`internal/api/caller.go`), not two parallel, un-unified context
|
||||
representations the way an earlier version of this server kept them. Every
|
||||
authorization predicate reads `Caller`'s methods:
|
||||
|
||||
- `Caller.IsAdmin()` — true **only** for a human system administrator, never
|
||||
for a service account of either scope, under any circumstance. `AdminOnly`
|
||||
and `requireSelfOrAdmin` key on this alone — user management
|
||||
(`POST /api/users`, `PUT /api/users/{id}/admin`, etc.) and
|
||||
`GET/PUT /api/admin/settings` stay human-only, forever. The original text
|
||||
here claimed an instance-scoped service account satisfies `AdminOnly` "for
|
||||
team-creation/listing purposes" — that was never true of the shipped code
|
||||
(`TEAM-LOOKUP.md` caught the listing half; the creation half was always a
|
||||
separate, bespoke check in `handleCreateTeam`, not `AdminOnly` itself) and
|
||||
is not being made true now. Don't widen `AdminOnly`: every time this has
|
||||
come up, the fix has been a narrower, purpose-built capability instead
|
||||
(`?name=` lookups for teams and service accounts; now
|
||||
`terdut-operator`'s own invite-minting feature for the one real gap this
|
||||
boundary left — how a human ever gets a first login on a no-OIDC,
|
||||
operator-managed install. See the bottom of "What this unblocks.")
|
||||
- `Caller.IsInstanceServiceAccount()` — true only for an instance-scoped
|
||||
service account, never for a human (including a human admin).
|
||||
`handleCreateTeam` uses exactly this: a human creates a team by being a
|
||||
human (and becomes its owner); an instance-scoped service account creates
|
||||
one with no human owner at all. The two paths are not interchangeable, so
|
||||
this predicate deliberately does not also admit a human admin.
|
||||
- `Caller.Role(teamID)`/`TeamIDs()` — a human's real `team_members` rows, or
|
||||
a team-scoped service account's single synthetic owner membership
|
||||
(`serveAsServiceAccount`). This is what makes `requireTeamMember`/
|
||||
`requireTeamOwner` treat a team-scoped service account as owner-equivalent
|
||||
for that one team, with no separate branch needed in either function.
|
||||
- `Caller.ServiceAccountID()` — used by `OperatorModeBlock` ("any service
|
||||
account passes") and by `callerMayManageServiceAccount`'s self-rotation
|
||||
check.
|
||||
- `Caller.AsHuman()` — the accessor every handler that needs a real
|
||||
`user_id` to act on behalf of must call and check, instead of reading a
|
||||
user off context unconditionally. Before the `Caller` type existed, four
|
||||
handlers did the latter and silently misbehaved for a service-account
|
||||
caller: `handleMe` and `handleTestNotification` 500'd (a zero-value user id
|
||||
that matches no row), `handleDismissOnboarding` silently no-op'd (`UPDATE
|
||||
... WHERE id = 0` affects nothing, still returns 204), and
|
||||
`handleCreateInvite` wrote that same zero value into `invites.created_by`
|
||||
— a real foreign-key violation, not just a wrong answer, since that column
|
||||
is nullable but was never passed as `nil`. All four now call `AsHuman()`
|
||||
and return an explicit 403 ("this endpoint is for human accounts only")
|
||||
or, for the invite case, leave `created_by` `NULL` the same way
|
||||
`handleCreateServiceAccount` already did for the analogous situation.
|
||||
|
||||
**Team scope is owner-equivalent for every `requireTeamOwner` endpoint,
|
||||
membership and invites included — by design, not by an unclosed gap.** An
|
||||
earlier version of this document flagged this as "acknowledged rather than
|
||||
closed," kept in check only by the social convention that nobody *builds*
|
||||
automation against those two routes. That convention is retired:
|
||||
`terdut-operator`'s `TerdutTeam` controller now mints and revokes its own
|
||||
team's invite link through exactly this capability (its existing
|
||||
team-scoped credential, `POST`/`DELETE /api/teams/{teamID}/invites`), which
|
||||
is the real fix for the human-onboarding gap below — not a narrower
|
||||
carve-out of this capability. `service_accounts_test.go`'s
|
||||
`TestServiceAccount_TeamScopeManagesItsOwnInvites` pins it.
|
||||
|
||||
**A team-scoped account can also mint another service account scoped to its
|
||||
own team** (`handleCreateServiceAccount`'s `callerOwnsTeam` branch, which a
|
||||
team-scoped caller already satisfies for its own team via the synthetic
|
||||
membership above). Kept, not restricted, for the same reason: a team-scoped
|
||||
credential is that team's owner's reach, full stop — carving this one
|
||||
capability out while leaving membership/invites alone would be an arbitrary
|
||||
asymmetry. Pinned by
|
||||
`TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam`.
|
||||
|
||||
**`callerMayManageServiceAccount` gained the one load-bearing fix this
|
||||
redesign exists for:** an instance-scoped service account may manage
|
||||
(mint/revoke a key on) *any* team-scoped account, not only one admin, that
|
||||
team's human owner, or the account itself. `handleCreateServiceAccount`
|
||||
already let an instance-scoped caller *create* a team-scoped account for
|
||||
any team; this closes the gap where adopting or rotating one it didn't just
|
||||
create in the same call — exactly `terdut-operator`'s documented
|
||||
adopt-on-409 crash-window recovery (its own `DESIGN.md` §5) — 403'd forever
|
||||
instead of succeeding (`terdut-operator#3`). Pinned by
|
||||
`TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey`.
|
||||
|
||||
Anywhere identity is recorded for a human (incident timeline
|
||||
`acknowledged_by`/`assigned_to`, audit-relevant fields), a service-account
|
||||
caller is still coerced into a bare `user_id` of `0` today — `Caller`'s new
|
||||
`Identity()` accessor exists for exactly this follow-up, but wiring it in
|
||||
needs a schema migration (an actor-attribution column distinct from
|
||||
`user_id`) and is deliberately out of scope here. Tracked separately, not by
|
||||
this document.
|
||||
|
||||
## What this unblocks
|
||||
|
||||
@@ -177,6 +239,21 @@ Directly resolves `terdut-operator` DESIGN.md §6's two broken assumptions:
|
||||
holding many credentials in one place (the operator's namespace) an
|
||||
acceptable trade rather than reintroducing the mirrored design's
|
||||
server-admin-equivalent-everywhere problem.
|
||||
4. **A human can get a first login on a no-OIDC, operator-managed install —
|
||||
without ever touching `AdminOnly` or `/api/admin/settings`.** This was
|
||||
filed as `terdut-server#23` ("no API path to create a human login after
|
||||
bootstrap") and diagnosed, at the time, as this server needing to let a
|
||||
service account through `AdminOnly`. It doesn't: the fix lives entirely
|
||||
in `terdut-operator`, because a team-scoped credential was *already*
|
||||
owner-equivalent for `POST /api/teams/{teamID}/invites`, and invite
|
||||
redemption (`POST /api/signup` with an `invite` token) bypasses
|
||||
`signup_mode` entirely — `terdut-operator` just never grew a feature to
|
||||
use either fact. Its `TerdutTeam` controller now mints and surfaces one
|
||||
via its own existing team-scoped credential (`spec.invite`,
|
||||
`status.inviteSecretRef`, see that repo's own docs), so a human joins a
|
||||
CRD-managed team by a real invite link, the same way anyone else would.
|
||||
`terdut-server#23` is closed with this note once that feature ships — its
|
||||
named routes stay human-only, correctly, not a gap.
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||
# metadata and drives nothing.
|
||||
version: 0.33.1
|
||||
appVersion: "v0.33.1"
|
||||
version: 0.35.0
|
||||
appVersion: "v0.35.0"
|
||||
|
||||
@@ -21,6 +21,23 @@ spec:
|
||||
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
enableServiceLinks: false
|
||||
{{- if .Values.database.waitForPostgres.enabled }}
|
||||
initContainers:
|
||||
- name: wait-for-postgres
|
||||
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
|
||||
env:
|
||||
- name: TERDUT_DB_DSN
|
||||
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
until pg_isready -d "$TERDUT_DB_DSN"; do
|
||||
echo "wait-for-postgres: not ready yet, retrying in 2s"
|
||||
sleep 2
|
||||
done
|
||||
{{- end }}
|
||||
containers:
|
||||
- name: terdut-server
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
|
||||
@@ -33,6 +33,26 @@ database:
|
||||
passwordSecret:
|
||||
name: ""
|
||||
key: password
|
||||
# Blocks the main container from starting until Postgres accepts
|
||||
# connections. Without this, a Deployment created before Postgres has
|
||||
# finished its very first boot -- initdb plus Patroni leader election, on a
|
||||
# from-scratch postgres-operator cluster -- crash-loops a few times: the
|
||||
# app's own ping-retry budget on startup (pingAttempts/pingRetryDelay in
|
||||
# internal/db/db.go) is sized for a much shorter, different race --
|
||||
# NetworkPolicy propagation, a few seconds -- not for genuine first-time
|
||||
# cluster creation, which routinely takes longer, so it exhausts and the
|
||||
# process exits before ever binding its HTTP port. A startupProbe cannot
|
||||
# help here: the crash happens before there is anything to probe.
|
||||
#
|
||||
# pg_isready needs no credentials -- it reports PQPING_OK on anything that
|
||||
# amounts to "a Postgres backend answered", including an auth challenge --
|
||||
# so no PGPASSWORD is wired into this container.
|
||||
waitForPostgres:
|
||||
enabled: true
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "17-alpine"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
|
||||
@@ -279,7 +279,11 @@ type meResponse struct {
|
||||
// between the login form and the app, since it cannot read its own cookie.
|
||||
func handleMe(db *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
user, err := fetchUser(r.Context(), db, caller.ID)
|
||||
if err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// Caller is the one principal type every authorization predicate in this
|
||||
// package reads from. Before this, a human (ctxUser + ctxTeams) and a
|
||||
// service account (ctxServiceAccount + a synthetic ctxTeams entry) were two
|
||||
// parallel, un-unified context representations — every predicate had to
|
||||
// remember which one(s) it needed to check, and the ones that forgot either
|
||||
// 403'd a service account that should have been let through (terdut-server#23,
|
||||
// terdut-operator#3), crashed on an unchecked zero-value user ID (handleMe,
|
||||
// handleTestNotification), or silently no-op'd (handleDismissOnboarding).
|
||||
// serveAs and serveAsServiceAccount now both build exactly one Caller and
|
||||
// store it under one context key; everything else in this file is a read
|
||||
// of one of its methods.
|
||||
type Caller struct {
|
||||
// user is set for a human caller (session cookie or a user's own API
|
||||
// key), nil for a service account of either scope.
|
||||
user *models.User
|
||||
|
||||
// sa is set for a service-account caller, nil for a human.
|
||||
sa *serviceAccountPrincipal
|
||||
|
||||
// memberships is the caller's real team_members rows for a human, or —
|
||||
// for a team-scoped service account — the single synthetic owner
|
||||
// membership serveAsServiceAccount injects (see its own comment for
|
||||
// why). Always nil for an instance-scoped service account: it acts on
|
||||
// teams by id, not by belonging to one.
|
||||
memberships []membership
|
||||
}
|
||||
|
||||
// AsHuman returns the real user behind this caller, or false for a service
|
||||
// account of either scope. Every handler that needs a real user_id to act
|
||||
// on behalf of — not just "is this caller sufficiently privileged" — calls
|
||||
// this and handles the false case explicitly, replacing the unchecked
|
||||
// userFromContext(ctx) zero-value reads that used to silently misbehave for
|
||||
// a service-account caller.
|
||||
func (c Caller) AsHuman() (models.User, bool) {
|
||||
if c.user == nil {
|
||||
return models.User{}, false
|
||||
}
|
||||
return *c.user, true
|
||||
}
|
||||
|
||||
// IsAdmin is true only for a human system administrator — never for a
|
||||
// service account, of either scope, under any circumstance. AdminOnly and
|
||||
// requireSelfOrAdmin key on this and nothing else: user management and
|
||||
// /api/admin/settings stay human-only forever, by design (SERVICE-ACCOUNTS.md).
|
||||
func (c Caller) IsAdmin() bool {
|
||||
return c.user != nil && c.user.IsAdmin
|
||||
}
|
||||
|
||||
// IsInstanceServiceAccount reports whether this caller is specifically an
|
||||
// instance-scoped service account — never true for a human, including a
|
||||
// human admin. handleCreateTeam needs exactly this: a human creates a team
|
||||
// by being a human (and becomes its owner as a side effect), an
|
||||
// instance-scoped service account creates one with no human owner at all;
|
||||
// the two paths are not interchangeable, so this predicate must not also
|
||||
// admit a human admin the way MayActAsInstanceAdmin deliberately does.
|
||||
func (c Caller) IsInstanceServiceAccount() bool {
|
||||
return c.sa != nil && c.sa.scope == models.ServiceAccountScopeInstance
|
||||
}
|
||||
|
||||
// Role reports the caller's role in teamID, and whether they belong to it
|
||||
// at all.
|
||||
func (c Caller) Role(teamID int64) (string, bool) {
|
||||
for _, m := range c.memberships {
|
||||
if m.teamID == teamID {
|
||||
return m.role, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// TeamIDs lists every team this caller belongs to: a human's real
|
||||
// memberships, or a team-scoped service account's own single team. Always
|
||||
// empty for an instance-scoped service account.
|
||||
func (c Caller) TeamIDs() []int64 {
|
||||
ids := make([]int64, 0, len(c.memberships))
|
||||
for _, m := range c.memberships {
|
||||
ids = append(ids, m.teamID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ServiceAccountID reports this caller's own service-account id, for the
|
||||
// "may manage/rotate its own credential" self-check in
|
||||
// callerMayManageServiceAccount, and for OperatorModeBlock's "any service
|
||||
// account passes" rule.
|
||||
func (c Caller) ServiceAccountID() (int64, bool) {
|
||||
if c.sa == nil {
|
||||
return 0, false
|
||||
}
|
||||
return c.sa.id, true
|
||||
}
|
||||
|
||||
// Identity is a stable, log/audit-facing string distinguishing a human
|
||||
// caller from a service account — "user:42" or "service-account:7". Not
|
||||
// wired into any database column today (incidents.go's acknowledged_by/
|
||||
// assigned_to/user_id are explicitly out of scope for this change — that
|
||||
// needs its own schema migration, tracked separately), but this is the one
|
||||
// place in the request path that already knows which kind of caller this
|
||||
// is, and that follow-up will want exactly this accessor.
|
||||
func (c Caller) Identity() string {
|
||||
switch {
|
||||
case c.user != nil:
|
||||
return fmt.Sprintf("user:%d", c.user.ID)
|
||||
case c.sa != nil:
|
||||
return fmt.Sprintf("service-account:%d", c.sa.id)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func callerFromContext(ctx context.Context) (Caller, bool) {
|
||||
c, ok := ctx.Value(ctxCaller).(Caller)
|
||||
return c, ok
|
||||
}
|
||||
@@ -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
|
||||
// whether it changed anything — an already-resolved incident is left alone.
|
||||
// Shared by the authenticated handler and the Acknowledge button in a push
|
||||
// notification, so both write the same state and the same timeline entry.
|
||||
// whether it changed anything — an already-resolved or already-acknowledged
|
||||
// incident is left alone, so a second acknowledge (a retried request, or a
|
||||
// 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) {
|
||||
res, err := q.ExecContext(ctx, `
|
||||
UPDATE incidents
|
||||
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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -197,10 +197,20 @@ func handleIncidentAcknowledge(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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) {
|
||||
s := newTS(t)
|
||||
postWebhook(t, s, []map[string]any{
|
||||
|
||||
+29
-36
@@ -16,10 +16,13 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
ctxUser contextKey = "user"
|
||||
ctxSession contextKey = "session"
|
||||
ctxTeams contextKey = "teams"
|
||||
ctxServiceAccount contextKey = "service_account"
|
||||
// ctxCaller holds the one Caller (see caller.go) every authorization
|
||||
// predicate in this package reads from — a human and a service account
|
||||
// used to be two parallel, un-unified context keys (ctxUser/ctxTeams vs.
|
||||
// ctxServiceAccount); this is why that was a mistake, not a smaller
|
||||
// version of the same idea.
|
||||
ctxCaller contextKey = "caller"
|
||||
ctxSession contextKey = "session"
|
||||
)
|
||||
|
||||
// AuthMiddleware accepts either of the two credentials the server issues: an
|
||||
@@ -179,8 +182,7 @@ func serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, db *sql.
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), ctxTeams, teams)
|
||||
ctx = context.WithValue(ctx, ctxUser, u)
|
||||
ctx := context.WithValue(r.Context(), ctxCaller, Caller{user: &u, memberships: teams})
|
||||
if sessionID != 0 {
|
||||
ctx = context.WithValue(ctx, ctxSession, sessionID)
|
||||
}
|
||||
@@ -192,9 +194,13 @@ func hashToken(token string) string {
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// userFromContext is a thin compatibility wrapper over Caller.AsHuman(), so
|
||||
// every call site written before the Caller abstraction (alerts.go,
|
||||
// incidents.go, schedule.go, stats.go, and more) needs no change and keeps
|
||||
// its exact existing behavior.
|
||||
func userFromContext(ctx context.Context) (models.User, bool) {
|
||||
u, ok := ctx.Value(ctxUser).(models.User)
|
||||
return u, ok
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.AsHuman()
|
||||
}
|
||||
|
||||
// serviceAccountPrincipal is a service account as resolved from its key:
|
||||
@@ -244,26 +250,21 @@ func serviceAccountFor(ctx context.Context, db *sql.DB, token string) (serviceAc
|
||||
// key is only ever set by the client that holds it, never attached by a
|
||||
// browser to a request another site makes.
|
||||
func serveAsServiceAccount(w http.ResponseWriter, r *http.Request, next http.Handler, sa serviceAccountPrincipal) {
|
||||
ctx := r.Context()
|
||||
var memberships []membership
|
||||
if sa.scope == models.ServiceAccountScopeTeam {
|
||||
ctx = context.WithValue(ctx, ctxTeams, []membership{{teamID: sa.teamID, role: models.RoleOwner}})
|
||||
memberships = []membership{{teamID: sa.teamID, role: models.RoleOwner}}
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxServiceAccount, sa)
|
||||
ctx := context.WithValue(r.Context(), ctxCaller, Caller{sa: &sa, memberships: memberships})
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
|
||||
func serviceAccountFromContext(ctx context.Context) (serviceAccountPrincipal, bool) {
|
||||
sa, ok := ctx.Value(ctxServiceAccount).(serviceAccountPrincipal)
|
||||
return sa, ok
|
||||
}
|
||||
|
||||
// isInstanceServiceAccount reports whether the caller is an instance-scoped
|
||||
// service account — the one identity allowed to create a team and mint a
|
||||
// team-scoped account against any of them, the two things system
|
||||
// administration can already do that this extends to automation.
|
||||
// isInstanceServiceAccount is a thin compatibility wrapper over
|
||||
// Caller.IsInstanceServiceAccount(), for call sites outside this package's
|
||||
// core predicates (handleCreateTeam, handleCreateServiceAccount) that
|
||||
// needed this exact, narrow check before the Caller abstraction existed.
|
||||
func isInstanceServiceAccount(ctx context.Context) bool {
|
||||
sa, ok := serviceAccountFromContext(ctx)
|
||||
return ok && sa.scope == models.ServiceAccountScopeInstance
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.IsInstanceServiceAccount()
|
||||
}
|
||||
|
||||
// operatorReason marks a write that operator mode refused as such, distinct
|
||||
@@ -291,7 +292,8 @@ func OperatorModeBlock(cfg config.Config) func(http.Handler) http.Handler {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := serviceAccountFromContext(r.Context()); ok {
|
||||
caller, _ := callerFromContext(r.Context())
|
||||
if _, ok := caller.ServiceAccountID(); ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
@@ -333,24 +335,15 @@ func callerMemberships(ctx context.Context, db *sql.DB, userID int64) ([]members
|
||||
// administration is about accounts, not about reading other people's incidents,
|
||||
// and an admin who needs to see a team's queue can add themselves to it.
|
||||
func callerTeamIDs(ctx context.Context) []int64 {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
ids := make([]int64, 0, len(ms))
|
||||
for _, m := range ms {
|
||||
ids = append(ids, m.teamID)
|
||||
}
|
||||
return ids
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.TeamIDs()
|
||||
}
|
||||
|
||||
// callerRole reports the caller's role in one team, and whether they are in it
|
||||
// at all.
|
||||
func callerRole(ctx context.Context, teamID int64) (string, bool) {
|
||||
ms, _ := ctx.Value(ctxTeams).([]membership)
|
||||
for _, m := range ms {
|
||||
if m.teamID == teamID {
|
||||
return m.role, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
c, _ := callerFromContext(ctx)
|
||||
return c.Role(teamID)
|
||||
}
|
||||
|
||||
// requireTeamMember answers the request and reports false unless the caller
|
||||
|
||||
@@ -66,12 +66,19 @@ func handleNotifyAck(db *sql.DB) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if !acked {
|
||||
// The incident closed between the page and the tap. Nothing to do,
|
||||
// and nothing the responder did wrong — report the state, not an error,
|
||||
// so ntfy shows a success toast rather than a failure.
|
||||
// Either the incident closed between the page and the tap, or it was
|
||||
// already acknowledged (e.g. from the web UI, or an earlier tap of
|
||||
// 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{
|
||||
"incident_id": incidentID,
|
||||
"status": "resolved",
|
||||
"status": inc.Status,
|
||||
})
|
||||
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) {
|
||||
s, _ := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
|
||||
fireCritical(t, s)
|
||||
|
||||
@@ -41,10 +41,14 @@ func callerIsAdmin(ctx context.Context) bool {
|
||||
return ok && u.IsAdmin
|
||||
}
|
||||
|
||||
// callerOwnsTeam reports whether the caller is a human owner of teamID. Built
|
||||
// on callerRole/ctxTeams like requireTeamOwner, but without writing a
|
||||
// response: callers here need to combine it with other ways of being
|
||||
// allowed, not stop at the first no.
|
||||
// callerOwnsTeam reports whether the caller is owner-equivalent for teamID:
|
||||
// a human owner, or that team's own team-scoped service account (its single
|
||||
// synthetic membership, serveAsServiceAccount — ratified in
|
||||
// SERVICE-ACCOUNTS.md as intentional, not an accident: a team-scoped
|
||||
// credential is that team's owner's reach, full stop, membership and
|
||||
// invites included). Built on callerRole like requireTeamOwner, but without
|
||||
// writing a response: callers here need to combine it with other ways of
|
||||
// being allowed, not stop at the first no.
|
||||
func callerOwnsTeam(ctx context.Context, teamID int64) bool {
|
||||
role, ok := callerRole(ctx, teamID)
|
||||
return ok && role == models.RoleOwner
|
||||
@@ -173,9 +177,24 @@ func fetchServiceAccount(ctx context.Context, db *sql.DB, id int64) (models.Serv
|
||||
|
||||
// callerMayManageServiceAccount reports whether the caller may mint or revoke
|
||||
// a key on sa: a system administrator, that team-scoped account's own human
|
||||
// owner, or the account rotating its own credential — which is not a
|
||||
// privilege escalation, the same reasoning requireSelfOrAdmin already rests
|
||||
// on for a user's own API keys.
|
||||
// owner, the account rotating its own credential (not a privilege
|
||||
// escalation, the same reasoning requireSelfOrAdmin already rests on for a
|
||||
// user's own API keys) — or, new, an instance-scoped service account
|
||||
// managing any team-scoped account.
|
||||
//
|
||||
// That last branch closes terdut-operator#3: handleCreateServiceAccount
|
||||
// already lets an instance-scoped caller *create* a team-scoped account for
|
||||
// any team (the branch below it, isInstanceServiceAccount(ctx)) — this
|
||||
// account didn't have an equivalent reach to *adopt or rotate* one it
|
||||
// didn't just create in the same call, which is exactly the recovery path
|
||||
// terdut-operator's own documented crash-window handling depends on
|
||||
// (DESIGN.md §5's general adopt-on-conflict rule): a reconcile that creates
|
||||
// the account successfully but crashes before persisting its credential
|
||||
// locally retries into a 409, and without this branch the only available
|
||||
// recovery — minting a fresh key on the now-existing account — 403'd
|
||||
// forever, with no way out. Granting it here is not a new power: it
|
||||
// mirrors the create-time reach this scope already has, just extended to
|
||||
// the retry path DESIGN.md's own crash-window reasoning requires.
|
||||
func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount) bool {
|
||||
if callerIsAdmin(ctx) {
|
||||
return true
|
||||
@@ -183,7 +202,11 @@ func callerMayManageServiceAccount(ctx context.Context, sa models.ServiceAccount
|
||||
if sa.TeamID != nil && callerOwnsTeam(ctx, *sa.TeamID) {
|
||||
return true
|
||||
}
|
||||
if self, ok := serviceAccountFromContext(ctx); ok && self.id == sa.ID {
|
||||
caller, _ := callerFromContext(ctx)
|
||||
if id, ok := caller.ServiceAccountID(); ok && id == sa.ID {
|
||||
return true
|
||||
}
|
||||
if sa.TeamID != nil && caller.IsInstanceServiceAccount() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -181,10 +181,136 @@ func TestServiceAccount_TeamScopeManagesItsResources(t *testing.T) {
|
||||
resp2.Body.Close()
|
||||
}
|
||||
|
||||
// Documents the capability already granted at create time (handleCreateServiceAccount's
|
||||
// own callerOwnsTeam branch) also applies here: a team-scoped account is that
|
||||
// team's owner's reach, membership and further accounts included, not just
|
||||
// the handful of endpoints exercised above. Kept, not restricted, for
|
||||
// symmetry with the now-ratified membership/invite capability below.
|
||||
func TestServiceAccount_TeamScopeCanMintAnotherAccountForItsOwnTeam(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/service-accounts",
|
||||
map[string]any{"name": "team-a-sa-2", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("team-scoped account minting another account for its own team: %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// SERVICE-ACCOUNTS.md ratifies this explicitly: a team-scoped account is
|
||||
// owner-equivalent for every requireTeamOwner endpoint, membership and
|
||||
// invites included — terdut-operator's own invite-minting feature depends on
|
||||
// exactly this. No test exercised handleCreateInvite from a service account
|
||||
// before this change, and it would have 500'd (created_by written as a bare
|
||||
// zero value against a NOT-validated-but-FK'd column) rather than succeeded;
|
||||
// see the signup_test.go addition for that half.
|
||||
func TestServiceAccount_TeamScopeManagesItsOwnInvites(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
var invite struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
resp := s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("team-scoped account creating an invite: %d", resp.StatusCode)
|
||||
}
|
||||
decode(t, resp, &invite)
|
||||
|
||||
var list []map[string]any
|
||||
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/teams/"+id64(teamA)+"/invites", nil), &list)
|
||||
if len(list) != 1 {
|
||||
t.Errorf("expected the invite to list back, got %d", len(list))
|
||||
}
|
||||
|
||||
if resp := s.reqAs(t, keyA, http.MethodDelete,
|
||||
"/api/teams/"+id64(teamA)+"/invites/"+id64(invite.ID), nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Errorf("team-scoped account revoking its own invite: %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key rotation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// terdut-operator#3: an instance-scoped account is already trusted to CREATE
|
||||
// a team-scoped account for any team (handleCreateServiceAccount's own
|
||||
// isInstanceServiceAccount branch) — this pins that it is equally trusted to
|
||||
// manage/rotate a key on one that already exists and that it did not just
|
||||
// create in this call, which is the exact shape of terdut-operator's own
|
||||
// crash-window recovery (mint succeeds, a later step is interrupted before
|
||||
// persisting the credential locally, and the next reconcile retries into a
|
||||
// 409 then needs to mint a fresh key on the now-existing account). Before
|
||||
// this fix, the second POST .../keys below 403'd forever.
|
||||
func TestServiceAccount_InstanceScopeAdoptsAnExistingTeamScopedAccountsKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
|
||||
resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/service-accounts",
|
||||
map[string]any{"name": "team-a-sa", "scope": models.ServiceAccountScopeTeam, "team_id": teamA})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create team-scoped account: %d", resp.StatusCode)
|
||||
}
|
||||
var created struct {
|
||||
ServiceAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"service_account"`
|
||||
}
|
||||
decode(t, resp, &created)
|
||||
|
||||
// Simulates the adopt-on-409 recovery path: this instance-scoped caller
|
||||
// did not just create this account in this call (a fresh *tdclient.Client
|
||||
// request, same as a second, independent reconcile would issue), yet
|
||||
// still needs to mint it a fresh key.
|
||||
rotateResp := s.reqAs(t, instanceKey, http.MethodPost,
|
||||
"/api/service-accounts/"+id64(created.ServiceAccount.ID)+"/keys", map[string]string{"name": "adopted"})
|
||||
if rotateResp.StatusCode != http.StatusCreated {
|
||||
t.Errorf("instance-scoped account adopting a team-scoped account's key: %d", rotateResp.StatusCode)
|
||||
}
|
||||
rotateResp.Body.Close()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AdminOnly / requireSelfOrAdmin — unchanged after the Caller refactor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The Caller abstraction must not have widened AdminOnly/requireSelfOrAdmin:
|
||||
// user management and /api/admin/settings stay human-only, for every scope
|
||||
// of service account, exactly as before.
|
||||
func TestAdminOnly_RefusesEveryServiceAccountScope(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
teamKey := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
for _, key := range []string{instanceKey, teamKey} {
|
||||
if resp := s.reqAs(t, key, http.MethodGet, "/api/admin/settings", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account reading /api/admin/settings, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, key, http.MethodPost, "/api/users",
|
||||
map[string]string{"username": "nope", "email": "nope@example.com"}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account creating a user, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, key, http.MethodGet, "/api/me", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account calling /api/me, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceAccount_SelfRotatesItsOwnKey(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
+24
-4
@@ -359,7 +359,19 @@ func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
}
|
||||
caller, _ := userFromContext(r.Context())
|
||||
// created_by is nullable (ON DELETE SET NULL) for exactly this
|
||||
// reason: the caller minting an invite is not always a human with a
|
||||
// real users row. A team-scoped service account is owner-equivalent
|
||||
// here (requireTeamOwner above already let it through), and this
|
||||
// must leave created_by NULL for one the same way
|
||||
// handleCreateServiceAccount already does for the analogous case —
|
||||
// an unchecked zero value would violate the users(id) foreign key
|
||||
// instead of recording "nobody" cleanly.
|
||||
var createdBy *int64
|
||||
if u, ok := userFromContext(r.Context()); ok {
|
||||
id := u.ID
|
||||
createdBy = &id
|
||||
}
|
||||
expires := time.Now().Add(inviteTTL)
|
||||
|
||||
var out inviteJSON
|
||||
@@ -368,7 +380,7 @@ func handleCreateInvite(db *sql.DB, publicURL string) http.HandlerFunc {
|
||||
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
|
||||
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
|
||||
hash, teamID, req.Role, createdBy, expires.Unix(), req.MaxUses).
|
||||
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
|
||||
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
||||
return
|
||||
@@ -429,7 +441,11 @@ func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
|
||||
errResp("this server has no ntfy configured, so it can send nothing"))
|
||||
return
|
||||
}
|
||||
caller, _ := userFromContext(r.Context())
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
|
||||
var topic *string
|
||||
if err := db.QueryRowContext(r.Context(),
|
||||
@@ -470,7 +486,11 @@ func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
|
||||
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
|
||||
return
|
||||
}
|
||||
caller, _ := userFromContext(r.Context())
|
||||
caller, ok := userFromContext(r.Context())
|
||||
if !ok {
|
||||
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if *req.Dismissed {
|
||||
|
||||
@@ -2,10 +2,14 @@ package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"testing"
|
||||
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/api"
|
||||
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
||||
)
|
||||
|
||||
// signup posts to the unauthenticated sign-up endpoint, the way the form does,
|
||||
@@ -268,3 +272,55 @@ func TestSignup_ValidatesLikeTheRestOfTheServer(t *testing.T) {
|
||||
t.Errorf("an existing username: expected 409, got %d", taken.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A team-scoped service account has no users row to attribute created_by to.
|
||||
// Before this fix, handleCreateInvite wrote its zero-value caller.ID straight
|
||||
// into that (nullable, ON DELETE SET NULL) foreign key instead of leaving it
|
||||
// NULL the way handleCreateServiceAccount already does for the same
|
||||
// situation — a 500, not the 201 TestServiceAccount_TeamScopeManagesItsOwnInvites
|
||||
// now confirms. This pins the column itself ends up NULL, not just "some
|
||||
// response came back".
|
||||
func TestSignup_InviteCreatedByAServiceAccountLeavesCreatedByNull(t *testing.T) {
|
||||
s := newTS(t)
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
teamA := createTeamAs(t, s, instanceKey, "team-a")
|
||||
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
|
||||
|
||||
var created struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/invites", map[string]any{}), &created)
|
||||
|
||||
var createdBy sql.NullInt64
|
||||
if err := s.db.QueryRow("SELECT created_by FROM invites WHERE id = $1", created.ID).Scan(&createdBy); err != nil {
|
||||
t.Fatalf("read back invites.created_by: %v", err)
|
||||
}
|
||||
if createdBy.Valid {
|
||||
t.Errorf("expected created_by to be NULL for a service-account-minted invite, got %d", createdBy.Int64)
|
||||
}
|
||||
}
|
||||
|
||||
// Neither of these has a real user_id to act on behalf of; both must 403 a
|
||||
// service account explicitly rather than 500 (handleTestNotification, which
|
||||
// used to query ntfy_topic for user id 0) or silently no-op (handleDismissOnboarding,
|
||||
// which used to UPDATE ... WHERE id = 0, affecting nothing and still
|
||||
// returning 204).
|
||||
func TestServiceAccount_HumanOnlyEndpointsRefuseExplicitly(t *testing.T) {
|
||||
// BaseURL set (even to a fake, unreachable address) so handleTestNotification
|
||||
// reaches its AsHuman() check instead of short-circuiting on "ntfy not
|
||||
// configured" first — this test is about the human-only check, not ntfy.
|
||||
s := newTS(t, api.NotifyConfig{BaseURL: "http://ntfy.invalid"})
|
||||
instanceKey := createServiceAccount(t, s, s.key, "terdut-operator", models.ServiceAccountScopeInstance, 0)
|
||||
|
||||
if resp := s.reqAs(t, instanceKey, http.MethodPost, "/api/me/notify/test", nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account testing notifications, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
if resp := s.reqAs(t, instanceKey, http.MethodPut, "/api/me/onboarding",
|
||||
map[string]bool{"dismissed": true}); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("expected 403 for a service account dismissing onboarding, got %d", resp.StatusCode)
|
||||
} else {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
+88
-19
@@ -47,12 +47,27 @@
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--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;
|
||||
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||
variable rather than deleted since .view, .toast and .nav's own height
|
||||
calc still read it. */
|
||||
--tabbar-h: 0px;
|
||||
/* The phone-width bottom tab bar's height. Unused above 900px: the
|
||||
desktop block overrides .nav/.view/.toast directly rather than reading
|
||||
this back down to 0. */
|
||||
--tabbar-h: 58px;
|
||||
--safe-top: env(safe-area-inset-top, 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;
|
||||
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 {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
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.all-acked::before { background: var(--warn); }
|
||||
|
||||
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
|
||||
left sidebar from 900px, where the desktop block below redeclares display
|
||||
and shows every section flat, .nav-link-secondary included. */
|
||||
.nav {
|
||||
display: none;
|
||||
display: flex;
|
||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||
height: calc(var(--tabbar-h) + 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);
|
||||
}
|
||||
.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 {
|
||||
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;
|
||||
color: var(--faint); font-size: 11px; font-weight: 600;
|
||||
/* min-width lets a column shrink below its label's natural width, which is
|
||||
what stops six tabs widening the bar past the screen. */
|
||||
min-width: 0; padding: 0 2px;
|
||||
}
|
||||
/* 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 {
|
||||
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-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
|
||||
colours, then they repeat; teamColorClass() in format.js picks one by the
|
||||
@@ -318,6 +341,14 @@ 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 .count { margin-left: 4px; opacity: 0.7; }
|
||||
/* Pinned to the visible right edge of the scrolling row (sticky, not
|
||||
absolute, so it tracks the scroll position rather than the content). */
|
||||
.chips-fade {
|
||||
position: sticky; right: -1px; flex: none;
|
||||
width: 24px; margin-left: -24px;
|
||||
background: linear-gradient(to right, transparent, var(--bg));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---------- lists ---------- */
|
||||
|
||||
@@ -381,7 +412,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.empty {
|
||||
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; }
|
||||
|
||||
.load-error {
|
||||
@@ -434,8 +465,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.detail-head .copy { margin-left: auto; }
|
||||
.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-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; }
|
||||
/* A copy of the sticky actionbar's primary button, right under the status
|
||||
it responds to — see quickActions() in incident.js. */
|
||||
.detail-quick-actions { margin-bottom: 14px; }
|
||||
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
@@ -450,6 +485,9 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.facts dt { color: var(--muted); }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.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-title {
|
||||
@@ -481,6 +519,17 @@ details > summary::before { content: "▸ "; }
|
||||
details[open] > summary::before { content: "▾ "; }
|
||||
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; }
|
||||
.tl-item {
|
||||
position: relative;
|
||||
@@ -513,7 +562,7 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
}
|
||||
.note-fix { background: var(--ok-soft); border-left: 3px solid var(--ok); }
|
||||
.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)); }
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--muted); }
|
||||
.note-actions { display: flex; justify-content: flex-end; }
|
||||
@@ -552,7 +601,7 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
@keyframes sheet-up { from { transform: translateY(24px); opacity: 0.6; } }
|
||||
.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-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-form { display: grid; gap: 12px; }
|
||||
.sheet-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
@@ -604,9 +653,18 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
.now-name { font-size: 20px; font-weight: 750; }
|
||||
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; }
|
||||
/* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge
|
||||
is easier to spot there than this plain accent-coloured text. */
|
||||
.you-badge { margin-left: 6px; }
|
||||
|
||||
.week-nav { display: flex; align-items: center; gap: 4px; }
|
||||
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; }
|
||||
/* 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; padding: 0; }
|
||||
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; }
|
||||
.day + .day { border-top: 1px solid var(--border); }
|
||||
@@ -614,11 +672,18 @@ details[open] > summary { margin-bottom: 8px; }
|
||||
.day-date { color: var(--faint); font-size: 13px; }
|
||||
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.day-who.nobody { color: var(--faint); font-style: italic; }
|
||||
/* A run of several days held by the same person (or left empty), replacing
|
||||
what used to be one identical row per day — see weekRuns() in oncall.js. */
|
||||
.day.range { grid-template-columns: 1fr auto; }
|
||||
.day-range { font-weight: 650; }
|
||||
.day.today { background: var(--accent-soft); }
|
||||
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
|
||||
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); }
|
||||
.day.today .day-name { color: var(--accent); }
|
||||
.day.today .day-name, .day.today .day-range { color: var(--accent); }
|
||||
.day.past { opacity: 0.6; }
|
||||
/* Highlights whichever row is yours, same soft tint as .today — they already
|
||||
read fine layered (today's own row is almost always one of yours too). */
|
||||
.day.mine { background: var(--accent-soft); }
|
||||
|
||||
.shift-list { list-style: none; margin: 0; padding: 0; }
|
||||
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
|
||||
@@ -675,9 +740,11 @@ kbd {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
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 {
|
||||
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);
|
||||
color: var(--muted); font-size: 14px;
|
||||
}
|
||||
@@ -701,6 +768,7 @@ kbd {
|
||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||
.pane-list .chip-sep { display: none; }
|
||||
.chips-fade { display: none; }
|
||||
.view-queue:not(.has-detail) .pane-detail { display: block; }
|
||||
|
||||
/* On desktop the list stays visible next to the detail. */
|
||||
@@ -966,6 +1034,7 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||
.overview-item p { margin: 4px 0 0; }
|
||||
.overview-note-icon { width: 13px; height: 13px; vertical-align: -2px; color: var(--ok); }
|
||||
|
||||
/* ---------- 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>
|
||||
<span class="nav-label">Alerts</span>
|
||||
</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>
|
||||
<span class="nav-label">Stats</span>
|
||||
</a>
|
||||
@@ -113,22 +116,26 @@
|
||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||
unhides it once /api/me says so. The server refuses every admin
|
||||
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>
|
||||
<span class="nav-label">Admin</span>
|
||||
</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>
|
||||
<span class="nav-label">Account</span>
|
||||
</a>
|
||||
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same
|
||||
sheet the old hamburger button did, for the sections the bottom
|
||||
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>
|
||||
|
||||
<header class="topbar">
|
||||
<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>
|
||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,11 @@ function render() {
|
||||
let body;
|
||||
if (error && !items) body = h('div', { class: 'load-error', text: error });
|
||||
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 {
|
||||
body = h('div', { class: 'list' },
|
||||
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 },
|
||||
};
|
||||
|
||||
// The mobile hamburger menu's contents — the same sections the desktop
|
||||
// sidebar's .nav-link list carries in index.html, in the same order.
|
||||
// The desktop sidebar's .nav-link list 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 = [
|
||||
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||
{ 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: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true, secondary: true },
|
||||
{ path: '/more', section: 'more', label: 'Account', icon: 'user', secondary: true },
|
||||
];
|
||||
|
||||
function parseRoute(pathname) {
|
||||
@@ -152,15 +153,16 @@ function render() {
|
||||
|
||||
// ---------- nav menu ----------
|
||||
|
||||
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
||||
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
||||
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
||||
// The phone bottom bar's "More" sheet: same shape as the sheet-based action
|
||||
// menus in incident.js (openSheet + a <ul class="menu"> of menu-item
|
||||
// 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() {
|
||||
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.adminOnly || state.me?.user?.is_admin);
|
||||
const items = NAV_ITEMS.filter((n) => n.secondary && (!n.adminOnly || state.me?.user?.is_admin));
|
||||
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('li', {}, ui.h('button', {
|
||||
class: 'menu-item',
|
||||
@@ -171,7 +173,6 @@ function openNavMenu() {
|
||||
},
|
||||
ui.icon(n.icon),
|
||||
n.label,
|
||||
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
||||
))),
|
||||
),
|
||||
]).then((path) => {
|
||||
@@ -205,8 +206,8 @@ function updateBadges() {
|
||||
pill.classList.toggle('has-triggered', triggered > 0);
|
||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||
|
||||
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
||||
// hamburger button (mobile) — only one of the two is ever visible at once.
|
||||
// Just the Queue tab's own badge now — phone bottom bar and desktop
|
||||
// sidebar both read the same [data-badge] span on that one nav-link.
|
||||
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||
badge.hidden = triggered === 0;
|
||||
badge.textContent = String(triggered);
|
||||
@@ -230,7 +231,7 @@ async function boot() {
|
||||
document.addEventListener('keydown', onKey);
|
||||
$('login-form').addEventListener('submit', onLogin);
|
||||
$('signup-form').addEventListener('submit', onSignup);
|
||||
$('menu-btn').addEventListener('click', openNavMenu);
|
||||
$('nav-more-btn').addEventListener('click', openNavMenu);
|
||||
teamselector.init();
|
||||
ssoErrorCode = takeSSOError();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
} from './ui.js';
|
||||
import {
|
||||
ago, when, until, isFuture, severityClass, STATUS_LABEL,
|
||||
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL,
|
||||
} from './format.js';
|
||||
import { myID, users } from './state.js';
|
||||
import { back } from './app.js';
|
||||
@@ -86,6 +86,7 @@ function render() {
|
||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||
h('h1', { class: 'detail-title', text: inc.title }),
|
||||
h('div', { class: 'detail-badges' }, statusBadges()),
|
||||
quickActions(),
|
||||
facts(),
|
||||
groupLabels(),
|
||||
alertsSection(),
|
||||
@@ -125,6 +126,20 @@ function who(id, name) {
|
||||
function facts() {
|
||||
const rows = [];
|
||||
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)}` }));
|
||||
if (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() {
|
||||
const entries = Object.entries(inc.group_labels || {});
|
||||
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' },
|
||||
h('h2', { class: 'section-title', text: 'Grouped by' }),
|
||||
h('div', { class: 'labels-wrap' }, entries.map(([k, v]) => labelChip(k, v))),
|
||||
h('details', {},
|
||||
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 'unsnoozed': return [strong(person), ' ended the snooze'];
|
||||
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 'resolution_note': return [strong(person), ' noted what fixed it'];
|
||||
case 'notified': {
|
||||
const to = person ? strong(person) : 'the fallback topic';
|
||||
if (ev.detail === 'reminder') return ['Reminder 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];
|
||||
}
|
||||
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() {
|
||||
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' },
|
||||
h('h2', { class: 'section-title' },
|
||||
h('span', { text: 'Timeline' }),
|
||||
@@ -245,8 +297,10 @@ function timelineSection() {
|
||||
icon('note'), 'Add note')),
|
||||
h('div', { class: 'card' },
|
||||
sorted.length
|
||||
? h('ol', { class: 'timeline' }, sorted.map(timelineItem))
|
||||
: emptyState('No events yet', '')),
|
||||
? phases.map((p) => h('div', { class: 'tl-phase' },
|
||||
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,43 @@ async function copyIncident() {
|
||||
const isOpen = () => inc.status !== 'resolved';
|
||||
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
|
||||
|
||||
function actionBar() {
|
||||
let primary;
|
||||
let secondary;
|
||||
// primaryAction and secondaryAction are factories, not shared nodes — a
|
||||
// button can only live in one place, and quickActions() below needs its own
|
||||
// copy of the primary one rather than the actionbar's.
|
||||
function primaryAction() {
|
||||
if (inc.status === 'triggered') {
|
||||
primary = 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');
|
||||
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
|
||||
}
|
||||
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()) {
|
||||
secondary = isSnoozed()
|
||||
return isSnoozed()
|
||||
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
|
||||
: 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'));
|
||||
const bar = h('div', { class: 'actionbar' }, primary, secondary, more);
|
||||
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note');
|
||||
}
|
||||
|
||||
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
|
||||
// right away, next to the status it responds to. The sticky actionbar below
|
||||
// keeps carrying every action, primary included, for whenever the page has
|
||||
// been scrolled past it.
|
||||
function quickActions() {
|
||||
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
|
||||
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
|
||||
return div;
|
||||
}
|
||||
|
||||
function actionBar() {
|
||||
const more = h('button', { class: 'btn', type: 'button', 'aria-label': 'More actions', onclick: moreMenu }, icon('more'), 'More');
|
||||
const bar = h('div', { class: 'actionbar' }, primaryAction(), secondaryAction(), more);
|
||||
if (busy) for (const b of bar.querySelectorAll('button')) b.disabled = true;
|
||||
return bar;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// every team the viewer is in, because somebody on two rotas wants both.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial } from './format.js';
|
||||
import { h, clear, badge, icon, spinner } from './ui.js';
|
||||
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
||||
import { myID, currentTeam } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-oncall');
|
||||
@@ -68,7 +68,7 @@ function render() {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -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() {
|
||||
const byDate = new Map(data.week.map((e) => [e.date, e]));
|
||||
const today = isoDate(new Date());
|
||||
const days = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = addDays(weekStart, i);
|
||||
const key = isoDate(d);
|
||||
const e = byDate.get(key);
|
||||
days.push(h('li', { class: `day ${key === today ? 'today' : ''} ${key < today ? 'past' : ''}` },
|
||||
h('span', { class: 'day-name', text: dayName.format(d) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(d) }),
|
||||
h('span', { class: `day-who ${e ? '' : 'nobody'}` }, e ? e.username : 'nobody', e && you(e.user_id)),
|
||||
));
|
||||
}
|
||||
const mine = myID();
|
||||
const days = weekRuns(byDate).map((r) => {
|
||||
const single = r.from === r.to;
|
||||
const cls = [
|
||||
'day',
|
||||
single ? '' : 'range',
|
||||
r.from <= today && today <= r.to ? 'today' : '',
|
||||
r.to < today ? 'past' : '',
|
||||
r.uid === mine ? 'mine' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const label = single
|
||||
? [h('span', { class: 'day-name', text: dayName.format(parse(r.from)) }),
|
||||
h('span', { class: 'day-date', text: dayDate.format(parse(r.from)) })]
|
||||
: [h('span', {
|
||||
class: 'day-range',
|
||||
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`,
|
||||
})];
|
||||
return h('li', { class: cls },
|
||||
...label,
|
||||
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
|
||||
);
|
||||
});
|
||||
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||
return [
|
||||
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) },
|
||||
icon('chevronLeft')),
|
||||
h('button', {
|
||||
class: 'btn btn-ghost label',
|
||||
class: 'btn btn-ghost week-label',
|
||||
type: 'button',
|
||||
title: 'Back to this week',
|
||||
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) },
|
||||
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() {
|
||||
const mine = data.upcoming.filter((e) => e.user_id === myID()).map((e) => e.date).sort();
|
||||
const runs = [];
|
||||
@@ -145,16 +175,34 @@ function myShifts() {
|
||||
else runs.push({ from: date, to: date });
|
||||
}
|
||||
const fmt = (s) => `${dayName.format(parse(s))} ${dayDate.format(parse(s))}`;
|
||||
return [
|
||||
h('div', { class: 'page-head' }, h('h2', { text: 'Your next shifts' })),
|
||||
const label = (r) => (r.from === r.to ? fmt(r.from) : `${fmt(r.from)} – ${fmt(r.to)}`);
|
||||
|
||||
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' },
|
||||
runs.length
|
||||
? h('ul', { class: 'shift-list' }, runs.slice(0, 8).map((r) =>
|
||||
h('ul', { class: 'shift-list' }, h('li', {},
|
||||
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('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('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 { 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 * as onboarding from './onboarding.js';
|
||||
import { navigate } from './app.js';
|
||||
@@ -18,12 +18,12 @@ const FILTERS = [
|
||||
];
|
||||
|
||||
const EMPTY = {
|
||||
open: ['All clear', 'Nothing open right now.'],
|
||||
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.'],
|
||||
acknowledged: ['Nothing acknowledged', 'No one is working an incident right now.'],
|
||||
snoozed: ['Nothing snoozed', 'Snoozed incidents show up here until the snooze runs out.'],
|
||||
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.'],
|
||||
archived: ['Nothing archived', ''],
|
||||
open: ['All clear', 'Nothing open right now.', 'checkCircle'],
|
||||
triggered: ['Nothing triggered', 'Every open incident has been acknowledged.', 'checkCircle'],
|
||||
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.', 'clock'],
|
||||
resolved: ['Nothing resolved', 'Resolved incidents are archived after a while.', null],
|
||||
archived: ['Nothing archived', 'Resolved incidents land here once archived.', 'archive'],
|
||||
};
|
||||
|
||||
onboarding.onRerender(() => renderList());
|
||||
@@ -87,6 +87,10 @@ export async function refresh({ fresh = false } = {}) {
|
||||
if (requested !== filter) return;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -101,18 +105,32 @@ function setFilter(id) {
|
||||
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() {
|
||||
const el = document.getElementById('queue-filters');
|
||||
const chips = FILTERS.map((f) =>
|
||||
h('button', {
|
||||
const chips = FILTERS.map((f) => {
|
||||
const count = chipCount(f.id);
|
||||
return h('button', {
|
||||
class: 'chip',
|
||||
type: 'button',
|
||||
role: 'tab',
|
||||
'aria-selected': String(f.id === filter),
|
||||
onclick: () => setFilter(f.id),
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -155,8 +179,8 @@ function renderList() {
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
const [title, text] = EMPTY[filter];
|
||||
clear(el, checklist, emptyState(title, text, filter === 'open' ? 'checkCircle' : null));
|
||||
const [title, text, iconName] = EMPTY[filter];
|
||||
clear(el, checklist, emptyState(title, text, iconName));
|
||||
return;
|
||||
}
|
||||
clear(el,
|
||||
@@ -201,9 +225,12 @@ function row(inc, index) {
|
||||
dataset: { index: String(index) },
|
||||
},
|
||||
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' },
|
||||
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,
|
||||
team,
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
|
||||
@@ -80,7 +80,7 @@ function render() {
|
||||
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||
else if (!data) body = spinner();
|
||||
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 {
|
||||
body = h('div', { class: 'stats' },
|
||||
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
|
||||
// reacts to.
|
||||
function teamPicker() {
|
||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||
return h('h1', { class: 'detail-title', text: data.team.name });
|
||||
}
|
||||
|
||||
// --- overview --------------------------------------------------------------
|
||||
@@ -204,18 +204,18 @@ function overview() {
|
||||
menuCard('/team/rota', 'Rota', null,
|
||||
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||||
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,
|
||||
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.'),
|
||||
menuCard('/team/sources', 'Alert sources', keys || null,
|
||||
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.'),
|
||||
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||||
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.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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.
|
||||
|
||||
import { h, openSheet, closeSheet } from './ui.js';
|
||||
import { h, icon, openSheet, closeSheet } from './ui.js';
|
||||
import { state, currentTeam, setSelectedTeam, onTeamChange } from './state.js';
|
||||
import { teamColorClass } from './format.js';
|
||||
|
||||
@@ -36,6 +36,9 @@ export function render() {
|
||||
btn.replaceChildren(
|
||||
h('span', { class: dotClass }),
|
||||
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'],
|
||||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||||
chevronRight: ['M9 6l6 6-6 6'],
|
||||
chevronDown: ['M6 9l6 6 6-6'],
|
||||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||||
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
|
||||
// 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.
|
||||
// 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) {
|
||||
return h('a', { class: 'card overview-item', href },
|
||||
h('div', { class: 'overview-head' },
|
||||
h('strong', { text: label }),
|
||||
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