Compare commits

...

30 Commits

Author SHA1 Message Date
Niklas Ye 1cb09525e3 Note that a release does not bump the demos' terdut-server pin
CI / chart (push) Successful in 1s
CI / test (push) Successful in 8s
CI / security (push) Successful in 15s
terdut-demo in Ryuvia/charts, a TerdutServer CR reconciled by terdut-operator,
and the kind demo in terdut-operator both pin this image, and chart-bump only
moves the terdut-server wrapper. terdut-demo therefore sat at v0.37.0 through
v0.41.0-v0.43.0 before it was synced on 2026-10-08, and nothing said it was
behind. CLAUDE.md now lists the step after the release one: bump the demo's
tag to the same digest and its chart version, as its own PR, and read the
range's migrations first, since the demo's database migrates forward at start.

Documentation only: nothing is built from this file, so it needs no release.
2026-10-08 19:08:30 +02:00
Niklas Ye c4833067f0 Build the clusters query with Sprintf, as the other handlers do
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m15s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m12s
Release / scan-image (push) Successful in 24s
gosec's G202 flagged the string concatenation in handleListClusters, and the
security job gates CI. The pieces joined were only placeholders and fixed
clauses, never request data, so this was not an injection; but every other
handler here builds its SQL with fmt.Sprintf over placeholders, and this one
now reads the same way. The query and its result are unchanged.
2026-10-08 18:28:26 +02:00
Niklas Ye 2bc8f336a0 Set the chart's placeholder version to 0.43.0
CI / chart (push) Successful in 1s
CI / security (push) Failing after 20s
CI / test (push) Has been cancelled
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with db474ca (0.42.1) and def0f68 (0.42.0) before it, because a
tree heading for v0.43.0 that still says 0.42.1 tells its reader
something false.
2026-10-08 18:24:55 +02:00
Niklas Ye a955356821 Filter the queue by cluster
A team with a cluster per alert source can now narrow the queue to one
cluster. A dropdown under the status chips lists the clusters, and appears
once there are two or more to choose between, the rule the team selector
follows. The choice is kept per browser, like the selected team, and dropped
if the server no longer knows the cluster rather than leaving an empty list
with no explanation. The status chip counts follow it, and a row leaves out
its cluster chip once the queue is narrowed to one.

The filter is on the server. The list is capped at 50 rows, so filtering what
is on screen would quietly miss older incidents in the Resolved and Archived
lists. GET /api/incidents takes ?cluster=<value>, matched against the
incident's `cluster` group label, and GET /api/incidents/clusters lists the
distinct values from the last 90 days (optionally for one team), so the
dropdown is not limited to what the current page happens to show. Both are
additive: no existing parameter or JSON shape changed, so terdut-tui keeps
working unchanged and has nothing it must mirror.

An incident carries the label only when `cluster` is in Alertmanager's
group_by, so the filter only sees those; the README says so.
2026-10-08 18:23:28 +02:00
Niklas Ye db474ca909 Set the chart's placeholder version to 0.42.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m12s
Release / test (push) Successful in 10s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / scan-image (push) Successful in 1s
Release / image (push) Successful in 1m4s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with def0f68 (0.42.0) and b848143 (0.41.3) before it, because a
tree heading for v0.42.1 that still says 0.42.0 tells its reader
something false.
2026-10-08 18:09:23 +02:00
Niklas Ye a23e88c16d Put the cluster first in an ntfy page title
A phone's lock screen cuts a long title off at the end, and an incident title
keeps its grouping labels there: "PodRestarting (cluster=prod-eu,
namespace=shop)". For a team with a Kubernetes cluster per alert source, the
cluster is the first thing a person wants and the first thing lost.

When the incident has a `cluster` group label, the title of the page now
leads with it, "[prod-eu] PodRestarting (namespace=shop)", and the label is
dropped from the parenthesis so it is not said twice, along with the
parenthesis itself if nothing else is left. The reminder and the resolution
use the same title, and the label is the one the web UI's chip reads. The
message body is unchanged.

An incident without a cluster label gets the same title as before, and
incidents that are already open keep theirs; only pages sent from now on
change. The constant originLabel names the label, as ORIGIN_LABEL does in the
web UI.

No endpoint or JSON shape changed, so nothing to mirror in terdut-tui.
2026-10-08 18:07:59 +02:00
Niklas Ye def0f68d00 Set the chart's placeholder version to 0.42.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m11s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m6s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with b848143 (0.41.3) and 7665e5e (0.41.2) before it, because a
tree heading for v0.42.0 that still says 0.41.3 tells its reader
something false.
2026-10-08 17:26:16 +02:00
Niklas Ye fb86a18988 Show which cluster an incident came from, as a chip
A team with one Alertmanager per Kubernetes cluster could not tell at a
glance where an incident started: the cluster was only a word inside the
title. When an alert carries a `cluster` label, the queue rows, the incident
page and the alerts list now show it as a chip in that cluster's colour, a
stable pick from the existing six-colour palette. A queue row drops
`cluster=...` from its title, since the chip says it, and the incident page
keeps the full title.

An incident has the label only when it is in Alertmanager's group_by, which
is also what keeps two clusters' identical alerts apart: incidents are matched
on the team and the groupKey, and the groupKey does not include external
labels. The README has a section on the two settings (Prometheus
externalLabels and group_by). The alerts list reads the label from the alert
itself, so it shows the chip with only the external label.

Web UI and docs only: no endpoint or JSON shape changed, so nothing to mirror
in terdut-tui. Nothing changes for a team whose alerts have no cluster label.
2026-10-08 17:24:52 +02:00
Niklas Ye b848143471 Set the chart's placeholder version to 0.41.3
CI / security (push) Successful in 4m45s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 3s
CI / test (push) Successful in 9m4s
Release / image (push) Successful in 2m15s
Release / scan-image (push) Successful in 40s
Release / binaries (push) Successful in 3m1s
CI / chart (push) Successful in 1s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 7665e5e (0.41.2) and 3a96b20 (0.41.1) before it, because a
tree heading for v0.41.3 that still says 0.41.2 tells its reader
something false.
2026-10-08 15:13:42 +02:00
Niklas Ye 942517c7a8 Keep the scroll fade at the screen edge on the queue's filter row
The fade that hints at more filters to the right was an element inside the
scrolling row, so it scrolled away with the chips instead of staying at the
edge; the comment on it claimed the opposite. It is now a mask on the strip
itself, applied only while there is more to scroll to (fadeOnOverflow in
ui.js sets data-more), so it stays put and disappears at the end instead of
dimming the last chip.

The Team and Admin tab strips had an always-on mask from the earlier
"Sources is cut off" fix, which dimmed their last tab even when fully
scrolled; they use the same mechanism now.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 15:12:16 +02:00
Niklas Ye 7665e5e52f Set the chart's placeholder version to 0.41.2
CI / chart (push) Successful in 1s
CI / security (push) Successful in 26s
CI / test (push) Successful in 5m42s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 1s
Release / binaries (push) Successful in 39s
Release / image (push) Successful in 1m22s
Release / scan-image (push) Successful in 27s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 3a96b20 (0.41.1) and 065b557 (0.41.0) before it, because a
tree heading for v0.41.2 that still says 0.41.1 tells its reader
something false.
2026-10-08 14:53:15 +02:00
Niklas Ye bcf1a3e99b Give the sidebar groups, and tidy the tables, disclosures and tab bar
The desktop sidebar is now grouped: Queue, On-call, Alerts and Stats; Team
and Admin; then Account at the foot, shown as the signed-in person with an
avatar and their name. The active item has a bar as well as a tint. The
queue's row of team chips is gone: the team selector is the one place the
team is chosen, and the chips offered the same choice a second time.

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

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

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

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

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

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

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

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

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

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

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

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

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

The first column of all three lists is now headed Name. Web UI only: no
change to any endpoint or JSON shape, so nothing to mirror in the TUI.
2026-10-08 11:46:09 +02:00
Niklas Ye 0f88574a41 Set the chart's placeholder version to 0.39.0
CI / chart (push) Successful in 4s
CI / security (push) Successful in 45s
CI / test (push) Successful in 6m1s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m12s
Release / scan-image (push) Successful in 6s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 584d344 (0.38.0) and df83adf (0.37.2) before it, because a
tree heading for v0.39.0 that still says 0.38.0 tells its reader
something false.
2026-10-08 09:02:07 +02:00
Niklas Ye 3613fd5732 Check rows.Err() in the remaining Next() loops (#27)
Incident list, the three stats breakdowns and the user list could return a
truncated result as if complete when the scan failed partway.
2026-10-08 09:00:54 +02:00
Niklas Ye dc62278788 Check rows.Err() after the timeline scan loop (#27) 2026-10-08 08:58:19 +02:00
Niklas Ye 0aaea8efb5 Record the actor on assign, archive and unarchive (#35)
Assign logged only the assignee (user_id), archive/unarchive logged nothing.
Migration 018 adds actor_user_id/actor_service_account_id to incident_events
for 'assigned'; archive/unarchive now log archived/unarchived events with the
caller via callerActorIDs. Timeline JSON gains actor_* fields; web timeline
renders them. Service accounts are still not assignable.
2026-10-08 08:53:04 +02:00
Niklas Ye 584d3441fc Set the chart's placeholder version to 0.38.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 2m9s
CI / test (push) Successful in 6m7s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 35s
Release / image (push) Successful in 1m10s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with df83adf (0.37.2) and 2b2609e (0.37.1) before it, because a
tree heading for v0.38.0 that still says 0.37.2 tells its reader
something false.
2026-10-07 22:39:00 +02:00
Niklas Ye 926aa2d3ec Add optional API key expiry and a missing way to list them
Part of the same security-hardening pass as the last five commits, and
the last item in its backlog. User API keys had no expiry at all --
unlike service-account keys, visibly distinct only by their "tdsa_"
prefix -- and, it turns out while implementing this, no way to list
them either: only create (returns the raw key once) and delete-by-id
existed, so a key's owner had no way to even discover what keys they
had short of remembering IDs from creation time.

handleCreateAPIKey takes an optional expires_in_days (0, the default,
keeps today's behavior: never expires, so no existing integration is
affected). apiKeyUser's lookup now carries `expires_at IS NULL OR
expires_at > now` as part of the query itself, the same way serveAs's
disabled_at check already works -- an expired key simply fails to
resolve, like a wrong one, rather than resolving and being caught
after the fact. New GET /api/users/{id}/api-keys (requireSelfOrAdmin,
same as create/delete) lists id/name/created_at/last_used_at/expires_at,
never the raw key.

Scoped down from the original plan on request: no web UI change, since
there turned out to be no existing API-keys UI at all to extend --
building one from scratch would have been a real feature addition, not
a hardening tweak.

Mirrored the additive expires_at field in terdut-tui's APIKey struct
(separate commit, separate repo) per this workspace's version-coupling
rule; the TUI does not create or list expiring keys itself yet.

New tests (api_keys_test.go): default never-expires, expires_in_days
sets expires_at, out-of-range values rejected, an expired key fails
auth after a fresh one worked, the listing never includes the raw key.
Also added the new GET route to authz_scope_test.go's self-or-admin
table from the previous commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:31:44 +02:00
Niklas Ye a2ca9c25d0 Add a regression test for the team/admin/self authorization pattern
Part of the same security-hardening pass as the last four commits.
terdut-server's authz is already solid -- centralized predicates
(requireTeamMember, requireTeamOwner, requireSelfOrAdmin, AdminOnly)
rather than ad hoc per-handler checks, confirmed by spot-checking several
handlers while writing this. But it is enforced by convention, not the
type system: a future handler that forgets its guard would compile and
read fine on review, exactly like one that remembers it.

authz_scope_test.go builds two teams and, for every team-scoped route
(members, OIDC groups, invites, escalation, dead man's switches,
integrations, schedule, plus every /api/incidents/{id}/... route, scoped
by the incident's own team_id through incidentIDParam's single
chokepoint), calls it as one team's owner against the other team's
resources and asserts 404 -- requireTeamMember and requireTeamOwner both
answer a non-member that way. Separate tests cover AdminOnly's routes
(403 for a non-admin) and requireSelfOrAdmin's (403 for a non-admin
acting on someone else's account).

Verified the test actually catches a regression, not just that it
passes today: temporarily removed handleListTeamMembers' requireTeamMember
call, confirmed exactly that one subtest failed and nothing else did,
then put it back.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:21:46 +02:00
Niklas Ye 92959cac38 Run the pod as non-root with a read-only filesystem
Part of the same security-hardening pass as the last three commits.
Neither the Dockerfile nor the chart's Deployment set any securityContext
at all, so the container ran as root by default — scratch has no
/etc/passwd for a USER directive to resolve against, so nobody had set one.

Dockerfile now ends with USER 65532:65532 (numeric, since scratch has no
user database; 65532 is the common "nonroot" convention, distroless's own
uid). The chart's Deployment adds a matching pod-level securityContext
(runAsNonRoot, runAsUser/runAsGroup: 65532, seccompProfile: RuntimeDefault)
plus per-container hardening (allowPrivilegeEscalation: false,
capabilities dropped, readOnlyRootFilesystem: true) on both the app
container and the wait-for-postgres init container — neither writes
anything to disk, so the root filesystem can stay read-only.

Verified with helm-lint and a manual `helm template` render of both the
terdut-server and terdut-demo charts. Not yet verified: an actual pod
starting with these in place — readOnlyRootFilesystem is exactly where a
non-obvious write (a temp file, a cache dir) would surface as a crash
rather than a lint error, so that needs a real rollout to confirm.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:16:06 +02:00
Niklas Ye f15db0e20a Add gosec to CI; fix the one real finding it surfaced
Part of the same security-hardening pass as the last two commits. make
lint was go vet only; govulncheck and gitleaks already scanned deps and
secrets on every push, but nothing read this repo's own source for
risky patterns (weak crypto, injection shapes, insecure cookies, ...).

New `make security-code` runs gosec, wired into ci.yaml's security job
alongside the other two. G104 (unchecked error) is excluded at the
Makefile level: every one of its 41 initial hits was this codebase's
existing, deliberate idiom for a best-effort write or an already-
reviewed json.Unmarshal of its own JSONB, predating gosec, and the rule
cannot tell that apart from a mistake -- seventeen individual #nosec
comments would hide a future real G104 regression in the suppression
noise rather than surface it. Reasoning is on the Makefile target.

Of the 12 remaining hits:
  - Genuinely real: oidc.go's callback logged error_description (and,
    two call sites down, identity.Subject) via %s before the request's
    state was even checked against its cookie -- an attacker-reachable
    value going into the log unquoted. Switched to %q, matching
    identity.Username's existing treatment, so a value holding a
    newline can't forge a second log line.
  - False positives, annotated inline rather than globally suppressed:
    4x G124 on cookies that already set Secure via cookieSecure(...)
    (a function call, not the literal `true` the rule wants), 3x G202
    on sqlArgs-built queries that only ever splice in a "$N"
    placeholder, never a value, and the remaining 5x G706 on log lines
    that were already %q-quoted -- gosec's taint analysis doesn't
    model format verbs, so it flags the tainted argument regardless.

Also fixed handleMe's swallowed Scan error (gosec's catch, pre-fix):
a transient DB error left hash/dismissed at their zero values and the
response claimed no password and no onboarding dismissal regardless
of the truth, rather than surfacing a 500.

Checked both workflow files for the injection class letsvisit found
there (a `${{ }}` expression spliced straight into a `run:` block):
every one here already goes through `env:` as a quoted shell variable,
documented in ci.yaml's own header comment. Nothing to fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:12:31 +02:00
Niklas Ye b82c10acf4 Back the login/signup/OIDC/device rate limiters with Postgres
Part of the same security-hardening pass as the body-size/header commit.
loginLimiter was an in-memory, per-process sync.Mutex+map -- fine for one
replica, but charts/terdut-server/values.yaml has set replicaCount: 2 in
production since v0.37.0. Each pod counted only its own traffic, so every
limit it guarded (failed logins, sign-ups, OIDC/device-login starts) was
effectively twice as generous as the constants say, not just in theory.

loginLimiter now stores its counters in a new rate_limit_counters table
(migration 016) instead of a map; blocked/fail/clear take a context and
query/upsert/delete a row keyed by the same strings callers already used
(username, client address, "signup:"+address, ...). Semantics are
unchanged -- a fixed window that resets rather than slides -- so no call
site's behavior changes, only where the count lives. Added purgeRateLimits
to the sweeper, alongside purgeSessions/purgeAckTokens, so expired windows
don't accumulate.

New internal (package api) tests in rate_limiter_test.go cover the basic
behavior plus the regression this exists to fix: two loginLimiter values
sharing one database, standing in for two replicas, now see one combined
count instead of each keeping their own.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:03:51 +02:00
Niklas Ye 7cd6fbf571 Cap request body size and add baseline security headers
Part of a security-hardening pass (see wiki for the full backlog).
decodeJSON had no size limit at all, so every JSON endpoint -- including
the two unauthenticated ones (bootstrap, the Alertmanager webhook) --
would buffer an attacker-supplied body of unbounded size before it was
even validated. decodeJSON now wraps the body in http.MaxBytesReader at
a 1 MiB default; the webhook gets its own 8 MiB cap via decodeJSONLimit,
since a real Alertmanager batch can be bigger than an ordinary API body.

Also adds a securityHeaders middleware, applied globally: nosniff on
every response (previously only the static site got it), and HSTS
(180-day max-age, conservative on purpose) whenever cookieSecure's
signal says the browser is on HTTPS. Checked the chart/gateway config
first -- neither sets HSTS anywhere, so this was a real gap.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 21:57:00 +02:00
50 changed files with 2482 additions and 505 deletions
+3
View File
@@ -125,6 +125,9 @@ jobs:
- name: Secret scan (gitleaks) - name: Secret scan (gitleaks)
run: make security-secrets run: make security-secrets
- name: Code security scan (gosec)
run: make security-code
# Host mode, no `container:`: helm is baked into the runner image, and a container job # Host mode, no `container:`: helm is baked into the runner image, and a container job
# could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason # could not install it -- get.helm.sh is unreachable from the dind bridge. Same reason
# release.yaml's chart job runs on the host. # release.yaml's chart job runs on the host.
+11 -1
View File
@@ -14,7 +14,7 @@ Preconditions and the plan, without side effects:
Config is `.release.conf` here plus `make release-vars`. The process itself lives in Config is `.release.conf` here plus `make release-vars`. The process itself lives in
`~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing. `~/.claude/skills/release/`; why it is shaped this way is in README.md §Releasing.
Two things about this repo specifically: Three things about this repo specifically:
- **The image is scanned after it is published, not before.** `scan-image` runs trivy - **The image is scanned after it is published, not before.** `scan-image` runs trivy
against the pushed image, because trivy cannot read a locally built one on this runner. against the pushed image, because trivy cannot read a locally built one on this runner.
@@ -25,6 +25,16 @@ Two things about this repo specifically:
so `chart-bump` needs `--image "$IMAGE"` to know which one moves. That sidecar backs up so `chart-bump` needs `--image "$IMAGE"` to know which one moves. That sidecar backs up
SQLite; the Postgres move (#2) retires it in favour of a `postgresql` CR with a k8up SQLite; the Postgres move (#2) retires it in favour of a `postgresql` CR with a k8up
`pg_dump` annotation, after which only the app image's tag is left. `pg_dump` annotation, after which only the app image's tag is left.
- **Two demos pin this image, and `chart-bump` moves neither.** `terdut-demo` in
`Ryuvia/charts` is a `TerdutServer` CR that terdut-operator reconciles, and its
`values.yaml` `image.tag` is meant to match production's pin (same digest). The kind demo
in terdut-operator (`examples/demo/01-server.yaml`) pins a tag too. A release only bumps
the `terdut-server` wrapper, so both drift silently: `terdut-demo` sat at v0.37.0 through
v0.41.0-v0.43.0 until it was synced on 2026-10-08. After a release, bump `terdut-demo`'s
tag to the same `image-digest` and its `Chart.yaml` `version:` (Flux reconciles on
ChartVersion), as its own PR, and say in the release report whether you did. Neither
demo has anything but the pin to change, but read the version range's migrations first:
the demo's Postgres migrates forward at startup.
## Checks ## Checks
+6
View File
@@ -24,4 +24,10 @@ FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /terdut /terdut COPY --from=builder /terdut /terdut
EXPOSE 8080 EXPOSE 8080
# Numeric, not a name: scratch has no /etc/passwd for one to resolve against,
# and Docker's USER accepts a bare UID:GID without it. 65532 is the common
# "nonroot" convention (distroless's own uid), chosen so the chart's pod
# securityContext (runAsNonRoot, runAsUser: 65532) matches what the image
# already runs as rather than fighting it.
USER 65532:65532
ENTRYPOINT ["/terdut"] ENTRYPOINT ["/terdut"]
+21
View File
@@ -143,6 +143,7 @@ BUILDX_BUILDER ?= terdut
TRIVY_VERSION := 0.73.0 TRIVY_VERSION := 0.73.0
GOVULNCHECK_VERSION := v1.1.4 GOVULNCHECK_VERSION := v1.1.4
GITLEAKS_VERSION := v8.30.0 GITLEAKS_VERSION := v8.30.0
GOSEC_VERSION := v2.29.0
# --pull, not --no-cache: refresh the base image without discarding the layer cache. # --pull, not --no-cache: refresh the base image without discarding the layer cache.
DOCKER_BUILD_FLAGS ?= --pull DOCKER_BUILD_FLAGS ?= --pull
@@ -222,6 +223,26 @@ release: push helm-package helm-push ## Publish image + chart (the workflow's on
security-go: ## Scan Go deps for known CVEs (govulncheck) security-go: ## Scan Go deps for known CVEs (govulncheck)
go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./... go run golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) ./...
# Code-level, not dependency- or secret-level: gosec reads this repo's own source for
# known-dangerous patterns (weak crypto, SQL/command injection shapes, insecure file
# permissions, …) rather than its module graph or working tree for leaked credentials,
# which is what security-go and security-secrets above already cover.
#
# G104 (unchecked error) is excluded. Every hit it found here on first run was this
# codebase's existing, deliberate idiom for a best-effort write or an already-reviewed
# json.Unmarshal of this server's own JSONB (see the "best-effort" comments in
# middleware.go and the //nolint:errcheck lines in alerts.go/deadman.go) -- a style that
# predates gosec and that G104 cannot distinguish from a mistake. Reaching the same
# green result by adding a dozens of individual #nosec comments would not add
# information; it would just make a future *real* G104 regression one more suppressed
# line instead of a visible one. Same reasoning as the chi-advisories note on
# security-go above: what gosec reports here (nothing, beyond G104) is the useful
# property, not a loophole. -exclude-generated skips web.go's embedded, build-time-only
# assets.
.PHONY: security-code
security-code: ## Scan this repo's own source for risky patterns (gosec)
go run github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) -exclude-generated -exclude=G104 ./...
# --no-git scans the working tree rather than the history, so this catches a secret on the # --no-git scans the working tree rather than the history, so this catches a secret on the
# way in. It is not a history audit and finding nothing here says nothing about what is # way in. It is not a history audit and finding nothing here says nothing about what is
# already committed. --redact because the finding is printed into a CI log. # already committed. --redact because the finding is printed into a CI log.
+30 -4
View File
@@ -444,6 +444,29 @@ high-water mark — the highest `severity` label any of its alerts has carried
an incident that hit `critical` still reads as critical after the critical alert an incident that hit `critical` still reads as critical after the critical alert
clears. clears.
### Several clusters, one team
A team with one Alertmanager per Kubernetes cluster, each posting to its own
source, needs two settings or the clusters run together.
1. Give every alert a `cluster` label at the source. In Prometheus that is
`externalLabels: {cluster: prod-eu}` (kube-prometheus-stack:
`prometheus.prometheusSpec.externalLabels`).
2. Add `cluster` to `group_by` in `alertmanager.yml`.
The second one is the one that matters. Incidents are matched on the team and
Alertmanager's `groupKey`, and the `groupKey` does not include external labels:
without `cluster` in `group_by`, the same alert in two clusters has the same
key and joins one incident. With it, each cluster gets its own, `cluster` is in
the incident's `group_labels`, and the web UI shows it as a coloured chip on the
queue, the incident and the alert list, instead of leaving it in the title.
An alert that is not grouped by `cluster` still shows the chip on the alert
list, which reads the label from the alert itself.
The queue has a cluster dropdown once there are two or more values to choose
between. It filters on the incident's `cluster` group label
(`GET /api/incidents?cluster=...`), so it only sees incidents grouped by it.
### An incident opens only on a new occurrence ### An incident opens only on a new occurrence
An incident opens when an alert **transitions into firing**: a fingerprint that An incident opens when an alert **transitions into firing**: a fingerprint that
@@ -926,7 +949,8 @@ the team gets the same `404` as anybody else.
| Method | Path | Description | | Method | Path | Description |
|---|---|---| |---|---|---|
| `GET` | `/api/incidents` | List incidents. Filters: `?status=triggered\|acknowledged\|resolved`, `?severity=`, `?assigned_to=<user id>`, `?archived=true`, `?snoozed=true`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?sort=severity`, `?limit=` (default 50, max 500) | | `GET` | `/api/incidents` | List incidents. Filters: `?status=triggered\|acknowledged\|resolved`, `?severity=`, `?assigned_to=<user id>`, `?archived=true`, `?snoozed=true`, `?from=YYYY-MM-DD`, `?to=YYYY-MM-DD`, `?sort=severity`, `?cluster=<value of the cluster group label>`, `?limit=` (default 50, max 500) |
| `GET` | `/api/incidents/clusters` | The distinct `cluster` values on the caller's incidents from the last 90 days, sorted (`?team_id=` narrows it). An empty array when nothing carries the label |
| `GET` | `/api/incidents/{id}` | Get single incident, with its alerts inline | | `GET` | `/api/incidents/{id}` | Get single incident, with its alerts inline |
| `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident | | `GET` | `/api/incidents/{id}/alerts` | Alerts under this incident |
| `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological | | `GET` | `/api/incidents/{id}/timeline` | Full event history, chronological |
@@ -984,9 +1008,11 @@ name: degrade unknown values to "resolved, reason unknown".
| `created_at` | timestamp | | | `created_at` | timestamp | |
Types written today: `triggered`, `alert_added`, `alert_resolved`, Types written today: `triggered`, `alert_added`, `alert_resolved`,
`acknowledged`, `unacknowledged`, `assigned`, `snoozed`, `unsnoozed`, `resolved`, `acknowledged`, `unacknowledged`, `assigned`, `archived`, `unarchived`, `snoozed`,
`note`, `notified`, `notify_failed`, `deadman_silent`. On an `assigned` event `unsnoozed`, `resolved`, `note`, `notified`, `notify_failed`, `deadman_silent`. On an
`user_id` is the **assignee**, not the actor. New types may be added; render `assigned` event `user_id` is the **assignee**, not the actor; the actor is in
`actor_user_id`/`actor_username` or `actor_service_account_id`/`actor_service_account_name`
(absent on assignments made before they were recorded). New types may be added; render
unknown ones generically rather than dropping them. unknown ones generically rather than dropping them.
On `notified` and `notify_failed`, `detail` carries the notification kind On `notified` and `notify_failed`, `detail` carries the notification kind
+2 -2
View File
@@ -15,5 +15,5 @@ type: application
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight: # appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
# image.tag stays "latest", which is what a local install actually pulls. appVersion is # image.tag stays "latest", which is what a local install actually pulls. appVersion is
# metadata and drives nothing. # metadata and drives nothing.
version: 0.37.2 version: 0.43.0
appVersion: "v0.37.2" appVersion: "v0.43.0"
@@ -25,11 +25,30 @@ spec:
{{- include "terdut-server.selectorLabels" . | nindent 8 }} {{- include "terdut-server.selectorLabels" . | nindent 8 }}
spec: spec:
enableServiceLinks: false enableServiceLinks: false
# Pod-wide default; both containers below run as this UID regardless of
# what their own image would otherwise pick (postgres:17-alpine's
# pg_isready needs no particular user, and 65532 is what the app image
# itself runs as now — see the Dockerfile's USER). seccompProfile here
# rather than per-container: there is no reason it would ever differ
# between them.
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
{{- if .Values.database.waitForPostgres.enabled }} {{- if .Values.database.waitForPostgres.enabled }}
initContainers: initContainers:
- name: wait-for-postgres - name: wait-for-postgres
image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}" image: "{{ .Values.database.waitForPostgres.image.repository }}:{{ .Values.database.waitForPostgres.image.tag }}"
imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }} imagePullPolicy: {{ .Values.database.waitForPostgres.image.pullPolicy }}
# No capability this loop needs, and nothing in it writes to disk:
# sh, pg_isready, echo and sleep all run read-only.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env: env:
- name: TERDUT_DB_DSN - name: TERDUT_DB_DSN
value: {{ required "database.dsn is required" .Values.database.dsn | quote }} value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
@@ -46,6 +65,13 @@ spec:
- name: terdut-server - name: terdut-server
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }} imagePullPolicy: {{ .Values.image.pullPolicy }}
# scratch, nothing to write: the binary keeps no local state and
# writes nothing to disk, so the root filesystem can stay read-only.
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports: ports:
- name: http - name: http
containerPort: {{ .Values.service.port }} containerPort: {{ .Values.service.port }}
+7 -1
View File
@@ -94,10 +94,16 @@ func handleIntegrationWebhook(db *sql.DB, notify NotifyConfig) http.HandlerFunc
} }
} }
// maxWebhookBodyBytes is larger than maxBodyBytes: a real Alertmanager batch
// can carry many alerts, each with several labels and annotations, and the
// sender is a trusted piece of infrastructure rather than an arbitrary
// caller.
const maxWebhookBodyBytes = 8 << 20
func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) { func receiveWebhook(w http.ResponseWriter, r *http.Request, db *sql.DB, notify NotifyConfig, src alertSource) {
teamID := src.teamID teamID := src.teamID
var payload amPayload var payload amPayload
if err := decodeJSON(r, &payload); err != nil { if err := decodeJSONLimit(r, &payload, maxWebhookBodyBytes); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid payload")) respond(w, http.StatusBadRequest, errResp("invalid payload"))
return return
} }
+135
View File
@@ -0,0 +1,135 @@
package api_test
import (
"net/http"
"testing"
"time"
)
func TestAPIKey_DefaultsToNeverExpiring(t *testing.T) {
s := newTS(t)
var key struct {
Key string `json:"key"`
ExpiresAt *string `json:"expires_at"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
map[string]string{"name": "no-expiry"}), &key)
if key.ExpiresAt != nil {
t.Errorf("expires_at = %v, want nil (unset expires_in_days means never expires)", *key.ExpiresAt)
}
}
func TestAPIKey_ExpiresInDaysSetsExpiresAt(t *testing.T) {
s := newTS(t)
var key struct {
ID int64 `json:"id"`
ExpiresAt *string `json:"expires_at"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
map[string]any{"name": "rotates", "expires_in_days": 30}), &key)
if key.ExpiresAt == nil {
t.Fatal("expires_at = nil, want a timestamp roughly 30 days out")
}
got, err := time.Parse(time.RFC3339, *key.ExpiresAt)
if err != nil {
t.Fatalf("parse expires_at: %v", err)
}
want := time.Now().AddDate(0, 0, 30)
if diff := want.Sub(got).Abs(); diff > time.Hour {
t.Errorf("expires_at = %v, want close to %v (30 days out)", got, want)
}
}
func TestAPIKey_ExpiresInDaysRejectsOutOfRange(t *testing.T) {
s := newTS(t)
for _, days := range []int{-1, 3651} {
resp := s.req(t, http.MethodPost, "/api/users/1/api-keys",
map[string]any{"name": "bad", "expires_in_days": days})
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expires_in_days=%d: status = %d, want %d", days, resp.StatusCode, http.StatusBadRequest)
}
}
}
func TestAPIKey_AnExpiredKeyCannotAuthenticate(t *testing.T) {
s := newTS(t)
var key struct {
ID int64 `json:"id"`
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
map[string]any{"name": "soon-expired", "expires_in_days": 1}), &key)
// A fresh key works...
req, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
req.Header.Set("Authorization", "Bearer "+key.Key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET /api/me: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("fresh key: status = %d, want %d", resp.StatusCode, http.StatusOK)
}
// ...and stops working once its expiry has passed.
s.exec(t, "UPDATE api_keys SET expires_at = $1 WHERE id = $2", time.Now().Add(-time.Hour).Unix(), key.ID)
req2, _ := http.NewRequest(http.MethodGet, s.URL+"/api/me", nil)
req2.Header.Set("Authorization", "Bearer "+key.Key)
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatalf("GET /api/me: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Errorf("expired key: status = %d, want %d", resp2.StatusCode, http.StatusUnauthorized)
}
}
func TestAPIKey_ListNeverReturnsTheRawKey(t *testing.T) {
s := newTS(t)
decode(t, s.req(t, http.MethodPost, "/api/users/1/api-keys",
map[string]string{"name": "listed"}), new(struct {
Key string `json:"key"`
}))
var keys []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Key string `json:"key"`
}
decode(t, s.req(t, http.MethodGet, "/api/users/1/api-keys", nil), &keys)
found := false
for _, k := range keys {
if k.Name == "listed" {
found = true
}
if k.Key != "" {
t.Errorf("key %d (%s): raw key present in listing", k.ID, k.Name)
}
}
if !found {
t.Error("the key just created does not appear in the listing")
}
}
func TestAPIKey_ListIsSelfOrAdmin(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "apikeys-a")
resp := a.call(http.MethodGet, "/api/users/1/api-keys", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
}
}
+5
View File
@@ -76,6 +76,7 @@ func Sweep(ctx context.Context, db *sql.DB, archiveAfter, staleAfter time.Durati
archiveResolvedIncidents(ctx, db, archiveAfter) archiveResolvedIncidents(ctx, db, archiveAfter)
purgeAckTokens(ctx, db) purgeAckTokens(ctx, db)
purgeSessions(ctx, db) purgeSessions(ctx, db)
purgeRateLimits(ctx, db)
} }
// expireStale resolves firing alerts that Alertmanager has stopped refreshing. // expireStale resolves firing alerts that Alertmanager has stopped refreshing.
@@ -121,6 +122,10 @@ func expireStale(ctx context.Context, db *sql.DB, staleAfter time.Duration, skip
for i, id := range ids { for i, id := range ids {
idList[i] = id idList[i] = id
} }
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
// placeholder they hand back, never a value; every value travels through
// args.all() as a bound parameter. See the sqlArgs doc comment in
// helpers.go.
if _, err := db.ExecContext(ctx, ` if _, err := db.ExecContext(ctx, `
UPDATE alerts UPDATE alerts
SET status = 'resolved', SET status = 'resolved',
+83 -42
View File
@@ -45,57 +45,85 @@ var dummyHash = sync.OnceValue(func() []byte {
return h return h
}) })
// loginLimiter counts failed logins in a fixed window, per username and per // loginLimiter counts failed logins (and other unauthenticated attempts:
// client address. The username limit is what stops guessing one account; the // sign-up, OIDC/device start) in a fixed window, per key — a username, a
// address limit is looser because every user behind the same gateway or NAT // client address, or both, depending on the caller.
// shares it. //
// Backed by Postgres rather than an in-memory map: this server runs more
// than one replica in production (v0.37.0), and a counter that only ever
// sees its own pod's traffic would quietly let every limit through
// multiplied by the replica count — two loginLimiter values pointed at the
// same db, standing in for two replicas, now share exactly one count per
// key instead of each keeping their own.
//
// The window resets rather than slides, the same behavior the in-memory
// version it replaces had: once a key's window is older than loginWindow,
// the next fail() starts a fresh one instead of extending the stale one.
type loginLimiter struct { type loginLimiter struct {
mu sync.Mutex db *sql.DB
failures map[string]*loginWindowCount
} }
type loginWindowCount struct { func newLoginLimiter(db *sql.DB) *loginLimiter {
start time.Time return &loginLimiter{db: db}
n int
} }
func newLoginLimiter() *loginLimiter { func (l *loginLimiter) blocked(ctx context.Context, key string, max int) bool {
return &loginLimiter{failures: map[string]*loginWindowCount{}} cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
} var count int
err := l.db.QueryRowContext(ctx, `
func (l *loginLimiter) blocked(key string, max int) bool { SELECT count FROM rate_limit_counters
l.mu.Lock() WHERE key = $1 AND window_start > $2`,
defer l.mu.Unlock() key, cutoff,
c, ok := l.failures[key] ).Scan(&count)
if !ok || time.Since(c.start) > loginWindow { if err != nil {
// No row (never failed, or its window already expired): not blocked.
// A real query error fails the same way — a rate limiter that locks
// everyone out during a brief database hiccup is worse than one that
// is briefly too generous.
return false return false
} }
return c.n >= max return count >= max
} }
func (l *loginLimiter) fail(keys ...string) { func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
l.mu.Lock() now := time.Now().Unix()
defer l.mu.Unlock() windowSecs := int64(loginWindow.Seconds())
now := time.Now()
for k, c := range l.failures {
if now.Sub(c.start) > loginWindow {
delete(l.failures, k)
}
}
for _, key := range keys { for _, key := range keys {
c, ok := l.failures[key] if _, err := l.db.ExecContext(ctx, `
if !ok { INSERT INTO rate_limit_counters (key, window_start, count)
c = &loginWindowCount{start: now} VALUES ($1, $2, 1)
l.failures[key] = c ON CONFLICT (key) DO UPDATE SET
window_start = CASE WHEN rate_limit_counters.window_start <= $2 - $3
THEN $2 ELSE rate_limit_counters.window_start END,
count = CASE WHEN rate_limit_counters.window_start <= $2 - $3
THEN 1 ELSE rate_limit_counters.count + 1 END`,
key, now, windowSecs,
); err != nil {
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
} }
c.n++
} }
} }
func (l *loginLimiter) clear(key string) { func (l *loginLimiter) clear(ctx context.Context, key string) {
l.mu.Lock() if _, err := l.db.ExecContext(ctx, "DELETE FROM rate_limit_counters WHERE key = $1", key); err != nil {
defer l.mu.Unlock() log.Printf("rate limiter: clear %q: %v", key, err)
delete(l.failures, key) }
}
// purgeRateLimits deletes rate-limit windows that have expired, from the
// sweeper — otherwise every distinct username and address this server has
// ever seen a failed attempt from would stay a row forever.
func purgeRateLimits(ctx context.Context, db *sql.DB) {
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
res, err := db.ExecContext(ctx,
"DELETE FROM rate_limit_counters WHERE window_start <= $1", cutoff)
if err != nil {
log.Printf("sweeper: purge rate limit counters: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("sweeper: purged %d expired rate limit counter(s)", n)
}
} }
// clientAddr is the address a login is counted against. Behind the gateway // clientAddr is the address a login is counted against. Behind the gateway
@@ -171,6 +199,9 @@ func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, user
return err return err
} }
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what trips
// this rule. See cookieSecure's own doc comment above.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Name: sessionCookie,
Value: raw, Value: raw,
@@ -198,7 +229,7 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
userKey := "user:" + strings.ToLower(username) userKey := "user:" + strings.ToLower(username)
addrKey := "addr:" + clientAddr(r) addrKey := "addr:" + clientAddr(r)
if limiter.blocked(userKey, loginMaxPerUser) || limiter.blocked(addrKey, loginMaxPerAddr) { if limiter.blocked(r.Context(), userKey, loginMaxPerUser) || limiter.blocked(r.Context(), addrKey, loginMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later")) respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
return return
@@ -220,11 +251,11 @@ func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.Handl
} }
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
if !match || !hash.Valid { if !match || !hash.Valid {
limiter.fail(userKey, addrKey) limiter.fail(r.Context(), userKey, addrKey)
respond(w, http.StatusUnauthorized, errResp("invalid username or password")) respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
return return
} }
limiter.clear(userKey) limiter.clear(r.Context(), userKey)
if err := startSession(w, r, db, userID, publicURL); err != nil { if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
@@ -252,6 +283,9 @@ func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value)) db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
} }
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what
// trips this rule. See cookieSecure's own doc comment above.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Name: sessionCookie,
Value: "", Value: "",
@@ -291,9 +325,16 @@ func handleMe(db *sql.DB) http.HandlerFunc {
} }
var hash sql.NullString var hash sql.NullString
var dismissed *int64 var dismissed *int64
db.QueryRowContext(r.Context(), if err := db.QueryRowContext(r.Context(),
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1", "SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed) caller.ID).Scan(&hash, &dismissed); err != nil {
// fetchUser above already found this row, so an error here is a
// transient database problem, not a missing user — worth a 500
// rather than silently answering "no password, not dismissed",
// which a client would otherwise take at face value.
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{ respond(w, http.StatusOK, meResponse{
User: user, User: user,
HasPassword: hash.Valid, HasPassword: hash.Valid,
+182
View File
@@ -0,0 +1,182 @@
package api_test
import (
"net/http"
"testing"
)
// This file is the regression test for the pattern documented throughout
// middleware.go: every team-scoped handler calls requireTeamMember or
// requireTeamOwner before touching data, every self-or-admin handler calls
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
// pattern is enforced by convention, not by the type system — a new handler
// that forgets the call would compile and pass review on a quick read just
// as easily as one that remembers it. These tests exercise every route that
// carries one of those guards as a caller who should be refused, so a future
// handler missing its guard fails CI instead of becoming a silent IDOR.
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
// every team-scoped route, calls it as team A's owner against team B's
// resources. requireTeamMember and requireTeamOwner both answer a non-member
// with 404 (team.go's own reasoning: whether a team exists is itself
// something only its members should learn), so every one of these must come
// back 404 regardless of which of the two guards its handler uses.
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-a")
b := newTeam(t, s, "authz-b")
// An incident in B, to cover the ID-based routes under /api/incidents —
// scoped by the incident's own team_id rather than a {teamID} path
// segment, but through the same single chokepoint (incidentIDParam).
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
var incidents []struct {
ID int64 `json:"id"`
}
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
if len(incidents) == 0 {
t.Fatal("setup: no incident in team B to test against")
}
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
bPath := "/api/teams/" + id64(b.id)
tests := []struct {
method, path string
}{
// Team membership/ownership itself.
{http.MethodPut, bPath},
{http.MethodDelete, bPath},
{http.MethodGet, bPath + "/members"},
{http.MethodPost, bPath + "/members"},
{http.MethodDelete, bPath + "/members/1"},
// OIDC group binding.
{http.MethodGet, bPath + "/oidc-groups"},
{http.MethodPut, bPath + "/oidc-groups"},
// Invites.
{http.MethodGet, bPath + "/invites"},
{http.MethodPost, bPath + "/invites"},
{http.MethodDelete, bPath + "/invites/1"},
// Escalation.
{http.MethodGet, bPath + "/escalation"},
{http.MethodPut, bPath + "/escalation"},
// Dead man's switches.
{http.MethodGet, bPath + "/deadman/switches"},
{http.MethodPost, bPath + "/deadman/switches"},
{http.MethodPut, bPath + "/deadman/switches/1"},
{http.MethodDelete, bPath + "/deadman/switches/1"},
// Integrations.
{http.MethodGet, bPath + "/integrations"},
{http.MethodPost, bPath + "/integrations"},
{http.MethodPatch, bPath + "/integrations/1"},
{http.MethodDelete, bPath + "/integrations/1"},
// Schedule.
{http.MethodGet, bPath + "/schedule"},
{http.MethodPost, bPath + "/schedule"},
{http.MethodDelete, bPath + "/schedule/1"},
// Incidents, scoped by the incident's own team rather than a
// {teamID} segment.
{http.MethodGet, incidentPath},
{http.MethodGet, incidentPath + "/alerts"},
{http.MethodGet, incidentPath + "/timeline"},
{http.MethodGet, incidentPath + "/similar"},
{http.MethodPost, incidentPath + "/acknowledge"},
{http.MethodDelete, incidentPath + "/acknowledge"},
{http.MethodPost, incidentPath + "/resolve"},
{http.MethodPost, incidentPath + "/assign"},
{http.MethodPost, incidentPath + "/snooze"},
{http.MethodDelete, incidentPath + "/snooze"},
{http.MethodPost, incidentPath + "/archive"},
{http.MethodDelete, incidentPath + "/archive"},
{http.MethodPost, incidentPath + "/notes"},
{http.MethodDelete, incidentPath + "/notes/1"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
}
})
}
}
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
// in router.go directly: a signed-in, non-admin caller gets 403 from every
// route in it, before any handler body runs.
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-admin")
tests := []struct {
method, path string
}{
{http.MethodPost, "/api/users"},
{http.MethodDelete, "/api/users/1"},
{http.MethodPut, "/api/users/1/admin"},
{http.MethodPut, "/api/users/1/disabled"},
{http.MethodGet, "/api/admin/teams"},
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
{http.MethodGet, "/api/admin/settings"},
{http.MethodPut, "/api/admin/settings"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
}
})
}
}
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
// user's account must be refused, the same as AdminOnly's routes, even
// though these sit in the general authenticated group rather than behind
// AdminOnly itself.
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
s := newTS(t)
a := newTeam(t, s, "authz-self-a")
b := newTeam(t, s, "authz-self-b")
var members []struct {
UserID int64 `json:"user_id"`
}
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
if len(members) == 0 {
t.Fatal("setup: team B has no members")
}
bUserID := id64(members[0].UserID)
tests := []struct {
method, path string
}{
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
{http.MethodPut, "/api/users/" + bUserID + "/password"},
{http.MethodGet, "/api/users/" + bUserID + "/api-keys"},
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
}
for _, tc := range tests {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := a.call(tc.method, tc.path, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
}
})
}
}
+4
View File
@@ -283,6 +283,10 @@ func deadmanAlerts(ctx context.Context, db *sql.DB, teamID int64, cfg deadmanSet
nameList[i] = n nameList[i] = n
} }
// #nosec G202 -- sqlArgs.add/addList only ever splice in the "$N"
// placeholder they hand back, never a value; every value travels through
// args.all() as a bound parameter. See the sqlArgs doc comment in
// helpers.go.
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT id, team_id, fingerprint, labels, status, received_at SELECT id, team_id, fingerprint, labels, status, received_at
FROM alerts FROM alerts
+2 -2
View File
@@ -72,12 +72,12 @@ func normalizeUserCode(s string) string {
func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { func handleDeviceStart(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
addrKey := "device:" + clientAddr(r) addrKey := "device:" + clientAddr(r)
if limiter.blocked(addrKey, deviceStartMaxPerAddr) { if limiter.blocked(r.Context(), addrKey, deviceStartMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later")) respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
return return
} }
limiter.fail(addrKey) limiter.fail(r.Context(), addrKey)
deviceCode, deviceHash, err := randomToken() deviceCode, deviceHash, err := randomToken()
if err != nil { if err != nil {
+17
View File
@@ -72,8 +72,25 @@ func respond(w http.ResponseWriter, status int, v any) {
json.NewEncoder(w).Encode(v) json.NewEncoder(w).Encode(v)
} }
// maxBodyBytes caps an ordinary JSON request body. 1 MiB is far more than any
// endpoint below needs — it exists so an unauthenticated caller (signup,
// login, bootstrap) can't make the server buffer an arbitrarily large body
// before the request is even validated.
const maxBodyBytes = 1 << 20
func decodeJSON(r *http.Request, v any) error { func decodeJSON(r *http.Request, v any) error {
return decodeJSONLimit(r, v, maxBodyBytes)
}
// decodeJSONLimit is decodeJSON with an explicit cap, for the one endpoint
// (the Alertmanager webhook, see maxWebhookBodyBytes) whose real payloads can
// legitimately be larger than maxBodyBytes.
func decodeJSONLimit(r *http.Request, v any, limit int64) error {
defer r.Body.Close() defer r.Body.Close()
// w is nil: there is no ResponseWriter here to disable keep-alive with,
// which net/http documents as fine — the limit is still enforced, the
// connection just isn't closed early on a request that blows past it.
r.Body = http.MaxBytesReader(nil, r.Body, limit)
return json.NewDecoder(r.Body).Decode(v) return json.NewDecoder(r.Body).Decode(v)
} }
+13
View File
@@ -32,6 +32,8 @@ const (
evAcknowledged = "acknowledged" evAcknowledged = "acknowledged"
evUnacknowledged = "unacknowledged" evUnacknowledged = "unacknowledged"
evAssigned = "assigned" evAssigned = "assigned"
evArchived = "archived"
evUnarchived = "unarchived"
evSnoozed = "snoozed" evSnoozed = "snoozed"
evUnsnoozed = "unsnoozed" evUnsnoozed = "unsnoozed"
evResolved = "resolved" evResolved = "resolved"
@@ -143,6 +145,17 @@ func logEvent(ctx context.Context, q querier, incidentID int64, evType string, u
return err return err
} }
// logAssignedEvent records an assignment: user_id is the assignee, and the
// caller who performed it goes in the actor_* columns (migration 018), since
// user_id cannot hold both.
func logAssignedEvent(ctx context.Context, q querier, incidentID, assigneeID int64, actorUserID, actorServiceAccountID *int64) error {
_, err := q.ExecContext(ctx, `
INSERT INTO incident_events (incident_id, type, user_id, actor_user_id, actor_service_account_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
incidentID, evAssigned, assigneeID, actorUserID, actorServiceAccountID, time.Now().Unix())
return err
}
// todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day. // todayUTC is the schedule's day key. The schedule's smallest unit is one UTC day.
func todayUTC() string { func todayUTC() string {
return time.Now().UTC().Format("2006-01-02") return time.Now().UTC().Format("2006-01-02")
+80 -2
View File
@@ -13,6 +13,52 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// handleListClusters answers GET /api/incidents/clusters: the distinct values of
// the origin label across the caller's incidents from the last 90 days, sorted,
// so the queue can offer them as a filter. Optional team_id narrows it to one
// team. Empty when nothing carries the label, which is how the UI knows to show
// no filter at all.
func handleListClusters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
args := &sqlArgs{}
where := []string{
"team_id = ANY(" + args.add(callerTeamIDs(r.Context())) + ")",
"triggered_at >= " + args.add(time.Now().AddDate(0, 0, -90).Unix()),
}
if team := r.URL.Query().Get("team_id"); team != "" {
if n, err := strconv.ParseInt(team, 10, 64); err == nil {
where = append(where, "team_id = "+args.add(n))
}
}
label := args.add(originLabel)
rows, err := db.QueryContext(r.Context(),
fmt.Sprintf("SELECT DISTINCT group_labels->>%[1]s AS v FROM incidents WHERE %[2]s AND group_labels->>%[1]s <> '' ORDER BY v LIMIT 200",
label, strings.Join(where, " AND ")),
args.all()...)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
clusters := []string{}
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
clusters = append(clusters, v)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, clusters)
}
}
func handleListIncidents(db *sql.DB) http.HandlerFunc { func handleListIncidents(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
@@ -53,6 +99,12 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
if severity := q.Get("severity"); severity != "" { if severity := q.Get("severity"); severity != "" {
where = append(where, "i.severity = "+args.add(severity)) where = append(where, "i.severity = "+args.add(severity))
} }
// Where it came from: the value of the origin label (originLabel, by
// convention "cluster") among the incident's group labels. An incident has
// it only when the label is in Alertmanager's group_by.
if cluster := q.Get("cluster"); cluster != "" {
where = append(where, "i.group_labels->>"+args.add(originLabel)+" = "+args.add(cluster))
}
if assignee := q.Get("assigned_to"); assignee != "" { if assignee := q.Get("assigned_to"); assignee != "" {
if n, err := strconv.ParseInt(assignee, 10, 64); err == nil { if n, err := strconv.ParseInt(assignee, 10, 64); err == nil {
where = append(where, "i.assigned_to = "+args.add(n)) where = append(where, "i.assigned_to = "+args.add(n))
@@ -100,6 +152,10 @@ func handleListIncidents(db *sql.DB) http.HandlerFunc {
} }
incidents = append(incidents, i) incidents = append(incidents, i)
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, incidents) respond(w, http.StatusOK, incidents)
} }
} }
@@ -158,10 +214,14 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
rows, err := db.QueryContext(r.Context(), ` rows, err := db.QueryContext(r.Context(), `
SELECT e.id, e.incident_id, e.type, e.user_id, u.username, SELECT e.id, e.incident_id, e.type, e.user_id, u.username,
e.service_account_id, sa.name, e.service_account_id, sa.name,
e.actor_user_id, au.username,
e.actor_service_account_id, asa.name,
e.alert_id, e.detail, e.created_at e.alert_id, e.detail, e.created_at
FROM incident_events e FROM incident_events e
LEFT JOIN users u ON u.id = e.user_id LEFT JOIN users u ON u.id = e.user_id
LEFT JOIN service_accounts sa ON sa.id = e.service_account_id LEFT JOIN service_accounts sa ON sa.id = e.service_account_id
LEFT JOIN users au ON au.id = e.actor_user_id
LEFT JOIN service_accounts asa ON asa.id = e.actor_service_account_id
WHERE e.incident_id = $1 WHERE e.incident_id = $1
ORDER BY e.created_at ASC, e.id ASC`, id) ORDER BY e.created_at ASC, e.id ASC`, id)
if err != nil { if err != nil {
@@ -176,6 +236,8 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
var ts int64 var ts int64
if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username, if err := rows.Scan(&e.ID, &e.IncidentID, &e.Type, &e.UserID, &e.Username,
&e.ServiceAccountID, &e.ServiceAccountName, &e.ServiceAccountID, &e.ServiceAccountName,
&e.ActorUserID, &e.ActorUsername,
&e.ActorServiceAccountID, &e.ActorServiceAccountName,
&e.AlertID, &e.Detail, &ts); err != nil { &e.AlertID, &e.Detail, &ts); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
@@ -183,6 +245,10 @@ func handleIncidentTimeline(db *sql.DB) http.HandlerFunc {
e.CreatedAt = time.Unix(ts, 0).UTC() e.CreatedAt = time.Unix(ts, 0).UTC()
events = append(events, e) events = append(events, e)
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, events) respond(w, http.StatusOK, events)
} }
} }
@@ -315,8 +381,10 @@ func handleIncidentAssign(db *sql.DB) http.HandlerFunc {
req.UserID, id) { req.UserID, id) {
return return
} }
// On an "assigned" event user_id is the assignee, not the actor. // On an "assigned" event user_id is the assignee; the actor goes in
if err := logEvent(r.Context(), db, id, evAssigned, &req.UserID, nil, nil, nil); err != nil { // the actor_* columns.
actorUserID, actorSAID := callerActorIDs(r.Context())
if err := logAssignedEvent(r.Context(), db, id, req.UserID, actorUserID, actorSAID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
@@ -417,6 +485,11 @@ func handleIncidentArchive(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusNotFound, errResp("incident not found")) respond(w, http.StatusNotFound, errResp("incident not found"))
return return
} }
userID, saID := callerActorIDs(r.Context())
if err := logEvent(r.Context(), db, id, evArchived, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respondIncident(w, r, db, id) respondIncident(w, r, db, id)
} }
} }
@@ -437,6 +510,11 @@ func handleIncidentUnarchive(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusNotFound, errResp("incident not found")) respond(w, http.StatusNotFound, errResp("incident not found"))
return return
} }
userID, saID := callerActorIDs(r.Context())
if err := logEvent(r.Context(), db, id, evUnarchived, userID, saID, nil, nil); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
} }
+137
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
@@ -870,3 +871,139 @@ func contains(haystack []string, needle string) bool {
} }
return false return false
} }
// ---------------------------------------------------------------------------
// Actor on assign / archive / unarchive (terdut-server#35)
// ---------------------------------------------------------------------------
// lastEvent returns the newest timeline event of the given type.
func lastEvent(t *testing.T, events []map[string]any, typ string) map[string]any {
t.Helper()
for i := len(events) - 1; i >= 0; i-- {
if events[i]["type"] == typ {
return events[i]
}
}
t.Fatalf("no %q event in %v", typ, eventTypes(events))
return nil
}
func TestIncident_AssignRecordsHumanActor(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-asg-actor", "Assignable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/users",
map[string]string{"username": "alice", "email": "alice@test.com"}).Body.Close()
s.req(t, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 2}).Body.Close()
ev := lastEvent(t, timeline(t, s, 1), "assigned")
if ev["username"] != "alice" {
t.Errorf("expected the assignee alice in username, got %v", ev["username"])
}
if ev["actor_user_id"] == nil || ev["actor_username"] == nil {
t.Errorf("expected the assigning human in actor_*, got %v", ev)
}
if ev["actor_service_account_id"] != nil {
t.Errorf("expected no service-account actor, got %v", ev["actor_service_account_id"])
}
}
func TestIncident_ArchiveUnarchiveRecordHumanActor(t *testing.T) {
s := newTS(t)
postWebhook(t, s, []map[string]any{
amAlert("fp-arc-actor", "Archivable", "firing", "2026-05-20T10:00:00Z", zeroTime, nil),
})
s.req(t, http.MethodPost, "/api/incidents/1/resolve", nil).Body.Close()
s.req(t, http.MethodPost, "/api/incidents/1/archive", nil).Body.Close()
s.req(t, http.MethodDelete, "/api/incidents/1/archive", nil).Body.Close()
events := timeline(t, s, 1)
for _, typ := range []string{"archived", "unarchived"} {
ev := lastEvent(t, events, typ)
if ev["user_id"] == nil || ev["service_account_id"] != nil {
t.Errorf("%s: expected only the human actor, got %v", typ, ev)
}
}
}
func TestServiceAccount_AssignArchiveUnarchiveRecordActor(t *testing.T) {
s := newTS(t)
instanceKey := createServiceAccount(t, s, s.key, "operator", models.ServiceAccountScopeInstance, 0)
teamA := createTeamAs(t, s, instanceKey, "team-a")
keyA := createServiceAccount(t, s, instanceKey, "team-a-sa", models.ServiceAccountScopeTeam, teamA)
var integration struct {
Key string `json:"key"`
}
decode(t, s.reqAs(t, keyA, http.MethodPost, "/api/teams/"+id64(teamA)+"/integrations",
map[string]string{"name": "test"}), &integration)
postToIntegration(t, s, integration.Key, "fp-sa-35", "SA35") // incident 1
resp := s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/assign", map[string]any{"user_id": 1})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("service account assign: %d", resp.StatusCode)
}
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/resolve", nil)
resp.Body.Close()
resp = s.reqAs(t, keyA, http.MethodPost, "/api/incidents/1/archive", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("service account archive: %d", resp.StatusCode)
}
resp = s.reqAs(t, keyA, http.MethodDelete, "/api/incidents/1/archive", nil)
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("service account unarchive: %d", resp.StatusCode)
}
var events []map[string]any
decode(t, s.reqAs(t, keyA, http.MethodGet, "/api/incidents/1/timeline", nil), &events)
asg := lastEvent(t, events, "assigned")
if asg["actor_service_account_id"] == nil || asg["actor_user_id"] != nil {
t.Errorf("assigned: expected only the service-account actor, got %v", asg)
}
if asg["user_id"] == nil {
t.Errorf("assigned: user_id must stay the assignee, got %v", asg)
}
for _, typ := range []string{"archived", "unarchived"} {
ev := lastEvent(t, events, typ)
if ev["service_account_id"] == nil || ev["user_id"] != nil {
t.Errorf("%s: expected only the service-account actor, got %v", typ, ev)
}
}
}
// The queue can be narrowed to one cluster, and the distinct clusters are
// offered so the filter has something to list.
func TestIncidents_FilterByCluster(t *testing.T) {
s := newTS(t)
for _, c := range []string{"prod-eu", "prod-us"} {
fireGroupedAs(t, s, "fp-"+c,
map[string]string{"alertname": "PodRestarting", "cluster": c, "namespace": "n"},
`{}:{alertname="PodRestarting",cluster="`+c+`",namespace="n"}`)
}
// A cluster-less incident exists too, and must never match a cluster filter.
fireGroupedAs(t, s, "fp-none", map[string]string{"alertname": "DiskFull"}, `{}:{alertname="DiskFull"}`)
if got := len(listIncidents(t, s, "")); got != 3 {
t.Fatalf("expected 3 open incidents, got %d", got)
}
eu := listIncidents(t, s, "?cluster=prod-eu")
if len(eu) != 1 {
t.Fatalf("expected 1 incident for prod-eu, got %d", len(eu))
}
if labels, _ := eu[0]["group_labels"].(map[string]any); labels["cluster"] != "prod-eu" {
t.Errorf("filtered to the wrong cluster: %v", eu[0]["group_labels"])
}
if got := len(listIncidents(t, s, "?cluster=nowhere")); got != 0 {
t.Errorf("an unknown cluster should match nothing, got %d", got)
}
var clusters []string
decode(t, s.req(t, http.MethodGet, "/api/incidents/clusters", nil), &clusters)
if want := "prod-eu,prod-us"; strings.Join(clusters, ",") != want {
t.Errorf("clusters = %v, want %s", clusters, want)
}
}
+29 -1
View File
@@ -79,6 +79,28 @@ func AuthMiddleware(db *sql.DB) func(http.Handler) http.Handler {
} }
} }
// securityHeaders sets headers that cost nothing to send on every response,
// API or static site alike. nosniff is unconditional; HSTS only fires once
// cookieSecure's signal says the browser is actually looking at this server
// over HTTPS — TLS terminates at the gateway, which (as of this writing) sets
// neither header itself.
//
// max-age is 180 days rather than the usual year-plus: short enough that if
// HTTPS here ever broke for real, the header would age out of a browser's
// cache well within a release cycle instead of locking anyone out of a
// working server. Raise it once this has run clean for a while.
func securityHeaders(publicURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
if cookieSecure(publicURL, r) {
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains")
}
next.ServeHTTP(w, r)
})
}
}
// AdminOnly rejects a caller who is not a system administrator. It runs inside // AdminOnly rejects a caller who is not a system administrator. It runs inside
// AuthMiddleware's group, so by the time it sees a request the caller is known. // AuthMiddleware's group, so by the time it sees a request the caller is known.
// //
@@ -113,10 +135,16 @@ func requireSelfOrAdmin(w http.ResponseWriter, r *http.Request, targetID int64)
} }
// apiKeyUser resolves an API key to its user and stamps its last use. // apiKeyUser resolves an API key to its user and stamps its last use.
// expires_at IS NULL OR > now is part of the lookup itself, the same way
// serveAs's disabled_at check is: an expired key is one that cannot
// authenticate, by construction, rather than one that happens to still
// resolve and has to be caught afterwards.
func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) { func apiKeyUser(ctx context.Context, db *sql.DB, token string) (int64, bool) {
var keyID, userID int64 var keyID, userID int64
err := db.QueryRowContext(ctx, err := db.QueryRowContext(ctx,
"SELECT id, user_id FROM api_keys WHERE key_hash = $1", hashToken(token), `SELECT id, user_id FROM api_keys
WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > $2)`,
hashToken(token), time.Now().Unix(),
).Scan(&keyID, &userID) ).Scan(&keyID, &userID)
if err != nil { if err != nil {
return 0, false return 0, false
+48 -3
View File
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -409,9 +410,11 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID) strings.TrimSuffix(cfg.PublicURL, "/"), inc.ID)
} }
title := pageTitle(inc)
switch n.kind { switch n.kind {
case notifyResolved: case notifyResolved:
msg.Title = "Resolved: " + inc.Title msg.Title = "Resolved: " + title
msg.Message = "All alerts stopped firing after " + msg.Message = "All alerts stopped firing after " +
humanDuration(time.Since(inc.TriggeredAt)) humanDuration(time.Since(inc.TriggeredAt))
msg.Priority = ntfyPriorityLow msg.Priority = ntfyPriorityLow
@@ -419,9 +422,9 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg return msg
case notifyReminder: case notifyReminder:
msg.Title = "Still unacknowledged: " + inc.Title msg.Title = "Still unacknowledged: " + title
default: default:
msg.Title = inc.Title msg.Title = title
} }
severity := derefString(inc.Severity) severity := derefString(inc.Severity)
@@ -443,6 +446,48 @@ func renderNotification(inc models.Incident, n outboxRow, firing int, cfg Notify
return msg return msg
} }
// originLabel is the label that says where an alert came from, for a team with
// several Kubernetes clusters behind it. It comes from Prometheus's
// externalLabels and reaches an incident through Alertmanager's group_by; the
// web UI reads the same label, and the README ("Several clusters, one team")
// explains how to set it up.
const originLabel = "cluster"
// pageTitle is the incident's title for a notification. A phone's lock screen
// cuts a long title off at the end, and the incident title puts the grouping
// labels there, so the cluster would be the first thing lost. When the incident
// has an origin it leads instead, "[prod-eu] PodRestarting (namespace=foo)", and
// is dropped from the parenthesis so it is not said twice. A title that is not
// in incidentTitle's "name (k=v, k=v)" shape keeps its text and gains the prefix.
func pageTitle(inc models.Incident) string {
origin := inc.GroupLabels[originLabel]
if origin == "" {
return inc.Title
}
return "[" + origin + "] " + titleWithoutLabel(inc.Title, originLabel, origin)
}
var titleShape = regexp.MustCompile(`(?s)^(.*?) \((.*)\)$`)
// titleWithoutLabel removes "key=value" from the parenthesised tail of a title
// built by incidentTitle, and the parentheses with it if nothing else is left.
func titleWithoutLabel(title, key, value string) string {
m := titleShape.FindStringSubmatch(title)
if m == nil {
return title
}
var rest []string
for _, part := range strings.Split(m[2], ", ") {
if part != key+"="+value {
rest = append(rest, part)
}
}
if len(rest) == 0 {
return m[1]
}
return m[1] + " (" + strings.Join(rest, ", ") + ")"
}
// ntfy's priority scale. Max is the one that overrides the phone's quiet // ntfy's priority scale. Max is the one that overrides the phone's quiet
// settings, which is the whole point of paging on critical. // settings, which is the whole point of paging on critical.
const ( const (
+82
View File
@@ -1,6 +1,7 @@
package api_test package api_test
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -164,10 +165,91 @@ func fireCritical(t *testing.T, s *ts) {
}, "{}:{alertname=\"DiskFull\"}") }, "{}:{alertname=\"DiskFull\"}")
} }
// fireGrouped posts one critical alert whose Alertmanager group carries the
// given labels, the way group_by puts them on the webhook.
func fireGrouped(t *testing.T, s *ts, groupLabels map[string]string, groupKey string) {
t.Helper()
fireGroupedAs(t, s, "fp-grouped", groupLabels, groupKey)
}
// fireGroupedAs is fireGrouped with its own alert fingerprint, for a test that
// needs several alerts open at once.
func fireGroupedAs(t *testing.T, s *ts, fingerprint string, groupLabels map[string]string, groupKey string) {
t.Helper()
payload := map[string]any{
"version": "4", "status": "firing", "groupKey": groupKey, "groupLabels": groupLabels,
"alerts": []map[string]any{amAlert(fingerprint, "PodRestarting", "firing",
"2026-05-20T10:00:00Z", zeroTime, map[string]string{"severity": "critical"})},
}
data, _ := json.Marshal(payload)
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", bytes.NewReader(data))
if err != nil {
t.Fatalf("post webhook: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("webhook returned %d", resp.StatusCode)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Delivery // Delivery
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// A phone cuts a long title off at the end, and the incident title keeps the
// grouping labels there, so the cluster leads the page instead.
func TestNotify_ClusterLeadsTheTitle(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
fireGrouped(t, s, map[string]string{
"alertname": "PodRestarting", "cluster": "prod-eu", "namespace": "shop",
}, `{}:{alertname="PodRestarting",cluster="prod-eu",namespace="shop"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "[prod-eu] PodRestarting (namespace=shop)"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
// Only the cluster is the whole grouping: no parenthesis is left behind.
func TestNotify_ClusterAloneLeavesNoParenthesis(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{})
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "cluster": "prod-eu"},
`{}:{alertname="PodRestarting",cluster="prod-eu"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "[prod-eu] PodRestarting"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
// Nothing changes for a team whose alerts have no cluster label.
func TestNotify_NoClusterKeepsTheTitle(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{})
fireGrouped(t, s, map[string]string{"alertname": "PodRestarting", "namespace": "shop"},
`{}:{alertname="PodRestarting",namespace="shop"}`)
s.sweepNotify(t)
msgs := f.messages()
if len(msgs) != 1 {
t.Fatalf("expected 1 push, got %d", len(msgs))
}
if want := "PodRestarting (namespace=shop)"; msgs[0].Title != want {
t.Errorf("title = %q, want %q", msgs[0].Title, want)
}
}
func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) { func TestNotify_TriggeredIncidentPagesOnCall(t *testing.T) {
s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"}) s, f := notifyTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
+18 -6
View File
@@ -121,12 +121,12 @@ func ssoRedirect(w http.ResponseWriter, r *http.Request, code ssoError) {
func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc { func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
addrKey := "oidc:" + clientAddr(r) addrKey := "oidc:" + clientAddr(r)
if limiter.blocked(addrKey, oidcStartMaxPerAddr) { if limiter.blocked(r.Context(), addrKey, oidcStartMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds()))) w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later")) respond(w, http.StatusTooManyRequests, errResp("too many sign-in attempts, try again later"))
return return
} }
limiter.fail(addrKey) limiter.fail(r.Context(), addrKey)
state, stateHash, err := randomToken() state, stateHash, err := randomToken()
if err != nil { if err != nil {
@@ -160,6 +160,9 @@ func handleOIDCLogin(db *sql.DB, prov *oidc.Provider, limiter *loginLimiter, pub
return return
} }
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what
// trips this rule. See cookieSecure's own doc comment in auth.go.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: oidcStateCookie, Name: oidcStateCookie,
Value: state, Value: state,
@@ -182,6 +185,9 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
// The state cookie has done its job once the callback arrives, whatever // The state cookie has done its job once the callback arrives, whatever
// the outcome. // the outcome.
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what
// trips this rule. See cookieSecure's own doc comment in auth.go.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1, Name: oidcStateCookie, Value: "", Path: "/api/oidc", MaxAge: -1,
HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode, HttpOnly: true, Secure: cookieSecure(publicURL, r), SameSite: http.SameSiteLaxMode,
@@ -189,7 +195,13 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
q := r.URL.Query() q := r.URL.Query()
if e := q.Get("error"); e != "" { if e := q.Get("error"); e != "" {
log.Printf("oidc: provider returned error %q: %s", e, q.Get("error_description")) // %q on both: this runs before state is checked against the
// cookie, so error and error_description are still whatever the
// request's query string says, not yet known to be the real
// provider's. %q keeps a crafted value (say, one holding a
// newline) from forging a second log line rather than just
// being a quoted string within this one.
log.Printf("oidc: provider returned error %q: %q", e, q.Get("error_description")) // #nosec G706 -- both %q
ssoRedirect(w, r, ssoDenied) ssoRedirect(w, r, ssoDenied)
return return
} }
@@ -226,7 +238,7 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
grants := oidc.ComputeGrants(cfg, identity.Groups) grants := oidc.ComputeGrants(cfg, identity.Groups)
if !grants.Admitted { if !grants.Admitted {
log.Printf("oidc: %q (%s) is in none of the allowed groups", identity.Username, identity.Subject) log.Printf("oidc: %q (%q) is in none of the allowed groups", identity.Username, identity.Subject) // #nosec G706 -- both %q
ssoRedirect(w, r, ssoNotAllowed) ssoRedirect(w, r, ssoNotAllowed)
return return
} }
@@ -243,11 +255,11 @@ func handleOIDCCallback(db *sql.DB, prov *oidc.Provider, publicURL string) http.
if err != nil { if err != nil {
var se ssoError var se ssoError
if errors.As(err, &se) { if errors.As(err, &se) {
log.Printf("oidc: refused %q (%s): %v", identity.Username, identity.Subject, se) log.Printf("oidc: refused %q (%q): %v", identity.Username, identity.Subject, se) // #nosec G706 -- both %q
ssoRedirect(w, r, se) ssoRedirect(w, r, se)
return return
} }
log.Printf("oidc: sign in %q: %v", identity.Username, err) log.Printf("oidc: sign in %q: %v", identity.Username, err) // #nosec G706 -- %q
ssoRedirect(w, r, ssoFailed) ssoRedirect(w, r, ssoFailed)
return return
} }
+161
View File
@@ -0,0 +1,161 @@
package api
// This file is internal (package api, not api_test) because loginLimiter and
// its blocked/fail/clear methods are unexported, and TestLoginLimiter_SharedAcrossReplicas
// specifically needs to construct two separate loginLimiter values pointed at
// one database — standing in for two replicas — which only this package can
// do. It duplicates testdb_test.go's newTestDB/withSearchPath rather than
// importing them: those live in the separate api_test package, compiled from
// this directory's external test files, and are not visible here. Same
// reasoning as advisory_lock_test.go, which makes the same trade for the
// same reason.
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/db"
_ "github.com/jackc/pgx/v5/stdlib"
)
var rateLimiterSchemaSeq int
// rateLimiterTestDB returns a migrated database private to this test.
func rateLimiterTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TERDUT_TEST_DSN")
if dsn == "" {
t.Fatalf("TERDUT_TEST_DSN is not set: these tests need Postgres.\n" +
"Run `make test-db` for a local one, then\n" +
" export TERDUT_TEST_DSN=postgres://terdut:terdut@localhost:5432/terdut_test?sslmode=disable")
}
rateLimiterSchemaSeq++
schema := fmt.Sprintf("test_rl_%d_%d", os.Getpid(), rateLimiterSchemaSeq)
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("connect to TERDUT_TEST_DSN: %v", err)
}
defer admin.Close()
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
t.Fatalf("create schema %s: %v", schema, err)
}
database, err := db.Open(rateLimiterWithSearchPath(dsn, schema))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() {
database.Close()
cleanup, err := sql.Open("pgx", dsn)
if err != nil {
return
}
defer cleanup.Close()
if _, err := cleanup.Exec("DROP SCHEMA " + schema + " CASCADE"); err != nil {
t.Logf("drop schema %s: %v", schema, err)
}
})
return database
}
func rateLimiterWithSearchPath(dsn, schema string) string {
opt := "-csearch_path=" + schema
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
u, err := url.Parse(dsn)
if err == nil {
q := u.Query()
q.Set("options", opt)
u.RawQuery = q.Encode()
return u.String()
}
}
return dsn + " options='" + opt + "'"
}
func TestLoginLimiter_BlocksAtMax(t *testing.T) {
database := rateLimiterTestDB(t)
ctx := context.Background()
l := newLoginLimiter(database)
for range 3 {
if l.blocked(ctx, "k", 3) {
t.Fatal("blocked before reaching max")
}
l.fail(ctx, "k")
}
if !l.blocked(ctx, "k", 3) {
t.Fatal("not blocked after reaching max")
}
}
func TestLoginLimiter_ClearResetsTheCount(t *testing.T) {
database := rateLimiterTestDB(t)
ctx := context.Background()
l := newLoginLimiter(database)
l.fail(ctx, "k")
l.fail(ctx, "k")
l.clear(ctx, "k")
if l.blocked(ctx, "k", 1) {
t.Fatal("still blocked after clear")
}
}
func TestLoginLimiter_KeysAreIndependent(t *testing.T) {
database := rateLimiterTestDB(t)
ctx := context.Background()
l := newLoginLimiter(database)
l.fail(ctx, "a")
if l.blocked(ctx, "b", 1) {
t.Fatal("failing one key blocked an unrelated one")
}
}
// TestLoginLimiter_SharedAcrossReplicas is the regression test for the gap
// this migration closes: an in-memory limiter would let each replica count
// independently, so a caller hitting two different pods could rack up
// max*replicaCount failures before either one blocked. Two loginLimiter
// values sharing one database, standing in for two replicas behind the same
// load balancer, must instead see one combined count.
func TestLoginLimiter_SharedAcrossReplicas(t *testing.T) {
database := rateLimiterTestDB(t)
ctx := context.Background()
replicaA := newLoginLimiter(database)
replicaB := newLoginLimiter(database)
const max = 4
// Alternate which "replica" records the failure, as a real deployment
// would split requests across pods.
for i := range max {
replica := replicaA
if i%2 == 1 {
replica = replicaB
}
if replicaA.blocked(ctx, "k", max) || replicaB.blocked(ctx, "k", max) {
t.Fatalf("blocked after only %d of %d failures", i, max)
}
replica.fail(ctx, "k")
}
if !replicaA.blocked(ctx, "k", max) {
t.Fatal("replica A does not see the combined count as blocked")
}
if !replicaB.blocked(ctx, "k", max) {
t.Fatal("replica B does not see the combined count as blocked")
}
}
+6 -3
View File
@@ -23,13 +23,14 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
// One limiter each, both process-wide for the life of the router: login // One limiter each, both process-wide for the life of the router: login
// counts failed passwords, sign-up counts account creation, and mixing the // counts failed passwords, sign-up counts account creation, and mixing the
// two would let a burst of sign-ups lock somebody out of logging in. // two would let a burst of sign-ups lock somebody out of logging in.
loginLimit := newLoginLimiter() loginLimit := newLoginLimiter(db)
signupLimiter := newLoginLimiter() signupLimiter := newLoginLimiter(db)
oidcLimit := newLoginLimiter() oidcLimit := newLoginLimiter(db)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) r.Use(middleware.Logger)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(securityHeaders(notify.PublicURL))
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respond(w, http.StatusOK, map[string]string{"status": "ok"}) respond(w, http.StatusOK, map[string]string{"status": "ok"})
@@ -111,6 +112,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/users/{id}/teams", handleUserTeams(db)) r.Get("/api/users/{id}/teams", handleUserTeams(db))
r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db)) r.Put("/api/users/{id}/notify", handleSetNotifyTarget(db))
r.Put("/api/users/{id}/password", handleSetPassword(db)) r.Put("/api/users/{id}/password", handleSetPassword(db))
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db)) r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db)) r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
@@ -143,6 +145,7 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
r.Get("/api/alerts/{id}", handleGetAlert(db)) r.Get("/api/alerts/{id}", handleGetAlert(db))
r.Get("/api/incidents", handleListIncidents(db)) r.Get("/api/incidents", handleListIncidents(db))
r.Get("/api/incidents/clusters", handleListClusters(db))
r.Get("/api/incidents/{id}", handleGetIncident(db)) r.Get("/api/incidents/{id}", handleGetIncident(db))
r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db)) r.Get("/api/incidents/{id}/alerts", handleIncidentAlerts(db))
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db)) r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
+3
View File
@@ -235,6 +235,9 @@ func scheduleRange(ctx context.Context, db *sql.DB, teamID int64, from, to strin
clause := strings.Join(where, " AND ") clause := strings.Join(where, " AND ")
// #nosec G202 -- clause is built from sqlArgs.add's "$N" placeholders
// only, never a value; every value travels through args.all() as a
// bound parameter. See the sqlArgs doc comment in helpers.go.
rows, err := db.QueryContext(ctx, ` rows, err := db.QueryContext(ctx, `
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
FROM schedule_entries s FROM schedule_entries s
+115
View File
@@ -0,0 +1,115 @@
package api_test
import (
"bytes"
"net/http"
"strings"
"testing"
"git.ryuvia.com/niklas/terdut-server/internal/api"
)
// ---------------------------------------------------------------------------
// Security headers
// ---------------------------------------------------------------------------
func TestSecurityHeaders_NosniffAlwaysSet(t *testing.T) {
s := newTS(t) // no PublicURL: the HTTPS signal is off
resp := s.req(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("X-Content-Type-Options = %q, want nosniff", got)
}
if got := resp.Header.Get("Strict-Transport-Security"); got != "" {
t.Errorf("Strict-Transport-Security = %q, want unset without an https PublicURL", got)
}
}
func TestSecurityHeaders_HSTSWhenPublicURLIsHTTPS(t *testing.T) {
s := newTS(t, api.NotifyConfig{PublicURL: "https://terdut.example.com"})
resp := s.req(t, http.MethodGet, "/api/me", nil)
defer resp.Body.Close()
got := resp.Header.Get("Strict-Transport-Security")
if !strings.HasPrefix(got, "max-age=") || !strings.Contains(got, "includeSubDomains") {
t.Errorf("Strict-Transport-Security = %q, want a max-age with includeSubDomains", got)
}
}
// ---------------------------------------------------------------------------
// Request body size limits
// ---------------------------------------------------------------------------
// TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody confirms an
// unauthenticated endpoint can't be made to buffer an arbitrarily large body:
// past maxBodyBytes, decodeJSON fails exactly as it would on any other
// malformed body, rather than the server reading the whole thing first.
func TestBodySizeLimit_OrdinaryEndpointRejectsOversizedBody(t *testing.T) {
s := newTS(t)
huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB, past the 1 MiB default
body := []byte(`{"username":"` + string(huge) + `","password":"x"}`)
resp, err := http.Post(s.URL+"/api/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST /api/login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d (oversized body treated as invalid)", resp.StatusCode, http.StatusBadRequest)
}
}
// TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault confirms the
// Alertmanager webhook's separate, larger cap actually takes effect: a body
// bigger than the ordinary default but within maxWebhookBodyBytes is still
// accepted, not rejected by the smaller limit every other endpoint gets.
func TestBodySizeLimit_WebhookAllowsLargerBodyThanDefault(t *testing.T) {
s := newTS(t)
// Padding kept inside one alert's annotation, comfortably past the 1 MiB
// default and still well under the webhook's 8 MiB cap.
padding := strings.Repeat("a", 3<<20) // 3 MiB
payload := `{"version":"4","status":"firing","groupKey":"big-group",` +
`"groupLabels":{"alertname":"BigAlert"},"alerts":[{"status":"firing",` +
`"labels":{"alertname":"BigAlert"},"annotations":{"note":"` + padding + `"},` +
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
`"fingerprint":"fp-big"}]}`
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", strings.NewReader(payload))
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want %d (body under the webhook's own cap)", resp.StatusCode, http.StatusOK)
}
}
// TestBodySizeLimit_WebhookRejectsPastItsOwnCap confirms the webhook's larger
// cap is still a cap, not an exemption from one.
func TestBodySizeLimit_WebhookRejectsPastItsOwnCap(t *testing.T) {
s := newTS(t)
huge := strings.Repeat("a", 9<<20) // 9 MiB, past the 8 MiB webhook cap
payload := `{"version":"4","status":"firing","groupKey":"huge-group",` +
`"groupLabels":{"alertname":"HugeAlert"},"alerts":[{"status":"firing",` +
`"labels":{"alertname":"HugeAlert"},"annotations":{"note":"` + huge + `"},` +
`"startsAt":"2026-05-20T10:00:00Z","endsAt":"0001-01-01T00:00:00Z",` +
`"fingerprint":"fp-huge"}]}`
resp, err := http.Post(s.URL+"/api/integrations/"+s.ingestKey+"/alertmanager",
"application/json", strings.NewReader(payload))
if err != nil {
t.Fatalf("POST webhook: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d (body past the webhook's own cap)", resp.StatusCode, http.StatusBadRequest)
}
}
+2 -2
View File
@@ -112,7 +112,7 @@ var errInviteUnusable = errors.New("invite is not usable")
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc { func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
addr := clientAddr(r) addr := clientAddr(r)
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) { if limiter.blocked(r.Context(), "signup:"+addr, maxSignupsPerAddr) {
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address")) respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
return return
} }
@@ -148,7 +148,7 @@ func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.Hand
var err error var err error
inv, err = loadInvite(r.Context(), db, req.Invite) inv, err = loadInvite(r.Context(), db, req.Invite)
if err != nil { if err != nil {
limiter.fail("signup:" + addr) limiter.fail(r.Context(), "signup:"+addr)
respond(w, http.StatusForbidden, errResp("this invite link is not usable")) respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return return
} }
+12
View File
@@ -73,6 +73,10 @@ func handleStatsTop(db *sql.DB) http.HandlerFunc {
} }
result = append(result, e) result = append(result, e)
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, result) respond(w, http.StatusOK, result)
} }
} }
@@ -104,6 +108,10 @@ func handleStatsByHour(db *sql.DB) http.HandlerFunc {
} }
counts[hr] = cnt counts[hr] = cnt
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
type entry struct { type entry struct {
Hour int `json:"hour"` Hour int `json:"hour"`
@@ -146,6 +154,10 @@ func handleStatsByDay(db *sql.DB) http.HandlerFunc {
} }
counts[dow] = cnt counts[dow] = cnt
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} dayNames := [7]string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
type entry struct { type entry struct {
+80 -3
View File
@@ -116,6 +116,10 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
u.DisabledAt = unixPtr(disabled) u.DisabledAt = unixPtr(disabled)
users = append(users, u) users = append(users, u)
} }
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, users) respond(w, http.StatusOK, users)
} }
} }
@@ -234,6 +238,11 @@ func handleDeleteUser(db *sql.DB) http.HandlerFunc {
} }
} }
// maxAPIKeyExpiryDays bounds expires_in_days: generous enough for any real
// rotation policy, tight enough to reject a typo (a year in hours, say) that
// would otherwise mint a key that outlives the server by decades.
const maxAPIKeyExpiryDays = 3650 // ~10 years
func handleCreateAPIKey(db *sql.DB) http.HandlerFunc { func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -247,6 +256,11 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
// ExpiresInDays is optional and, left zero, means the key never
// expires — the only behavior any key had before this field
// existed, so an existing integration that does not send it is
// unaffected.
ExpiresInDays int64 `json:"expires_in_days,omitempty"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body")) respond(w, http.StatusBadRequest, errResp("invalid request body"))
@@ -256,6 +270,10 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusBadRequest, errResp("name is required")) respond(w, http.StatusBadRequest, errResp("name is required"))
return return
} }
if req.ExpiresInDays < 0 || req.ExpiresInDays > maxAPIKeyExpiryDays {
respond(w, http.StatusBadRequest, errResp("expires_in_days must be 0 (never expires) or up to "+strconv.Itoa(maxAPIKeyExpiryDays)))
return
}
var exists int var exists int
if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil { if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = $1", userID).Scan(&exists); err != nil {
@@ -268,18 +286,77 @@ func handleCreateAPIKey(db *sql.DB) http.HandlerFunc {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
var expiresAt *int64
var expiresAtTime *time.Time
if req.ExpiresInDays > 0 {
t := time.Now().AddDate(0, 0, int(req.ExpiresInDays)).UTC()
u := t.Unix()
expiresAt = &u
expiresAtTime = &t
}
var keyID int64 var keyID int64
if err := db.QueryRowContext(r.Context(), if err := db.QueryRowContext(r.Context(),
"INSERT INTO api_keys (user_id, key_hash, name) VALUES ($1, $2, $3) RETURNING id", "INSERT INTO api_keys (user_id, key_hash, name, expires_at) VALUES ($1, $2, $3, $4) RETURNING id",
userID, hash, req.Name).Scan(&keyID); err != nil { userID, hash, req.Name, expiresAt).Scan(&keyID); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error")) respond(w, http.StatusInternalServerError, errResp("internal error"))
return return
} }
key := models.APIKey{ID: keyID, UserID: userID, Name: req.Name, Key: raw, CreatedAt: time.Now().UTC()} key := models.APIKey{
ID: keyID, UserID: userID, Name: req.Name, Key: raw,
CreatedAt: time.Now().UTC(), ExpiresAt: expiresAtTime,
}
respond(w, http.StatusCreated, key) respond(w, http.StatusCreated, key)
} }
} }
// handleListAPIKeys lists a user's own API keys: never the raw key itself
// (only ever returned once, at creation), just enough to tell them apart,
// see which are stale (last_used_at) and which are about to stop working
// (expires_at) — the data handleCreateAPIKey and apiKeyUser's last-use stamp
// already produce, with no endpoint to read it back until now.
func handleListAPIKeys(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, userID) {
return
}
rows, err := db.QueryContext(r.Context(),
`SELECT id, name, created_at, last_used_at, expires_at
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC`, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
keys := []models.APIKey{}
for rows.Next() {
var k models.APIKey
var created int64
var lastUsed, expires *int64
if err := rows.Scan(&k.ID, &k.Name, &created, &lastUsed, &expires); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
k.UserID = userID
k.CreatedAt = time.Unix(created, 0).UTC()
k.LastUsedAt = unixPtr(lastUsed)
k.ExpiresAt = unixPtr(expires)
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, keys)
}
}
func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc { func handleDeleteAPIKey(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) userID, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
@@ -0,0 +1,16 @@
-- Backs the rate limiters (failed logins, sign-ups, OIDC/device start) with
-- Postgres instead of an in-memory map, now that the server runs more than
-- one replica in production (v0.37.0): a counter that only ever sees its own
-- pod's traffic quietly let every one of these limits through multiplied by
-- the replica count.
--
-- window_start is the start of the current fixed window for key, in the same
-- "unix seconds" shape every other timestamp in this schema uses. The window
-- resets rather than slides, matching the in-memory limiter it replaces:
-- once a key's window is older than the limiter's window length, the next
-- failure starts a fresh one instead of extending the stale one.
CREATE TABLE rate_limit_counters (
key TEXT PRIMARY KEY,
window_start BIGINT NOT NULL,
count INT NOT NULL
);
@@ -0,0 +1,7 @@
-- Optional expiry on a user's own API keys. NULL (the existing default for
-- every row already in this table) means "never expires" -- the same
-- behavior these keys have always had, so no existing integration breaks.
-- Service account keys are deliberately NOT touched: they are a different
-- table, managed by automation, and already distinguished by their own
-- "tdsa_" prefix.
ALTER TABLE api_keys ADD COLUMN expires_at BIGINT;
@@ -0,0 +1,21 @@
-- Who performed an assignment (terdut-server#35). On an 'assigned' event
-- incident_events.user_id is the assignee, so the actor needs columns of its
-- own. Only populated for 'assigned' events; every other event type keeps
-- using user_id/service_account_id for the actor. Older 'assigned' rows stay
-- NULL (the actor was never recorded). Same shape as migration 015: nullable,
-- mutually exclusive, ON DELETE SET NULL.
--
-- assigned_to_service_account_id is still deliberately not added: making
-- service accounts assignable is a separate change (request body, assignee
-- picker, notifier, filters).
ALTER TABLE incident_events
ADD COLUMN actor_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN actor_service_account_id BIGINT REFERENCES service_accounts(id) ON DELETE SET NULL;
ALTER TABLE incident_events
ADD CONSTRAINT incident_events_assign_actor_xor_chk CHECK (
actor_user_id IS NULL OR actor_service_account_id IS NULL
);
CREATE INDEX incident_events_actor_user_id_idx ON incident_events(actor_user_id);
CREATE INDEX incident_events_actor_service_account_id_idx ON incident_events(actor_service_account_id);
+10 -1
View File
@@ -74,7 +74,8 @@ type Incident struct {
// and is the only history this server keeps — alert rows are mutated in place. // and is the only history this server keeps — alert rows are mutated in place.
// //
// Type is one of: triggered, alert_added, alert_resolved, acknowledged, // Type is one of: triggered, alert_added, alert_resolved, acknowledged,
// unacknowledged, assigned, snoozed, unsnoozed, resolved, note. UserID and // unacknowledged, assigned, archived, unarchived, snoozed, unsnoozed,
// resolved, note. UserID and
// ServiceAccountID are mutually exclusive; both nil means the server acted // ServiceAccountID are mutually exclusive; both nil means the server acted
// rather than any caller. // rather than any caller.
type IncidentEvent struct { type IncidentEvent struct {
@@ -92,6 +93,14 @@ type IncidentEvent struct {
ServiceAccountID *int64 `json:"service_account_id,omitempty"` ServiceAccountID *int64 `json:"service_account_id,omitempty"`
ServiceAccountName *string `json:"service_account_name,omitempty"` ServiceAccountName *string `json:"service_account_name,omitempty"`
// Actor* name who performed an 'assigned' event, whose UserID is the
// assignee. Mutually exclusive; unset on every other event type and on
// assignments made before migration 018. See terdut-server#35.
ActorUserID *int64 `json:"actor_user_id,omitempty"`
ActorUsername *string `json:"actor_username,omitempty"`
ActorServiceAccountID *int64 `json:"actor_service_account_id,omitempty"`
ActorServiceAccountName *string `json:"actor_service_account_name,omitempty"`
AlertID *int64 `json:"alert_id,omitempty"` AlertID *int64 `json:"alert_id,omitempty"`
Detail *string `json:"detail,omitempty"` Detail *string `json:"detail,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
+8
View File
@@ -37,5 +37,13 @@ type APIKey struct {
Name string `json:"name"` Name string `json:"name"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"`
// ExpiresAt is nil for a key that never expires, which is every key
// created before this field existed and still the default for a new one
// unless its creator asks otherwise (see handleCreateAPIKey's
// expires_in_days). AuthMiddleware stops accepting a key once this
// passes; nothing deletes the row for it.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
Key string `json:"key,omitempty"` // populated only on creation, never stored Key string `json:"key,omitempty"` // populated only on creation, never stored
} }
+319 -95
View File
@@ -10,33 +10,33 @@
--surface: #ffffff; --surface: #ffffff;
--surface-2: #eff1f4; --surface-2: #eff1f4;
--surface-hover: #f7f8fa; --surface-hover: #f7f8fa;
--border: #e2e5ea; --border: #d9dde4;
--border-strong: #cfd3da; --border-strong: #c5cad3;
--text: #16181d; --text: #16181d;
--muted: #5b626e; --muted: #5b626e;
--faint: #8a909b; --faint: #676d79;
--accent: #2f5bd3; --accent: #2f5bd3;
--accent-text: #ffffff; --accent-text: #ffffff;
--accent-soft: #e8eefc; --accent-soft: #e8eefc;
--crit: #d0342c; --crit: #c22d26;
--crit-soft: #fdecea; --crit-soft: #fdecea;
--warn: #b86e00; --warn: #8f5500;
--warn-soft: #fdf3e1; --warn-soft: #fdf3e1;
--info: #2f6fdf; --info: #245fc7;
--info-soft: #e9f0fd; --info-soft: #e9f0fd;
--ok: #1d7f4c; --ok: #1a7445;
--ok-soft: #e6f5ec; --ok-soft: #e6f5ec;
--snooze: #6b5bd2; --snooze: #6050c8;
--snooze-soft: #efedfb; --snooze-soft: #efedfb;
/* Two hues that mean nothing on their own. The rota needs six colours to /* Two hues that mean nothing on their own. The rota needs six colours to
tell six people apart and the palette above only has four that are not tell six people apart and the palette above only has four that are not
already an alarm. */ already an alarm. */
--teal: #0f7d8c; --teal: #0c7180;
--teal-soft: #e3f4f6; --teal-soft: #e3f4f6;
--pink: #b3427e; --pink: #a63b73;
--pink-soft: #fbe8f2; --pink-soft: #fbe8f2;
--radius: 10px; --radius: 10px;
@@ -72,17 +72,21 @@
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);
} }
/* Dark palette. Applied by the OS preference unless the user chose Light in
Account (data-theme="light" on <html>, set by js/theme.js), and always when
they chose Dark. The two blocks must stay identical: CSS has no way to share
a declaration list between a media query and an attribute selector. */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:not([data-theme="light"]) {
--bg: #0f1115; --bg: #0f1115;
--surface: #171a20; --surface: #1a1e26;
--surface-2: #1f232b; --surface-2: #232834;
--surface-hover: #1c2027; --surface-hover: #20252e;
--border: #2a2f38; --border: #343b49;
--border-strong: #394050; --border-strong: #444d5f;
--text: #e7e9ed; --text: #e7e9ed;
--muted: #a0a7b3; --muted: #a0a7b3;
--faint: #737a87; --faint: #8a92a0;
--accent: #6d8ff0; --accent: #6d8ff0;
--accent-text: #0b0d12; --accent-text: #0b0d12;
@@ -108,6 +112,42 @@
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%); --shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
} }
} }
:root[data-theme="dark"] {
--bg: #0f1115;
--surface: #1a1e26;
--surface-2: #232834;
--surface-hover: #20252e;
--border: #343b49;
--border-strong: #444d5f;
--text: #e7e9ed;
--muted: #a0a7b3;
--faint: #8a92a0;
--accent: #6d8ff0;
--accent-text: #0b0d12;
--accent-soft: #1d2640;
--crit: #ff6b61;
--crit-soft: #3a1c1b;
--warn: #f0b140;
--warn-soft: #362a14;
--info: #74a3ff;
--info-soft: #1a2640;
--ok: #4cc488;
--ok-soft: #15301f;
--snooze: #a89bff;
--snooze-soft: #262245;
--teal: #4fc2d4;
--teal-soft: #0f2e33;
--pink: #f07fb8;
--pink-soft: #3a1c2d;
--shadow: 0 1px 2px rgb(0 0 0 / 40%);
--shadow-lg: 0 16px 40px rgb(0 0 0 / 55%);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
[hidden] { display: none !important; } [hidden] { display: none !important; }
@@ -237,6 +277,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); } .open-pill.has-triggered { background: var(--crit-soft); color: var(--crit); }
.open-pill.has-triggered::before { background: var(--crit); } .open-pill.has-triggered::before { background: var(--crit); }
.open-pill.all-acked::before { background: var(--warn); } .open-pill.all-acked::before { background: var(--warn); }
/* "All clear" is a status, not an action: green with a check, no dot. */
.open-pill.all-clear { background: var(--ok-soft); color: var(--ok); }
.open-pill.all-clear::before { content: none; }
.open-pill .icon { width: 14px; height: 14px; }
/* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the /* Bottom tab bar on phones (Queue, On-call, Alerts, Team, More); becomes the
left sidebar from 900px, where the desktop block below redeclares display left sidebar from 900px, where the desktop block below redeclares display
@@ -252,6 +296,7 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
} }
.nav-brand { display: none; } .nav-brand { display: none; }
.nav-sep, .nav-avatar { display: none; }
/* Hidden here (shown from 900px below): on the phone bar the team switcher /* Hidden here (shown from 900px below): on the phone bar the team switcher
lives in the topbar instead, as #team-selector-mobile. */ lives in the topbar instead, as #team-selector-mobile. */
.nav-team-selector { display: none; } .nav-team-selector { display: none; }
@@ -261,7 +306,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
block below cancels it back to a natural-width row item. */ block below cancels it back to a natural-width row item. */
flex: 1 1 0; flex: 1 1 0;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
color: var(--faint); font-size: 11px; font-weight: 600; color: var(--muted); font-size: 12px; font-weight: 600;
/* The More tab is a <button>; without this it keeps the browser's grey box
and reads as a highlighted tab beside four plain links. */
background: none; border: 0; font-family: inherit; cursor: pointer;
/* min-width lets a column shrink below its label's natural width, which is /* min-width lets a column shrink below its label's natural width, which is
what stops six tabs widening the bar past the screen. */ what stops six tabs widening the bar past the screen. */
min-width: 0; padding: 0 2px; min-width: 0; padding: 0 2px;
@@ -276,6 +324,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } .nav-link svg { width: 24px; height: 24px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.nav-link[aria-current="page"] { color: var(--accent); } .nav-link[aria-current="page"] { color: var(--accent); }
/* The open tab's icon is filled, so it is not told apart by colour alone. */
.nav-link[aria-current="page"] svg { fill: currentColor; fill-opacity: 0.16; }
.nav-badge { .nav-badge {
position: absolute; top: 6px; left: calc(50% + 6px); position: absolute; top: 6px; left: calc(50% + 6px);
min-width: 18px; height: 18px; padding: 0 5px; min-width: 18px; height: 18px; padding: 0 5px;
@@ -290,13 +340,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.nav-team-selector, .nav-team-selector,
.team-selector-mobile { .team-selector-mobile {
display: inline-flex; align-items: center; gap: 8px; align-items: center; gap: 8px;
border: 1px solid var(--border-strong); border-radius: 999px; border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
font-size: 13px; font-weight: 600; cursor: pointer; font-size: 13px; font-weight: 600; cursor: pointer;
padding: 4px 12px; max-width: 100%; padding: 4px 12px; max-width: 100%;
} }
.team-selector-mobile { padding: 4px 10px; font-size: 12px; max-width: 120px; } .team-selector-mobile { display: inline-flex; padding: 4px 10px; font-size: 12px; max-width: 120px; }
.team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .team-selector-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; } .team-selector-chevron { width: 14px; height: 14px; flex: none; color: var(--faint); margin-left: -2px; }
@@ -340,18 +390,28 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--surface); color: var(--muted); background: var(--surface); color: var(--muted);
font-size: 13px; font-weight: 600; cursor: pointer; font-size: 13px; font-weight: 600; cursor: pointer;
} }
.chip[aria-selected="true"] { background: var(--text); border-color: var(--text); color: var(--bg); } .chip[aria-selected="true"], .chip[aria-checked="true"] { background: var(--text); border-color: var(--text); color: var(--bg); }
.theme-picker { padding: 0 0 8px; }
.chip .count { margin-left: 4px; opacity: 0.7; } .chip .count { margin-left: 4px; opacity: 0.7; }
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling) /* Nothing in it: step back so the chips that have something stand out. */
box stays flush with its real right edge regardless of scroll position, .chip .count.zero { opacity: 0.4; }
which turned out not to be true of position:sticky here — as a flex /* A scrolling strip fades on the right edge while there is more to scroll to
item, its sticky offset interacted with the row's gap and its own (fadeOnOverflow in ui.js sets data-more). A mask on the strip itself, not an
negative margin, landing short of the edge by about one gap's width. */ element inside it: anything inside a scroller scrolls away with the content,
.chips-fade { which is what the old overlay did. */
position: absolute; top: 0; right: 0; bottom: 0; [data-more] {
width: 24px; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
background: linear-gradient(to right, transparent, var(--bg)); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
pointer-events: none; }
/* The queue's cluster filter: a pill-shaped select under the status chips. */
.queue-origin { position: relative; display: flex; align-items: center; padding: 0 16px 8px; }
.origin-select-icon { position: absolute; left: 28px; width: 16px; height: 16px; color: var(--muted); pointer-events: none; }
.origin-select {
min-height: 34px; max-width: 100%; padding: 0 12px 0 34px;
border: 1px solid var(--border-strong); border-radius: 999px;
background: var(--surface); color: var(--text);
font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
} }
/* ---------- lists ---------- */ /* ---------- lists ---------- */
@@ -377,7 +437,10 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
background: var(--sev, var(--border-strong)); background: var(--sev, var(--border-strong));
} }
.row-title { .row-title {
font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 650; overflow: hidden; overflow-wrap: anywhere;
/* Two lines, not one: titles differ at the end ("PodRestarting
(namespace=...") and an ellipsis cut exactly the part that tells rows apart. */
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
} }
.row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; } .row-age { color: var(--faint); font-size: 13px; text-align: right; white-space: nowrap; }
.row-meta { .row-meta {
@@ -390,7 +453,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
/* Which team's queue a row came from. Only rendered for somebody in more than /* Which team's queue a row came from. Only rendered for somebody in more than
one team, so it never repeats the same word down the whole list. */ one team, so it never repeats the same word down the whole list. */
/* Separates the status chips from the team chips in the queue's filter row. */ /* Separates the status chips from the team chips in the queue's filter row. */
.chip-sep { width: 1px; align-self: stretch; background: var(--border); margin: 0 2px; }
.row-team { .row-team {
padding: 1px 6px; border-radius: 4px; padding: 1px 6px; border-radius: 4px;
@@ -436,6 +498,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
} }
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; } .badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
.badge.plain::before { display: none; } .badge.plain::before { display: none; }
.badge .badge-icon, .origin-chip .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
/* The cluster an incident or alert came from. Same shape as a badge, coloured
from the rcN palette (see originClass in format.js), never the severity one. */
.origin-chip {
display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
padding: 1px 8px; border-radius: 999px;
font-size: 12px; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap;
overflow: hidden; text-overflow: ellipsis;
}
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); } .badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); } .badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); } .badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
@@ -471,10 +542,6 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; } .detail-head .crumb { font-weight: 600; color: var(--muted); font-size: 14px; }
.detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; } .detail-title { font-size: var(--fs-xl); font-weight: 750; letter-spacing: -0.01em; margin: 16px 0 8px; overflow-wrap: anywhere; }
.detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; } .detail-badges { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
/* A copy of the sticky actionbar's primary button, right under the status
it responds to — see quickActions() in incident.js. */
.detail-quick-actions { margin-bottom: 14px; }
.detail-quick-actions .btn-primary { font-size: 16px; min-height: 44px; }
.card { .card {
background: var(--surface); background: var(--surface);
@@ -517,11 +584,25 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
.alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; } .alert-item-summary { color: var(--muted); font-size: 14px; overflow-wrap: anywhere; }
.alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); } .alert-item-foot { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 13px; color: var(--faint); }
.alert-item-foot a { color: var(--accent); font-weight: 600; } .alert-item-foot a { color: var(--accent); font-weight: 600; }
details > summary { cursor: pointer; color: var(--muted); font-size: 13px; font-weight: 600; list-style: none; } /* A disclosure is a button: a chevron that turns, and a 44px target on a
touch screen (the bare triangle it replaces was a few pixels wide). */
details > summary {
display: flex; align-items: center; gap: 8px;
min-height: 44px; margin: 0 -6px; padding: 0 6px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--muted); font-size: 14px; font-weight: 600; list-style: none;
}
details > summary::-webkit-details-marker { display: none; } details > summary::-webkit-details-marker { display: none; }
details > summary::before { content: "▸ "; } details > summary::before {
details[open] > summary::before { content: "▾ "; } content: ""; flex: none; width: 7px; height: 7px; margin: 0 5px 0 3px;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(-45deg); transition: transform 0.12s;
}
details[open] > summary::before { transform: rotate(45deg); }
details > summary:hover { background: var(--surface-2); color: var(--text); }
details > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
details[open] > summary { margin-bottom: 8px; } details[open] > summary { margin-bottom: 8px; }
@media (hover: hover) and (pointer: fine) { details > summary { min-height: 32px; } }
@media (prefers-reduced-motion: reduce) { details > summary::before { transition: none; } }
/* One .tl-phase per status the incident has been through (see /* One .tl-phase per status the incident has been through (see
timelinePhases() in incident.js) — each with its own .timeline <ol>, so timelinePhases() in incident.js) — each with its own .timeline <ol>, so
@@ -543,18 +624,26 @@ details[open] > summary { margin-bottom: 8px; }
.tl-item::before { .tl-item::before {
content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border); content: ""; position: absolute; left: 23px; top: 0; bottom: 0; width: 2px; background: var(--border);
} }
.tl-item:first-child::before { top: 16px; } .tl-item:first-child::before { top: 18px; }
.tl-item:last-child::before { bottom: calc(100% - 16px); } .tl-item:last-child::before { bottom: calc(100% - 18px); }
.tl-dot { .tl-dot {
position: relative; z-index: 1; position: relative; z-index: 1;
width: 10px; height: 10px; margin: 5px 0 0 5px; border-radius: 50%; display: grid; place-items: center;
background: var(--surface); border: 2px solid var(--faint); width: 20px; height: 20px; border-radius: 50%;
background: var(--surface); border: 2px solid var(--border-strong); color: var(--muted);
} }
.tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit); } /* An event with no icon keeps the old plain dot. */
.tl-acknowledged .tl-dot { border-color: var(--warn); background: var(--warn); } .tl-dot:empty { width: 10px; height: 10px; margin: 5px 0 0 5px; }
.tl-resolved .tl-dot { border-color: var(--ok); background: var(--ok); } .tl-icon { width: 11px; height: 11px; stroke-width: 2.6; }
.tl-snoozed .tl-dot { border-color: var(--snooze); } .tl-triggered .tl-dot, .tl-notify_failed .tl-dot, .tl-deadman_silent .tl-dot { border-color: var(--crit); color: var(--crit); }
.tl-note .tl-dot { border-color: var(--accent); background: var(--accent); } .tl-acknowledged .tl-dot { border-color: var(--warn); color: var(--warn); }
.tl-resolved .tl-dot { border-color: var(--ok); color: var(--ok); }
.tl-snoozed .tl-dot { border-color: var(--snooze); color: var(--snooze); }
.tl-note .tl-dot, .tl-resolution_note .tl-dot { border-color: var(--accent); color: var(--accent); }
.tl-problem .tl-dot { border-color: var(--warn); background: var(--warn-soft); color: var(--warn); }
.tl-problem.tl-notify_failed .tl-dot, .tl-problem.tl-deadman_silent .tl-dot { border-color: var(--crit); background: var(--crit-soft); color: var(--crit); }
.tl-problem .tl-text { color: var(--warn); font-weight: 600; }
.tl-problem.tl-notify_failed .tl-text, .tl-problem.tl-deadman_silent .tl-text { color: var(--crit); }
.tl-body { min-width: 0; font-size: 14px; } .tl-body { min-width: 0; font-size: 14px; }
.tl-text { overflow-wrap: anywhere; } .tl-text { overflow-wrap: anywhere; }
.tl-text .who { font-weight: 650; } .tl-text .who { font-weight: 650; }
@@ -650,7 +739,6 @@ details[open] > summary { margin-bottom: 8px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; } .page-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 16px auto 12px; }
.page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); } .page-head h2 { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.now-card { display: flex; align-items: center; gap: 14px; padding: 16px; margin-top: 16px; }
.avatar { .avatar {
flex: none; display: grid; place-items: center; flex: none; display: grid; place-items: center;
width: 44px; height: 44px; border-radius: 50%; width: 44px; height: 44px; border-radius: 50%;
@@ -658,40 +746,62 @@ details[open] > summary { margin-bottom: 8px; }
font-weight: 750; font-size: 17px; text-transform: uppercase; font-weight: 750; font-size: 17px; text-transform: uppercase;
} }
.avatar.none { background: var(--surface-2); color: var(--faint); } .avatar.none { background: var(--surface-2); color: var(--faint); }
.now-label { color: var(--muted); font-size: 13px; font-weight: 600; }
.now-name { font-size: 20px; font-weight: 750; }
.you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; } .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 /* 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. */ is easier to spot there than this plain accent-coloured text. */
.you-badge { margin-left: 6px; } .you-badge { margin-left: 6px; }
.week-nav { display: flex; align-items: center; gap: 4px; } .week-nav { display: flex; align-items: center; gap: 4px; }
.week-nav .label { font-size: 14px; font-weight: 650; min-width: 9em; text-align: center; } .week-nav .month-label { font-size: 15px; font-weight: 650; min-width: 8em; text-align: center; }
/* The week-nav button that shows the date range, "28 Sep – 4 Oct", with the /* 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 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 .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. */ 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-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; } .week-label small { color: var(--faint); font-weight: 600; font-size: 11px; margin-left: 2px; }
.days { list-style: none; margin: 0 auto; padding: 0; } .oncall-title h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.01em; margin: 8px 0 0; }
.day { display: grid; grid-template-columns: 3.2em 4.2em 1fr; align-items: center; gap: 8px; min-height: 50px; padding: 0 14px; } .oncall-title p { margin: 2px 0 0; font-size: 14px; }
.day + .day { border-top: 1px solid var(--border); } .oncall-grid { display: grid; gap: 0; }
.day-name { font-weight: 650; } .oncall-main, .oncall-side { min-width: 0; }
.day-date { color: var(--faint); font-size: 13px; } .hero-list { display: grid; gap: 12px; }
.day-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.day-who.nobody { color: var(--faint); font-style: italic; } .hero { display: grid; gap: 14px; padding: 18px; margin-top: 16px; }
/* A run of several days held by the same person (or left empty), replacing .hero-top { display: flex; align-items: center; gap: 16px; }
what used to be one identical row per day — see weekRuns() in oncall.js. */ .hero-label { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--muted); }
.day.range { grid-template-columns: 1fr auto; } .hero-avatar { width: 64px; height: 64px; font-size: 24px; }
.day-range { font-weight: 650; } .hero-name { font-size: 24px; font-weight: 650; letter-spacing: -0.01em; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.day.today { background: var(--accent-soft); } .hero-until { color: var(--muted); font-size: 15px; margin-top: 2px; }
.day.today:first-child { border-radius: var(--radius) var(--radius) 0 0; }
.day.today:last-child { border-radius: 0 0 var(--radius) var(--radius); } .week-card { margin-top: 16px; padding: 16px 12px 12px; }
.day.today .day-name, .day.today .day-range { color: var(--accent); } .week-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; padding: 0 4px; }
.day.past { opacity: 0.6; } .week-head h2 { margin: 0; font-size: 15px; font-weight: 650; }
/* Highlights whichever row is yours, same soft tint as .today — they already /* Bordered, so the arrows read as buttons. 40px: a touch target without
read fine layered (today's own row is almost always one of yours too). */ crowding the date between them. */
.day.mine { background: var(--accent-soft); } .week-arrow { width: 40px; min-height: 40px; border: 1px solid var(--border-strong); }
.strip { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.strip-day {
position: relative; min-width: 0;
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 0 8px; border: 1px solid transparent; border-radius: var(--radius);
}
.strip-name { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); }
.strip-date { font-size: 15px; font-weight: 650; }
.strip-avatar { width: 32px; height: 32px; font-size: 13px; }
.strip-who { max-width: 100%; padding: 0 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.strip-who.nobody { font-style: italic; color: var(--faint); }
.strip-day.past { opacity: 0.6; }
/* "Current", not "selected": a neutral tint, a bar and aria-current, so it
reads as a marker and not as a pressed button. The accent stays for things
you can act on. */
.strip-day.today { background: var(--surface-2); border-color: var(--border-strong); }
.strip-day.today::before {
content: ""; position: absolute; top: -1px; left: 12px; right: 12px; height: 3px;
border-radius: 0 0 3px 3px; background: var(--text);
}
.strip-day.today .strip-name { color: var(--text); }
.strip-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-top: 10px; padding: 10px 4px 0; border-top: 1px solid var(--border); font-size: 13px; color: var(--muted); }
.strip-legend span { display: inline-flex; align-items: center; gap: 6px; }
.strip-legend .strip-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.shift-list { list-style: none; margin: 0; padding: 0; } .shift-list { list-style: none; margin: 0; padding: 0; }
.shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; } .shift-list li { display: flex; justify-content: space-between; padding: 12px 14px; }
@@ -751,6 +861,17 @@ kbd {
.nav-team-selector { display: inline-flex; 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-link-secondary { display: flex; }
.nav-more-btn { display: none; } .nav-more-btn { display: none; }
.nav-sep { display: block; height: 1px; margin: 8px 10px; background: var(--border); }
/* The account link sits at the foot of the sidebar, as the signed-in person. */
.nav-sep-account { margin-top: auto; }
.nav-account .nav-avatar {
display: grid; place-items: center; flex: none;
width: 28px; height: 28px; margin: -4px 0 -4px -4px; border-radius: 50%;
background: var(--accent-soft); color: var(--accent);
font-size: 13px; font-weight: 750; text-transform: uppercase;
}
.nav-account .nav-avatar:not([hidden]) ~ .nav-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-account svg:has(~ .nav-avatar:not([hidden])) { display: none; }
.nav-link { .nav-link {
flex: none; flex-direction: row; justify-content: flex-start; gap: 12px; flex: none; flex-direction: row; justify-content: flex-start; gap: 12px;
min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm); min-height: 40px; padding: 0 10px; border-radius: var(--radius-sm);
@@ -758,6 +879,11 @@ kbd {
} }
.nav-link:hover { background: var(--surface-2); } .nav-link:hover { background: var(--surface-2); }
.nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); } .nav-link[aria-current="page"] { background: var(--accent-soft); color: var(--accent); }
/* A 3px bar on the active item, as well as the tint. */
.nav-link[aria-current="page"]::before {
content: ""; position: absolute; left: -12px; top: 8px; bottom: 8px; width: 3px;
border-radius: 0 3px 3px 0; background: var(--accent);
}
.nav-link svg { width: 20px; height: 20px; } .nav-link svg { width: 20px; height: 20px; }
.nav-badge { position: static; margin-left: auto; } .nav-badge { position: static; margin-left: auto; }
@@ -775,8 +901,6 @@ kbd {
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar /* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
is hidden, so the chips wrap here instead: Archived stays reachable. */ is hidden, so the chips wrap here instead: Archived stays reachable. */
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; } .pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
.pane-list .chip-sep { display: none; }
.chips-fade { display: none; }
/* With nothing selected there is no detail to show next to, so the list /* With nothing selected there is no detail to show next to, so the list
takes the whole row instead of leaving the second column as dead space takes the whole row instead of leaving the second column as dead space
around the placeholder text. Selecting an incident (.has-detail) drops around the placeholder text. Selecting an incident (.has-detail) drops
@@ -825,9 +949,12 @@ kbd {
.admin-table th { .admin-table th {
text-align: left; font-weight: 600; color: var(--muted); font-size: 12px; text-align: left; font-weight: 600; color: var(--muted); font-size: 12px;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
padding: 4px 8px 4px 0; border-bottom: 1px solid var(--border); padding: 4px 16px 4px 0; border-bottom: 1px solid var(--border);
} }
.admin-table td { padding: 8px 8px 8px 0; border-bottom: 1px solid var(--border); vertical-align: middle; } /* 16px between columns, so a right-aligned count never touches the
left-aligned text beside it ("5" against "16d ago"). */
.admin-table td { padding: 10px 16px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
.admin-table th:last-child, .admin-table td:last-child { padding-right: 0; }
.admin-table tr:last-child td { border-bottom: none; } .admin-table tr:last-child td { border-bottom: none; }
.admin-table .num { text-align: right; font-variant-numeric: tabular-nums; } .admin-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.admin-table td .btn-sm + .btn-sm { margin-left: 6px; } .admin-table td .btn-sm + .btn-sm { margin-left: 6px; }
@@ -840,11 +967,26 @@ kbd {
Six columns do not fit a phone, so the table scrolls inside its card rather Six columns do not fit a phone, so the table scrolls inside its card rather
than the page. A heartbeat under a switch with several is indented, the way than the page. A heartbeat under a switch with several is indented, the way
the escalation ladder indents its levels. */ the escalation ladder indents its levels. */
.card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; } /* A card with a header row (the Team and Admin settings cards) pads itself:
plain .card has no padding, so these used to print their text flush against
the border with the button jammed in the corner. The divider separates the
title row from the content below it. */
.card:has(> .card-head) { padding: 20px; }
@media (min-width: 900px) { .card:has(> .card-head) { padding: 24px; } }
.card-head {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding-bottom: 14px; margin-bottom: 14px; border-bottom: 1px solid var(--border);
}
.card-head h2 { margin: 0; font-size: 17px; font-weight: 650; }
.table-scroll { overflow-x: auto; margin-top: 12px; } .table-scroll { overflow-x: auto; margin-top: 12px; }
.status-table th, .status-table td { white-space: nowrap; } .status-table th, .status-table td { white-space: nowrap; }
.status-table td.wrap { white-space: normal; min-width: 12em; } .status-table td.wrap { white-space: normal; min-width: 12em; }
.status-table .source-row td { border-bottom-style: dashed; } .status-table .source-row td { border-bottom-style: dashed; }
.status-table tr.clickable { cursor: pointer; }
.status-table tr.clickable:hover td, .status-table tr.clickable:focus-visible td { background: rgba(127, 127, 127, .1); }
.switch-facts { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 12px 0; }
.switch-facts dt { opacity: .7; }
.switch-facts dd { margin: 0; }
.status-table .source-row td:first-child { padding-left: 16px; } .status-table .source-row td:first-child { padding-left: 16px; }
.source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } .source-labels { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
@@ -908,23 +1050,32 @@ kbd {
too alike down a column to read, so a day carries an initial in that too alike down a column to read, so a day carries an initial in that
person's colour and the legend underneath says whose. A shift is then a run person's colour and the legend underneath says whose. A shift is then a run
of one colour, which is the shape the question actually has. */ of one colour, which is the shape the question actually has. */
.rota-grid { display: grid; grid-template-columns: 2.4em repeat(7, 1fr); gap: 2px; padding: 10px; } .rota-grid { padding: 10px 12px 4px; }
.rota-heads, .rota-row { display: grid; grid-template-columns: 2.4em repeat(7, minmax(0, 1fr)); column-gap: 2px; }
.rota-wd { .rota-wd {
padding-bottom: 4px; text-align: center; padding-bottom: 6px; text-align: center;
color: var(--muted); font-size: 11px; font-weight: 700; color: var(--muted); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; text-transform: uppercase; letter-spacing: 0.04em;
} }
.rota-row { grid-template-rows: 26px 34px; padding: 4px 0 6px; }
.rota-row + .rota-row { border-top: 1px solid var(--border); }
/* A day is a cell that spans both of its row's lines: the number on the first,
the shift bar drawn over the second. The bar ignores the pointer, so a tap
anywhere in the cell reaches the button. */
.rota-day { .rota-day {
display: flex; flex-direction: column; align-items: center; gap: 4px; grid-row: 1 / span 2;
min-height: 52px; padding: 6px 0 8px; display: flex; justify-content: center; align-items: flex-start;
min-width: 0; padding-top: 2px;
border: 0; border-radius: var(--radius-sm); background: none; border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; color: inherit; font: inherit; color: inherit;
} }
button.rota-day { cursor: pointer; } button.rota-day { cursor: pointer; }
button.rota-day:hover { background: var(--surface-2); } button.rota-day:hover { background: var(--surface-2); }
button.rota-day:focus-visible, button.rota-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* The week number starts each row. Quiet by default, because it is a label /* The week number starts each row. Quiet by default, because it is a label
first; an owner's tap on it is the second thing it does. */ first; an owner's tap on it is the second thing it does. */
.rota-week { .rota-week {
grid-column: 1; grid-row: 1 / span 2;
display: grid; place-items: center; display: grid; place-items: center;
border: 0; border-radius: var(--radius-sm); background: none; border: 0; border-radius: var(--radius-sm); background: none;
font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums;
@@ -932,18 +1083,43 @@ button.rota-day:hover { background: var(--surface-2); }
} }
button.rota-week { cursor: pointer; } button.rota-week { cursor: pointer; }
button.rota-week:hover { background: var(--surface-2); color: var(--text); } button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rota-week.current { color: var(--accent); font-weight: 700; } .rota-week.current { color: var(--text); font-weight: 700; }
/* The sheet's row of who holds each day of the week. */ /* The sheet's row of who holds each day of the week. */
.week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; } .week-holders { display: flex; justify-content: space-between; gap: 4px; margin: 4px 0 12px; }
.week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; } .week-holder { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; }
.week-holder.past { opacity: 0.55; } .week-holder.past { opacity: 0.55; }
.rota-num { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } .rota-num {
.rota-day.today { background: var(--accent-soft); } min-width: 22px; height: 22px; padding: 0 3px; border-radius: 11px;
.rota-day.today .rota-num { color: var(--accent); font-weight: 700; } text-align: center; line-height: 22px;
.rota-day.past { opacity: 0.55; } color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums;
}
/* Today is a filled number, a marker and not the accent, which means "you can
act on this". */
.rota-day.today .rota-num { background: var(--text); color: var(--bg); font-weight: 700; }
/* The days either side of the month are real days and are drawn, but they /* The days either side of the month are real days and are drawn, but they
belong to the month you are not looking at. */ belong to the month you are not looking at. */
.rota-day.outside { opacity: 0.35; } .rota-day.outside .rota-num { opacity: 0.4; }
/* A shift: a run of consecutive days held by one person, in that person's
colour (.rc1-.rc6 below). Rounded ends are where it really starts or stops;
a flat end with a chevron carries on across the row break. */
.rota-bar {
grid-row: 2; align-self: center; z-index: 1; pointer-events: none;
display: flex; align-items: center; min-width: 0; height: 28px; padding: 0 8px;
border-radius: 6px;
font-size: 13px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rota-bar.past { opacity: 0.55; }
.rota-bar.outside { opacity: 0.4; }
.rota-bar.cont-l { margin-left: -2px; padding-left: 10px; border-top-left-radius: 0; border-bottom-left-radius: 0; }
.rota-bar.cont-r { margin-right: -2px; border-top-right-radius: 0; border-bottom-right-radius: 0; }
.rota-bar.cont-l::before { content: "‹"; margin-right: 4px; opacity: 0.7; }
/* Nobody on call. Loud while it can still be fixed, quiet once it is history. */
.rota-bar.gap {
background: repeating-linear-gradient(135deg, transparent 0 5px, var(--warn-soft) 5px 10px);
border: 1px dashed var(--warn); color: var(--warn);
}
.rota-bar.gap.past { background: none; border-color: var(--border-strong); color: var(--faint); }
.rota-chip { .rota-chip {
display: grid; place-items: center; display: grid; place-items: center;
@@ -963,14 +1139,18 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.rc5 { background: var(--teal-soft); color: var(--teal); } .rc5 { background: var(--teal-soft); color: var(--teal); }
.rc6 { background: var(--pink-soft); color: var(--pink); } .rc6 { background: var(--pink-soft); color: var(--pink); }
.rota-foot { padding: 12px 14px; border-top: 1px solid var(--border); } .rota-banner {
.rota-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; font-size: 14px; } display: flex; align-items: center; gap: 10px;
.rota-key { display: inline-flex; align-items: center; gap: 6px; } padding: 12px 14px; border-top: 1px solid var(--border); font-size: 14px;
.rota-key .rota-chip { width: 22px; height: 22px; font-size: 11px; } }
.rota-note { margin: 10px 0 0; color: var(--muted); font-size: 13px; } .rota-banner .icon { width: 20px; height: 20px; }
.rota-note:first-child { margin-top: 0; } .rota-banner.ok { color: var(--ok); }
.rota-bulk { padding: 12px 14px; border-top: 1px solid var(--border); } .rota-banner.warn { color: var(--warn); background: var(--warn-soft); }
.rota-bulk .stacked-form { margin-top: 4px; } .rota-foot {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 12px 14px; border-top: 1px solid var(--border);
}
.rota-bulk-form { padding: 0 14px 14px; }
.sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; } .sheet-pick { display: flex; align-items: center; gap: 8px; font-size: 14px; }
.ladder-level { .ladder-level {
@@ -983,6 +1163,43 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
.ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; } .ladder-editor { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; margin-top: 12px; }
/* A target that would not wake anybody says why, in place: it is the reason a /* A target that would not wake anybody says why, in place: it is the reason a
level is red, and the thing to go and fix. */ level is red, and the thing to go and fix. */
/* The escalation ladder: a numbered step per level, the wait between levels as
its own line, and what happens after the last one at the bottom. */
.ladder { list-style: none; margin: 16px 0 0; padding: 0; }
.step { display: grid; grid-template-columns: 32px minmax(0, 1fr); column-gap: 14px; }
.step-rail { display: flex; flex-direction: column; align-items: center; }
.step-node {
display: grid; place-items: center; flex: none;
width: 32px; height: 32px; border-radius: 50%;
border: 2px solid var(--border-strong); background: var(--surface);
font-size: 14px; font-weight: 750;
}
.step-node.st-ready { border-color: var(--ok); color: var(--ok); }
.step-node.st-escalating { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node.st-unreachable { border-color: var(--crit); color: var(--crit); background: var(--crit-soft); }
.step-node.end { border-style: dashed; color: var(--muted); }
.step-node.end.warn { border-color: var(--warn); color: var(--warn); background: var(--warn-soft); }
.step-node .icon { width: 16px; height: 16px; }
.step-line { flex: 1; width: 2px; min-height: 8px; background: var(--border-strong); }
.step-body { min-width: 0; padding-bottom: 4px; }
.step-top { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 32px; }
.step-pages { display: flex; flex-direction: column; gap: 2px; margin-top: 2px; font-size: 14px; }
.step-waiting { margin-top: 6px; font-size: 13px; color: var(--muted); }
.step-waiting a { font-weight: 650; }
.step-gap { display: flex; align-items: center; gap: 8px; padding: 6px 0 10px; color: var(--muted); font-size: 13px; font-weight: 600; }
.step-gap .icon { width: 14px; height: 14px; }
.callout {
display: flex; align-items: flex-start; gap: 12px; margin-top: 4px;
padding: 12px 14px; border: 1px solid var(--warn); border-radius: var(--radius);
background: var(--warn-soft);
}
.callout > .icon { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--warn); }
.callout strong { color: var(--warn); }
.callout p { margin: 2px 0 10px; font-size: 14px; }
.ladder-facts { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
.ladder-facts p { margin: 0; }
.ladder-facts p + p { margin-top: 4px; }
.target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; } .target-line { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.target-problem { color: var(--crit); font-size: 12px; font-weight: 600; } .target-problem { color: var(--crit); font-size: 12px; font-weight: 600; }
@@ -1092,3 +1309,10 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
@media (min-width: 900px) { @media (min-width: 900px) {
.stat-tiles { grid-template-columns: repeat(3, 1fr); } .stat-tiles { grid-template-columns: repeat(3, 1fr); }
} }
/* On-call, wide: the hero and the week on the left, your shifts on the right.
.view-page > * caps its children at 760px, so the grid lifts that. */
@media (min-width: 900px) {
#view-oncall > .oncall-grid { max-width: 1080px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 0 24px; align-items: start; }
#view-oncall > .oncall-title { max-width: 1080px; }
}
+14 -2
View File
@@ -14,6 +14,7 @@
<link rel="icon" href="/icon.svg" type="image/svg+xml"> <link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-touch-icon.png"> <link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="stylesheet" href="/app.css"> <link rel="stylesheet" href="/app.css">
<script src="/js/theme.js"></script>
<script type="module" src="/js/app.js"></script> <script type="module" src="/js/app.js"></script>
</head> </head>
<body> <body>
@@ -109,6 +110,9 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
<span class="nav-label">Stats</span> <span class="nav-label">Stats</span>
</a> </a>
<!-- Dividers between the groups (Queue, On-call, Alerts, Stats | Team,
Admin | Account). Desktop sidebar only; the phone bar has no room. -->
<span class="nav-sep" aria-hidden="true"></span>
<a class="nav-link" href="/team" data-section="team" aria-label="Team"> <a class="nav-link" href="/team" data-section="team" aria-label="Team">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="8" r="3"/><circle cx="17" cy="9" r="2.5"/><path d="M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4"/></svg>
<span class="nav-label">Team</span> <span class="nav-label">Team</span>
@@ -120,9 +124,14 @@
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z"/></svg>
<span class="nav-label">Admin</span> <span class="nav-label">Admin</span>
</a> </a>
<a class="nav-link nav-link-secondary" href="/more" data-section="more" aria-label="Account"> <span class="nav-sep nav-sep-account" aria-hidden="true"></span>
<!-- At the foot of the desktop sidebar, as the signed-in person: app.js
fills the avatar and the name from /api/me, and until then it reads
"Account". -->
<a class="nav-link nav-link-secondary nav-account" href="/more" data-section="more" aria-label="Account">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/></svg>
<span class="nav-label">Account</span> <span class="nav-avatar" id="nav-avatar" aria-hidden="true" hidden></span>
<span class="nav-label" id="nav-account-label">Account</span>
</a> </a>
<!-- Phone-width only (see .nav-more-btn in app.css): opens the same <!-- Phone-width only (see .nav-more-btn in app.css): opens the same
sheet the old hamburger button did, for the sections the bottom sheet the old hamburger button did, for the sections the bottom
@@ -145,6 +154,9 @@
<section id="view-queue" class="view view-queue" data-view="queue"> <section id="view-queue" class="view view-queue" data-view="queue">
<div class="pane pane-list"> <div class="pane pane-list">
<div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div> <div class="chips" id="queue-filters" role="tablist" aria-label="Filter"></div>
<!-- The cluster filter. Hidden until the queue has seen two or more
clusters; queue.js fills it in. -->
<div class="queue-origin" id="queue-origin" hidden></div>
<div id="queue-list" class="list"></div> <div id="queue-list" class="list"></div>
</div> </div>
<div class="pane pane-detail" id="detail" aria-live="polite"></div> <div class="pane pane-detail" id="detail" aria-live="polite"></div>
+20
View File
@@ -28,6 +28,9 @@ function render() {
...passwordSection(user, hasPassword), ...passwordSection(user, hasPassword),
h('div', { class: 'page-head' }, h('h2', { text: 'Appearance' })),
themePicker(),
h('div', { class: 'only-desktop' }, h('div', { class: 'only-desktop' },
h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })), h('div', { class: 'page-head' }, h('h2', { text: 'Keyboard' })),
h('div', { class: 'card' }, shortcuts())), h('div', { class: 'card' }, shortcuts())),
@@ -39,6 +42,23 @@ function render() {
); );
} }
// System / Light / Dark. Per browser, not per account: it lives in
// localStorage (see js/theme.js), the same place the selected team does.
const THEMES = [['system', 'System'], ['light', 'Light'], ['dark', 'Dark']];
function themePicker() {
const theme = window.terdutTheme;
const buttons = THEMES.map(([value, text]) => h('button', {
class: 'chip', type: 'button', role: 'radio', text,
onclick: () => { theme.set(value); sync(); },
}));
const sync = () => buttons.forEach((b, i) => b.setAttribute('aria-checked', String(THEMES[i][0] === theme.get())));
sync();
return h('div', { class: 'card card-pad' },
h('div', { class: 'chips theme-picker', role: 'radiogroup', 'aria-label': 'Theme' }, ...buttons),
h('p', { class: 'muted small', text: 'System follows your device. This applies to this browser only.' }));
}
// Where this user's pages go. The onboarding checklist's first step sends // Where this user's pages go. The onboarding checklist's first step sends
// people here for it, and until now there was nothing here to send them to: // people here for it, and until now there was nothing here to send them to:
// the topic could only be set with curl or by an administrator. // the topic could only be set with curl or by an administrator.
+9 -3
View File
@@ -13,7 +13,7 @@
// gate. // gate.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js'; import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, myID } from './state.js'; import { state, myID } from './state.js';
const view = () => document.getElementById('view-admin'); const view = () => document.getElementById('view-admin');
@@ -102,13 +102,19 @@ function section() {
// buttons, because these are four URLs: app.js intercepts the click, the // buttons, because these are four URLs: app.js intercepts the click, the
// browser's Back walks them, and a reload lands where you were. // browser's Back walks them, and a reload lands where you were.
function subnav() { function subnav() {
return h('nav', { class: 'subnav', 'aria-label': 'Administration' }, const nav = h('nav', { class: 'subnav', 'aria-label': 'Administration' },
TABS.map((t) => h('a', { TABS.map((t) => h('a', {
class: 'subnav-link', class: 'subnav-link',
href: t.path, href: t.path,
text: t.label, text: t.label,
'aria-current': t.tab === tab ? 'page' : null, 'aria-current': t.tab === tab ? 'page' : null,
}))); })));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
} }
// --- overview -------------------------------------------------------------- // --- overview --------------------------------------------------------------
@@ -165,7 +171,7 @@ function teamsCard() {
function newTeamForm() { function newTeamForm() {
const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true }); const name = h('input', { name: 'name', type: 'text', placeholder: 'New team name', required: true });
const form = h('form', { class: 'inline-form' }, name, const form = h('form', { class: 'inline-form' }, name,
h('button', { class: 'btn', type: 'submit', text: 'Create' })); h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create' }));
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
+8 -5
View File
@@ -152,16 +152,19 @@ function identityCard() {
// membership looks the way it does, but setting it is the team's own // membership looks the way it does, but setting it is the team's own
// owner's call, from the Team tab. // owner's call, from the Team tab.
...(state.auth?.oidc?.enabled ? [ ...(state.auth?.oidc?.enabled ? [
fact('OIDC member group', t.oidc_member_group || '—'), fact('OIDC member group', t.oidc_member_group || notConfigured()),
fact('OIDC owner group', t.oidc_owner_group || '—'), fact('OIDC owner group', t.oidc_owner_group || notConfigured()),
] : []), ] : []),
), ),
form, err, ok, form, err, ok,
); );
} }
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
// value is text or a node.
function fact(label, value) { function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })]; return [h('dt', { text: label }), h('dd', {}, value)];
} }
// --- members --------------------------------------------------------------- // --- members ---------------------------------------------------------------
@@ -207,7 +210,7 @@ function membersCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role, const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' })); h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
act(() => api.addTeamMember(teamID, Number(pick.value), role.value)); act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
@@ -238,7 +241,7 @@ function invitesCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, role, const form = h('form', { class: 'inline-form' }, role,
h('button', { class: 'btn', type: 'submit', text: 'Create invite' })); h('button', { class: 'btn btn-primary', type: 'submit', text: 'Create invite' }));
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy) return;
+1 -1
View File
@@ -197,7 +197,7 @@ function teamsCard() {
h('option', { value: 'member', text: 'member' }), h('option', { value: 'member', text: 'member' }),
h('option', { value: 'owner', text: 'owner' })); h('option', { value: 'owner', text: 'owner' }));
const form = h('form', { class: 'inline-form' }, pick, role, const form = h('form', { class: 'inline-form' }, pick, role,
h('button', { class: 'btn', type: 'submit', text: 'Add' })); h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add' }));
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
act(() => api.addTeamMember(Number(pick.value), userID, role.value)); act(() => api.addTeamMember(Number(pick.value), userID, role.value));
+6 -4
View File
@@ -2,8 +2,8 @@
// incident it belongs to, which is where anything can be done about it. // incident it belongs to, which is where anything can be done about it.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js'; import { h, clear, badge, severityBadge, originChip, emptyState, spinner } from './ui.js';
import { age, severityClass, labelSummary } from './format.js'; import { age, labelSummary, originOf, ORIGIN_LABEL } from './format.js';
const FILTERS = [ const FILTERS = [
{ id: 'firing', label: 'Firing', query: { status: 'firing' } }, { id: 'firing', label: 'Firing', query: { status: 'firing' } },
@@ -77,7 +77,8 @@ function row(a) {
const summary = (a.annotations && a.annotations.summary) || ''; const summary = (a.annotations && a.annotations.summary) || '';
const sev = a.labels && a.labels.severity; const sev = a.labels && a.labels.severity;
const labels = labelSummary(Object.fromEntries( const labels = labelSummary(Object.fromEntries(
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity'))); Object.entries(a.labels || {}).filter(([k]) => k !== 'severity' && k !== ORIGIN_LABEL)));
const origin = originOf(a.labels);
const linked = a.incident_id != null; const linked = a.incident_id != null;
return h(linked ? 'a' : 'div', { return h(linked ? 'a' : 'div', {
class: `row st-${a.status} ${linked ? '' : 'no-link'}`, class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
@@ -86,8 +87,9 @@ function row(a) {
h('div', { class: 'row-title', text: a.name }), h('div', { class: 'row-title', text: a.name }),
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }), h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
h('div', { class: 'row-meta' }, h('div', { class: 'row-meta' },
origin && originChip(origin),
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`), badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
sev && badge(sev, `plain ${severityClass(sev)}`), sev && severityBadge(sev),
summary && h('span', { text: summary }), summary && h('span', { text: summary }),
labels && h('span', { class: 'labels', text: labels }), labels && h('span', { class: 'labels', text: labels }),
), ),
+3
View File
@@ -83,6 +83,7 @@ export const setNotifyTarget = (id, ntfyTopic) =>
// incidents // incidents
export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts }); export const incidents = (query, opts) => call('GET', '/incidents', { query, ...opts });
export const clusters = (query) => call('GET', '/incidents/clusters', { query });
export const incident = (id) => call('GET', `/incidents/${id}`); export const incident = (id) => call('GET', `/incidents/${id}`);
export const timeline = (id) => call('GET', `/incidents/${id}/timeline`); export const timeline = (id) => call('GET', `/incidents/${id}/timeline`);
@@ -150,6 +151,8 @@ export const deleteIntegration = (id, integrationID) =>
export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`); export const deadmanSwitches = (id) => call('GET', `/teams/${id}/deadman/switches`);
export const createDeadmanSwitch = (id, body) => export const createDeadmanSwitch = (id, body) =>
call('POST', `/teams/${id}/deadman/switches`, { body }); call('POST', `/teams/${id}/deadman/switches`, { body });
export const updateDeadmanSwitch = (id, switchID, body) =>
call('PUT', `/teams/${id}/deadman/switches/${switchID}`, { body });
export const deleteDeadmanSwitch = (id, switchID) => export const deleteDeadmanSwitch = (id, switchID) =>
call('DELETE', `/teams/${id}/deadman/switches/${switchID}`); call('DELETE', `/teams/${id}/deadman/switches/${switchID}`);
+15 -1
View File
@@ -16,6 +16,7 @@ import * as admin from './admin.js';
import * as adminuser from './adminuser.js'; import * as adminuser from './adminuser.js';
import * as adminteam from './adminteam.js'; import * as adminteam from './adminteam.js';
import * as device from './device.js'; import * as device from './device.js';
import { initial } from './format.js';
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -202,7 +203,8 @@ function updateBadges() {
const pill = $('open-pill'); const pill = $('open-pill');
pill.hidden = false; pill.hidden = false;
pill.textContent = open ? `${open} open` : 'All clear'; pill.replaceChildren(...(open ? [`${open} open`] : [ui.icon('checkCircle'), 'All clear']));
pill.classList.toggle('all-clear', open === 0);
pill.classList.toggle('has-triggered', triggered > 0); pill.classList.toggle('has-triggered', triggered > 0);
pill.classList.toggle('all-acked', open > 0 && triggered === 0); pill.classList.toggle('all-acked', open > 0 && triggered === 0);
@@ -466,7 +468,19 @@ export async function signOut() {
showLogin(); showLogin();
} }
// The sidebar's Account link shows who is signed in: an avatar and the name,
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
// screen reader still hears "Account".
function renderAccountNav() {
const name = state.me?.user?.username;
if (!name) return;
$('nav-avatar').textContent = initial(name);
$('nav-avatar').hidden = false;
$('nav-account-label').textContent = name;
}
function showApp() { function showApp() {
renderAccountNav();
ssoErrorCode = null; ssoErrorCode = null;
$('boot').hidden = true; $('boot').hidden = true;
$('login').hidden = true; $('login').hidden = true;
+33
View File
@@ -117,3 +117,36 @@ export function labelSummary(labels, skip = 'alertname') {
export function initial(name) { export function initial(name) {
return (name || '?').trim().charAt(0) || '?'; return (name || '?').trim().charAt(0) || '?';
} }
// Where an incident or alert came from, for a team with several Kubernetes
// clusters (or other origins) behind it: the value of one label, `cluster` by
// convention. It comes from Prometheus's externalLabels, so it is on every
// alert; an incident carries it only when it is in Alertmanager's group_by,
// which is also what keeps two clusters' identical alerts from merging into one
// incident (see the README, "Several clusters, one team").
export const ORIGIN_LABEL = 'cluster';
export function originOf(labels) {
const v = labels && labels[ORIGIN_LABEL];
return v ? String(v) : null;
}
// A stable colour for an origin, from the six-colour rcN palette app.css has,
// so "prod-eu" is the same colour on every row without anything being stored.
export function originClass(value) {
let n = 0;
for (const ch of value) n = (n * 31 + ch.charCodeAt(0)) >>> 0;
return `rc${(n % 6) + 1}`;
}
// The title without its `cluster=...` part, for a row that shows the origin as
// a chip: the server puts every grouping label in the title, so the cluster
// would otherwise appear twice. Anything that does not look like the server's
// "name (k=v, k=v)" shape is returned unchanged.
export function titleWithoutOrigin(title, value) {
if (!value) return title;
const m = title.match(/^(.*?) \((.*)\)$/s);
if (!m) return title;
const rest = m[2].split(', ').filter((p) => p !== `${ORIGIN_LABEL}=${value}`);
return rest.length ? `${m[1]} (${rest.join(', ')})` : m[1];
}
+48 -25
View File
@@ -4,10 +4,10 @@
import * as api from './api.js'; import * as api from './api.js';
import * as poll from './poll.js'; import * as poll from './poll.js';
import { import {
h, clear, icon, badge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
} from './ui.js'; } from './ui.js';
import { import {
ago, when, until, duration, isFuture, severityClass, STATUS_LABEL, ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
} from './format.js'; } from './format.js';
import { myID, users } from './state.js'; import { myID, users } from './state.js';
import { back } from './app.js'; import { back } from './app.js';
@@ -86,7 +86,6 @@ function render() {
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
h('h1', { class: 'detail-title', text: inc.title }), h('h1', { class: 'detail-title', text: inc.title }),
h('div', { class: 'detail-badges' }, statusBadges()), h('div', { class: 'detail-badges' }, statusBadges()),
quickActions(),
facts(), facts(),
groupLabels(), groupLabels(),
alertsSection(), alertsSection(),
@@ -100,7 +99,9 @@ function render() {
function statusBadges() { function statusBadges() {
const out = []; const out = [];
if (inc.severity) out.push(badge(inc.severity, `plain ${severityClass(inc.severity)}`)); const origin = originOf(inc.group_labels);
if (origin) out.push(originChip(origin));
if (inc.severity) out.push(severityBadge(inc.severity));
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`)); out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) { if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed')); out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
@@ -145,7 +146,7 @@ function facts() {
: ackActorLabel() || 'Unassigned'; : ackActorLabel() || 'Unassigned';
rows.push(h('dt', { text: 'At a glance' }), h('dd', { class: 'fact-summary' }, 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))), h('span', { class: 'fact-chip' }, icon('clock', 'icon fact-icon'), duration(elapsedTo - Date.parse(inc.triggered_at))),
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), inc.severity && severityBadge(inc.severity),
h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible), h('span', { class: 'fact-chip' }, icon('user', 'icon fact-icon'), responsible),
)); ));
@@ -225,6 +226,15 @@ function actorLabel(ev, named = false) {
return null; return null;
} }
// assignerLabel is actorLabel for an 'assigned' event, whose user_id is the
// assignee: the person who made the assignment is in the actor_* fields
// (null for assignments from before they were recorded).
function assignerLabel(ev, named = false) {
if (ev.actor_user_id != null) return named ? (ev.actor_username || 'someone') : who(ev.actor_user_id, ev.actor_username);
if (ev.actor_service_account_id != null) return ev.actor_service_account_name || 'a service account';
return null;
}
// named spells users out instead of "you", for text that leaves this page. // named spells users out instead of "you", for text that leaves this page.
function eventText(ev, named = false) { function eventText(ev, named = false) {
const person = actorLabel(ev, named); const person = actorLabel(ev, named);
@@ -239,7 +249,12 @@ function eventText(ev, named = false) {
case 'alert_resolved': return [`Alert resolved: ${alertName()}`]; case 'alert_resolved': return [`Alert resolved: ${alertName()}`];
case 'acknowledged': return [strong(person), ' acknowledged']; case 'acknowledged': return [strong(person), ' acknowledged'];
case 'unacknowledged': return [strong(person), ' cleared the acknowledgement']; case 'unacknowledged': return [strong(person), ' cleared the acknowledgement'];
case 'assigned': return ['Assigned to ', strong(person)]; case 'assigned': {
const by = assignerLabel(ev, named);
return by ? ['Assigned to ', strong(person), ' by ', strong(by)] : ['Assigned to ', strong(person)];
}
case 'archived': return [strong(person), ' archived the incident'];
case 'unarchived': return [strong(person), ' unarchived the incident'];
case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`]; case 'snoozed': return [strong(person), ` snoozed until ${ev.detail ? when(ev.detail) : '…'}`];
case 'unsnoozed': return [strong(person), ' ended the snooze']; case 'unsnoozed': return [strong(person), ' ended the snooze'];
case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing']; case 'resolved': return person ? [strong(person), ' resolved the incident'] : ['Resolved: every alert stopped firing'];
@@ -325,10 +340,30 @@ function timelineSection() {
const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note'; const isNote = (ev) => ev.type === 'note' || ev.type === 'resolution_note';
// One icon per kind of event, so the rail says what happened before the text
// does. Anything not listed keeps a plain dot.
const EVENT_ICON = {
triggered: 'bell', notified: 'bell', escalated: 'arrowUp',
acknowledged: 'check', unacknowledged: 'undo', assigned: 'user',
snoozed: 'clock', unsnoozed: 'clock', resolved: 'checkCircle',
alert_added: 'plus', alert_resolved: 'check',
archived: 'archive', unarchived: 'undo',
note: 'note', resolution_note: 'note',
notify_failed: 'alertTriangle', deadman_silent: 'alertTriangle',
};
// An escalation that ran out of levels, or of anywhere to send the page, is
// the one event on the rail that means "nobody is being told": it gets the
// warning treatment instead of reading like any other step.
const isProblem = (ev) => ev.type === 'notify_failed' || ev.type === 'deadman_silent'
|| (ev.type === 'escalated' && /exhausted/i.test(ev.detail || ''));
function timelineItem(ev) { function timelineItem(ev) {
const mine = isNote(ev) && ev.user_id === myID(); const mine = isNote(ev) && ev.user_id === myID();
return h('li', { class: `tl-item tl-${ev.type}` }, const problem = isProblem(ev);
h('span', { class: 'tl-dot' }), const iconName = problem ? 'alertTriangle' : EVENT_ICON[ev.type];
return h('li', { class: `tl-item tl-${ev.type}${problem ? ' tl-problem' : ''}` },
h('span', { class: 'tl-dot' }, iconName && icon(iconName, 'icon tl-icon')),
h('div', { class: 'tl-body' }, h('div', { class: 'tl-body' },
h('div', { class: 'tl-text' }, eventText(ev)), h('div', { class: 'tl-text' }, eventText(ev)),
h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }), h('div', { class: 'tl-time', title: ev.created_at, text: `${when(ev.created_at)} · ${ago(ev.created_at)}` }),
@@ -438,9 +473,8 @@ async function copyIncident() {
const isOpen = () => inc.status !== 'resolved'; const isOpen = () => inc.status !== 'resolved';
const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until); const isSnoozed = () => isOpen() && isFuture(inc.snoozed_until);
// primaryAction and secondaryAction are factories, not shared nodes — a // primaryAction and secondaryAction are factories, not shared nodes: a button
// button can only live in one place, and quickActions() below needs its own // can only live in one place, and the actionbar is rebuilt on every render.
// copy of the primary one rather than the actionbar's.
function primaryAction() { function primaryAction() {
if (inc.status === 'triggered') { if (inc.status === 'triggered') {
return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge'); return h('button', { class: 'btn btn-primary', type: 'button', onclick: acknowledge }, icon('check'), 'Acknowledge');
@@ -459,17 +493,7 @@ function secondaryAction() {
? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze') ? h('button', { class: 'btn', type: 'button', onclick: unsnooze }, icon('bell'), 'Unsnooze')
: h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze'); : h('button', { class: 'btn', type: 'button', onclick: snooze }, icon('clock'), 'Snooze');
} }
return h('button', { class: 'btn', type: 'button', onclick: addNote }, icon('note'), 'Note'); return h('button', { class: 'btn', type: 'button', onclick: copyIncident }, icon('copy'), 'Copy');
}
// A copy of the primary action (Acknowledge/Resolve/…) up where it's seen
// right away, next to the status it responds to. The sticky actionbar below
// keeps carrying every action, primary included, for whenever the page has
// been scrolled past it.
function quickActions() {
const div = h('div', { class: 'detail-quick-actions' }, primaryAction());
if (busy) for (const b of div.querySelectorAll('button')) b.disabled = true;
return div;
} }
// Desktop has room to show what a phone folds into the More sheet below — see // Desktop has room to show what a phone folds into the More sheet below — see
@@ -480,7 +504,6 @@ function extraActions() {
if (!isOpen()) return []; if (!isOpen()) return [];
const out = [ const out = [
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'), h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: assign }, icon('user'), 'Assign…'),
h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: addNote }, icon('note'), 'Add note…'),
]; ];
out.push(inc.status === 'acknowledged' out.push(inc.status === 'acknowledged'
? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack') ? h('button', { class: 'btn btn-sm action-extra', type: 'button', onclick: unacknowledge }, icon('undo'), 'Clear ack')
@@ -676,9 +699,9 @@ async function moreMenu() {
items.push(h('li', { class: 'menu-sep', role: 'separator' })); items.push(h('li', { class: 'menu-sep', role: 'separator' }));
items.push(item('checkCircle', 'Resolve…', resolve, 'danger')); items.push(item('checkCircle', 'Resolve…', resolve, 'danger'));
} else { } else {
// The bar already carries the primary action (Archive/Unarchive) and
// Copy, so the sheet adds only what it lacks.
items.push(item('note', 'Add note…', addNote)); items.push(item('note', 'Add note…', addNote));
items.push(item('copy', 'Copy incident', copyIncident));
items.push(inc.archived_at ? item('undo', 'Unarchive', unarchive) : item('archive', 'Archive', archive));
} }
const fn = await openSheet(() => [ const fn = await openSheet(() => [
+72 -66
View File
@@ -1,5 +1,6 @@
// On-call: who is on duty now, the week around it, and your own next shifts. // On-call: who is on duty now, the week around it, and your own next shifts.
// Read-only for now; the TUI edits the schedule. // Read-only for now; the TUI edits the schedule, so there is no add or swap
// button here.
// //
// One team's rota at a time — the viewer's first team, since a viewer in one // One team's rota at a time — the viewer's first team, since a viewer in one
// team has nothing to choose between. "On call now" is the exception and shows // team has nothing to choose between. "On call now" is the exception and shows
@@ -59,11 +60,17 @@ function render() {
clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner()); clear(view(), error ? h('div', { class: 'load-error', text: error }) : spinner());
return; return;
} }
const team = currentTeam();
clear(view(), clear(view(),
// The top bar carries the title on a phone; the desktop has none.
h('div', { class: 'only-desktop oncall-title' },
h('h1', { text: 'On-call' }),
team && h('p', { class: 'muted', text: `${team.name} · who is on call, the week ahead and your shifts` })),
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }), error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
nowCard(), h('div', { class: 'oncall-grid' },
weekCard(), h('div', { class: 'oncall-main' }, nowCard(), weekCard()),
myShifts(), h('div', { class: 'oncall-side' }, myShifts()),
),
); );
} }
@@ -78,75 +85,73 @@ function nowCard() {
const entries = data.now || []; const entries = data.now || [];
const showTeam = entries.length > 1; const showTeam = entries.length > 1;
if (entries.length === 0) { if (entries.length === 0) {
return h('div', { class: 'card now-card' }, return h('div', { class: 'card hero' },
h('div', { class: 'avatar none', text: '–' }), h('div', { class: 'hero-label', text: 'On call now' }),
h('div', {}, h('div', { class: 'hero-top' },
h('div', { class: 'now-label', text: 'On call now' }), h('div', { class: 'avatar hero-avatar none', text: '–' }),
h('div', { class: 'now-name', text: 'Nobody' }), h('div', { class: 'hero-name', text: 'Nobody' })));
),
);
} }
return h('div', {}, ...entries.map((n) => return h('div', { class: 'hero-list' }, ...entries.map((n) => {
h('div', { class: 'card now-card' }, const until = shiftEnd(n);
h('div', { class: 'avatar', text: initial(n.username) }), return h('div', { class: 'card hero' },
h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
h('div', { class: 'hero-top' },
h('div', { class: 'avatar hero-avatar', text: initial(n.username) }),
h('div', {}, h('div', {},
h('div', { h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
class: 'now-label', until && h('div', { class: 'hero-until', text: until }))));
text: showTeam ? `On call now · ${n.team_name}` : 'On call now', }));
}),
h('div', { class: 'now-name' }, n.username, you(n.user_id)),
),
)));
} }
// Groups the week's 7 days into runs held by the same person (or the same // "until Mon 12 Oct · ends in 3d 20h", for the current team only: the shift's
// empty slot) — the week's own version of the consecutive-day grouping // end is read off the rota (the run of consecutive days from today held by the
// myShifts does for a single person's own dates, below. Seven identical rows // same person), and that is only loaded for one team. The same midnight
// for one person all week collapses to the one bar this way. // boundary the "Current shift" row below uses.
function weekRuns(byDate) { function shiftEnd(n) {
const runs = []; const team = currentTeam();
for (let i = 0; i < 7; i++) { if (!team || n.team_id !== team.id) return null;
const date = isoDate(addDays(weekStart, i)); const mine = new Map(data.upcoming.map((e) => [e.date, e.user_id]));
const e = byDate.get(date) || null; let day = new Date();
const uid = e ? e.user_id : null; if (mine.get(isoDate(day)) !== n.user_id) return null;
const last = runs[runs.length - 1]; while (mine.get(isoDate(addDays(day, 1))) === n.user_id) day = addDays(day, 1);
if (last && last.uid === uid) last.to = date; // Still holding the last day loaded: the shift may run on past it.
else runs.push({ uid, entry: e, from: date, to: date }); if (isoDate(day) >= data.upcoming.reduce((m, e) => (e.date > m ? e.date : m), '')) return null;
} const end = addDays(parse(isoDate(day)), 1);
return runs; return `until ${dayName.format(end)} ${dayDate.format(end)} · ends in ${duration(end - Date.now())}`;
} }
// A stable colour per person from the six-colour rcN palette. The week page
// has no member list to take an index from (team.js does), so the id decides.
const personClass = (userID) => `rc${(userID % 6) + 1}`;
// The week as seven cells, Monday to Sunday, each showing who holds that day.
// A hand-over in the middle of the week is visible without reading anything.
function weekCard() { function weekCard() {
const byDate = new Map(data.week.map((e) => [e.date, e])); const byDate = new Map(data.week.map((e) => [e.date, e]));
const today = isoDate(new Date()); const today = isoDate(new Date());
const mine = myID(); const mine = myID();
const days = weekRuns(byDate).map((r) => { const seen = new Map();
const single = r.from === r.to; const cells = [];
const cls = [ for (let i = 0; i < 7; i++) {
'day', const d = addDays(weekStart, i);
single ? '' : 'range', const date = isoDate(d);
r.from <= today && today <= r.to ? 'today' : '', const e = byDate.get(date) || null;
r.to < today ? 'past' : '', if (e) seen.set(e.user_id, e.username);
r.uid === mine ? 'mine' : '', cells.push(h('li', {
].filter(Boolean).join(' '); class: ['strip-day', date === today && 'today', date < today && 'past'].filter(Boolean).join(' '),
const label = single 'aria-current': date === today ? 'date' : null,
? [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: 'strip-name', text: dayName.format(d) }),
: [h('span', { h('span', { class: 'strip-date', text: String(d.getDate()) }),
class: 'day-range', h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }),
text: `${dayName.format(parse(r.from))} ${dayDate.format(parse(r.from))} – ${dayName.format(parse(r.to))} ${dayDate.format(parse(r.to))}`, h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' })));
})]; }
return h('li', { class: cls },
...label,
h('span', { class: `day-who ${r.entry ? '' : 'nobody'}` }, r.entry ? r.entry.username : 'nobody', r.entry && you(r.entry.user_id)),
);
});
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date())); const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
return [ return h('div', { class: 'card week-card' },
h('div', { class: 'page-head' }, h('div', { class: 'week-head' },
h('h2', { text: thisWeek ? 'This week' : 'Week' }), h('h2', { text: thisWeek ? 'This week' : 'Week' }),
h('div', { class: 'week-nav' }, h('div', { class: 'week-nav' },
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) }, h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Previous week', onclick: () => shiftWeek(-1) },
icon('chevronLeft')), icon('chevronLeft')),
h('button', { h('button', {
class: 'btn btn-ghost week-label', class: 'btn btn-ghost week-label',
@@ -155,12 +160,13 @@ function weekCard() {
onclick: () => { weekStart = mondayOf(new Date()); refresh(); }, onclick: () => { weekStart = mondayOf(new Date()); refresh(); },
text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`, text: `${dayDate.format(weekStart)} – ${dayDate.format(addDays(weekStart, 6))}`,
}, h('small', { text: ` Week ${isoWeek(weekStart)}` })), }, h('small', { text: ` Week ${isoWeek(weekStart)}` })),
h('button', { class: 'btn btn-ghost btn-icon', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) }, h('button', { class: 'btn btn-icon week-arrow', type: 'button', 'aria-label': 'Next week', onclick: () => shiftWeek(1) },
icon('chevronRight')), icon('chevronRight')))),
), h('ul', { class: 'strip' }, cells),
), seen.size > 0 && h('div', { class: 'strip-legend' },
h('ul', { class: 'card days' }, days), [...seen].map(([id, name]) => h('span', {},
]; h('i', { class: `strip-dot ${personClass(id)}` }),
id === mine ? `${name} (you)` : name))));
} }
// myShifts groups your upcoming dates into runs of consecutive days, then // myShifts groups your upcoming dates into runs of consecutive days, then
+124 -46
View File
@@ -1,9 +1,9 @@
// The incident queue: filter chips and a list of incident rows. // The incident queue: filter chips and a list of incident rows.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, badge, emptyState, spinner } from './ui.js'; import { h, clear, icon, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
import { ago, until, isFuture, severityClass, labelSummary, teamColorClass } from './format.js'; import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
import { state, myID, setSelectedTeam, onTeamChange } from './state.js'; import { state, myID, onTeamChange } from './state.js';
import * as onboarding from './onboarding.js'; import * as onboarding from './onboarding.js';
import { navigate } from './app.js'; import { navigate } from './app.js';
@@ -30,8 +30,10 @@ onboarding.onRerender(() => renderList());
// The queue used to keep its own team filter (a per-tab sessionStorage value, // The queue used to keep its own team filter (a per-tab sessionStorage value,
// out of step with team.js's own picker); both now defer to the global // out of step with team.js's own picker); both now defer to the global
// selector's shared state, so re-render whenever it changes. // selector's shared state, so re-render whenever it changes.
onTeamChange(() => { onTeamChange(async () => {
renderChips(); renderChips();
await loadClusters(true);
renderOrigin();
refresh({ fresh: true }); refresh({ fresh: true });
}); });
@@ -52,6 +54,63 @@ function loadFilter() {
return 'open'; return 'open';
} }
// Which cluster the queue is narrowed to, or null for all of them. Kept in
// localStorage like the selected team: it is a standing choice, not a
// per-tab one.
const ORIGIN_KEY = 'terdut.queue.cluster';
let origin = loadOrigin();
let clusters = []; // the clusters the server has seen, for the dropdown
let clustersAt = 0;
function loadOrigin() {
try {
return localStorage.getItem(ORIGIN_KEY) || null;
} catch {
return null; // storage unavailable
}
}
function saveOrigin() {
try {
if (origin) localStorage.setItem(ORIGIN_KEY, origin);
else localStorage.removeItem(ORIGIN_KEY);
} catch {
/* storage unavailable */
}
}
// The clusters to offer, from the server because the list on screen is only
// the fifty most recent. Asked again at most every five minutes; a cluster
// first met in an incident on screen is added at once (see noteClusters). A
// remembered choice the server no longer knows would filter to nothing with no
// hint why, so it is dropped.
async function loadClusters(force = false) {
if (!force && Date.now() - clustersAt < 5 * 60 * 1000) return;
try {
const query = state.selectedTeamID != null ? { team_id: state.selectedTeamID } : undefined;
clusters = await api.clusters(query);
clustersAt = Date.now();
if (origin && !clusters.includes(origin)) {
origin = null;
saveOrigin();
}
} catch {
/* the filter is a convenience: without the list it stays hidden */
}
}
function noteClusters(list) {
let added = false;
for (const inc of list || []) {
const c = originOf(inc.group_labels);
if (c && !clusters.includes(c)) {
clusters = [...clusters, c].sort();
added = true;
}
}
return added;
}
function saveFilter() { function saveFilter() {
try { try {
sessionStorage.setItem('terdut.queue.filter', filter); sessionStorage.setItem('terdut.queue.filter', filter);
@@ -64,6 +123,7 @@ export function show(incidentID) {
selected = incidentID; selected = incidentID;
if (!built) { if (!built) {
renderChips(); renderChips();
renderOrigin();
built = true; built = true;
} }
renderList(); renderList();
@@ -73,15 +133,22 @@ export async function refresh({ fresh = false } = {}) {
const f = FILTERS.find((x) => x.id === filter); const f = FILTERS.find((x) => x.id === filter);
const requested = filter; const requested = filter;
try { try {
// First, so a remembered cluster the server no longer knows is dropped
// before it is sent as a filter.
await loadClusters();
// The open list is already fetched for the badges; no need to ask twice. // The open list is already fetched for the badges; no need to ask twice.
// The cached open queue covers every team, so it can only be reused when // The cached open queue covers every team, so it can only be reused when
// no team filter is applied. // no team filter is applied.
const query = state.selectedTeamID != null ? { ...f.query, team_id: state.selectedTeamID } : f.query; const query = { ...f.query };
const cached = filter === 'open' && !fresh && state.selectedTeamID == null; if (state.selectedTeamID != null) query.team_id = state.selectedTeamID;
if (origin) query.cluster = origin;
const cached = filter === 'open' && !fresh && state.selectedTeamID == null && !origin;
const result = cached ? state.open : await api.incidents(query); const result = cached ? state.open : await api.incidents(query);
await onboarding.load(); await onboarding.load();
if (requested !== filter) return; if (requested !== filter) return;
items = result; items = result;
noteClusters(state.open);
noteClusters(items);
error = null; error = null;
} catch (err) { } catch (err) {
if (requested !== filter) return; if (requested !== filter) return;
@@ -91,6 +158,7 @@ export async function refresh({ fresh = false } = {}) {
// whichever caller updated it before calling here — app.js's poll, or the // whichever caller updated it before calling here — app.js's poll, or the
// `cached` branch above. // `cached` branch above.
renderChips(); renderChips();
renderOrigin();
renderList(); renderList();
} }
@@ -109,15 +177,19 @@ function setFilter(id) {
// for the badges — Snoozed/Resolved/Archived would need a request of their // for the badges — Snoozed/Resolved/Archived would need a request of their
// own, so those chips stay count-less for now. // own, so those chips stay count-less for now.
function chipCount(id) { function chipCount(id) {
const open = state.selectedTeamID == null const open = state.open.filter((i) =>
? state.open (state.selectedTeamID == null || i.team_id === state.selectedTeamID)
: state.open.filter((i) => i.team_id === state.selectedTeamID); && (!origin || originOf(i.group_labels) === origin));
if (id === 'open') return open.length; if (id === 'open') return open.length;
if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length; if (id === 'triggered') return open.filter((i) => i.status === 'triggered').length;
if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length; if (id === 'acknowledged') return open.filter((i) => i.status === 'acknowledged').length;
return null; return null;
} }
// Set the first time the chips are drawn: the strip element is the same one
// for the life of the page, so one scroll listener and one observer will do.
let chipsFade;
function renderChips() { function renderChips() {
const el = document.getElementById('queue-filters'); const el = document.getElementById('queue-filters');
const chips = FILTERS.map((f) => { const chips = FILTERS.map((f) => {
@@ -129,42 +201,39 @@ function renderChips() {
'aria-selected': String(f.id === filter), 'aria-selected': String(f.id === filter),
onclick: () => setFilter(f.id), onclick: () => setFilter(f.id),
text: f.label, text: f.label,
}, count != null && h('span', { class: 'count', text: String(count) })); }, count != null && h('span', { class: count === 0 ? 'count zero' : 'count', text: String(count) }));
}); });
// Somebody in one team has nothing to choose between, so the row of team // There are no team chips here: the team selector in the sidebar (and in the
// chips appears only when there is more than one. The default is all of // phone's top bar) is the one place the team is chosen, and chips for it
// them: the combined queue is the point. // would be the same choice offered twice.
if (state.teams.length > 1) {
chips.push(h('span', { class: 'chip-sep' }));
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(state.selectedTeamID == null),
onclick: () => setSelectedTeam(null),
}, h('span', { class: 'team-dot' }), ' All teams'));
for (const team of state.teams) {
chips.push(h('button', {
class: 'chip',
type: 'button',
role: 'tab',
'aria-selected': String(team.id === state.selectedTeamID),
onclick: () => setSelectedTeam(team.id),
},
h('span', { class: `team-dot ${teamColorClass(team.id)}` }),
' ' + team.name,
));
}
}
// A scroll hint for the phone-width row, where the chips can run off the
// right edge with nothing to suggest there's more; the desktop sidebar
// wraps instead of scrolling (see .pane-list .chips), so this fades out
// there via CSS rather than being left out here.
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
clear(el, chips); clear(el, chips);
chipsFade ??= fadeOnOverflow(el);
chipsFade();
}
// The cluster dropdown, under the status chips. Not shown at all until there
// are two clusters to choose between, the same rule the team selector follows.
function renderOrigin() {
const el = document.getElementById('queue-origin');
if (clusters.length < 2) {
el.hidden = true;
return;
}
const select = h('select', { class: 'origin-select', 'aria-label': 'Cluster' },
h('option', { value: '', text: 'All clusters', selected: !origin }),
clusters.map((c) => h('option', { value: c, text: c, selected: c === origin })));
select.addEventListener('change', () => {
origin = select.value || null;
saveOrigin();
items = null;
cursor = -1;
renderChips();
renderList();
refresh({ fresh: true });
});
clear(el, icon('server', 'icon origin-select-icon'), select);
el.hidden = false;
} }
function renderList() { function renderList() {
@@ -194,6 +263,10 @@ function row(inc, index) {
const snoozed = isFuture(inc.snoozed_until); const snoozed = isFuture(inc.snoozed_until);
const resolved = inc.status === 'resolved'; const resolved = inc.status === 'resolved';
// A filter that already says what the status is (Triggered, Resolved,
// Archived) would only have every row repeat it.
const impliedStatus = filter === 'triggered' || filter === 'resolved' || filter === 'archived';
let status; let status;
if (resolved) status = badge('Resolved', 'st-resolved'); if (resolved) status = badge('Resolved', 'st-resolved');
else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed'); else if (snoozed) status = badge(`Snoozed · ${until(inc.snoozed_until)}`, 'st-snoozed');
@@ -214,23 +287,28 @@ function row(inc, index) {
// The team is shown only to somebody who is in more than one. For everybody // The team is shown only to somebody who is in more than one. For everybody
// else it is the same word on every row, which is noise rather than // else it is the same word on every row, which is noise rather than
// information. // information.
const team = state.teams.length > 1 && inc.team_name // ...and not when the queue is already narrowed to one team: that team is
// the same word on every row there too.
const team = state.teams.length > 1 && state.selectedTeamID == null && inc.team_name
? h('span', { class: 'row-team', text: inc.team_name }) ? h('span', { class: 'row-team', text: inc.team_name })
: null; : null;
const rowOrigin = originOf(inc.group_labels);
return h('a', { return h('a', {
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`, class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
href: `/incidents/${inc.id}`, href: `/incidents/${inc.id}`,
'aria-current': inc.id === selected ? 'true' : null, 'aria-current': inc.id === selected ? 'true' : null,
dataset: { index: String(index) }, dataset: { index: String(index) },
}, },
h('div', { class: 'row-title', text: inc.title }), h('div', { class: 'row-title', text: titleWithoutOrigin(inc.title, rowOrigin) }),
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }), h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
h('div', { class: 'row-meta' }, h('div', { class: 'row-meta' },
status, rowOrigin && !origin && originChip(rowOrigin),
!impliedStatus && status,
// The left-border colour alone doesn't say what it means; spell it out // The left-border colour alone doesn't say what it means; spell it out
// too, same badge the incident detail page uses for severity. // too, same badge the incident detail page uses for severity.
inc.severity && badge(inc.severity, `plain ${severityClass(inc.severity)}`), inc.severity && severityBadge(inc.severity),
assignee, assignee,
team, team,
labels && h('span', { class: 'labels', text: labels }), labels && h('span', { class: 'labels', text: labels }),
+318 -159
View File
@@ -18,7 +18,7 @@
// than no form, but it is not the thing enforcing anything. // than no form, but it is not the thing enforcing anything.
import * as api from './api.js'; import * as api from './api.js';
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js'; import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js'; import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
@@ -168,13 +168,19 @@ function section() {
// buttons, because these are six URLs: app.js intercepts the click, the // buttons, because these are six URLs: app.js intercepts the click, the
// browser's Back walks them, and a reload lands where you were. // browser's Back walks them, and a reload lands where you were.
function subnav() { function subnav() {
return h('nav', { class: 'subnav', 'aria-label': 'Team' }, const nav = h('nav', { class: 'subnav', 'aria-label': 'Team' },
TABS.map((t) => h('a', { TABS.map((t) => h('a', {
class: 'subnav-link', class: 'subnav-link',
href: t.path, href: t.path,
text: t.label, text: t.label,
'aria-current': t.tab === tab ? 'page' : null, 'aria-current': t.tab === tab ? 'page' : null,
}))); })));
// On a phone the strip overflows; bring the open section into view so a
// tab past the edge (Sources, Switches) is never the one that is hidden.
fadeOnOverflow(nav);
requestAnimationFrame(() => nav.querySelector('[aria-current]')
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
return nav;
} }
// Names which team's settings the six sections below belong to. It used to be // Names which team's settings the six sections below belong to. It used to be
@@ -229,8 +235,8 @@ function overview() {
// A month of it, as a grid. It used to be thirty rows of "date — username", // A month of it, as a grid. It used to be thirty rows of "date — username",
// which is a rota spelled out one day at a time: the question asked of it is // which is a rota spelled out one day at a time: the question asked of it is
// "who has which stretch", and thirty names down a column is the one shape // "who has which stretch", and thirty names down a column is the one shape
// that answer cannot be read in. So each day carries a coloured initial // that answer cannot be read in. So a shift is drawn as one bar across its
// instead, the legend says whose, and a shift becomes a run of one colour. // days with the person's name on it.
// //
// The same month laid out the same way as the on-call page's week, because it // The same month laid out the same way as the on-call page's week, because it
// is the same rota — heading and arrows outside the card, days inside it. // is the same rota — heading and arrows outside the card, days inside it.
@@ -269,22 +275,8 @@ function scheduleCard() {
const today = isoDate(new Date()); const today = isoDate(new Date());
const month = monthStart.getMonth(); const month = monthStart.getMonth();
// Whose colours to explain, in the order the month meets them. Only the days const rows = [];
// of this month count: a name that appears solely in the overhang belongs to for (let w = 0; w < count / 7; w++) rows.push(weekRow(addDays(start, w * 7), byDate, today, month));
// the month next door and would be explaining a chip nobody asked about.
const seen = new Map();
const cells = [];
for (let i = 0; i < count; i++) {
const d = addDays(start, i);
const key = isoDate(d);
const e = byDate.get(key);
const inMonth = d.getMonth() === month;
// Every row starts with its week number, which is also the way to fill the
// whole week at once.
if (i % 7 === 0) cells.push(weekCell(d, byDate, today));
if (inMonth && e && !seen.has(e.user_id)) seen.set(e.user_id, e.username);
cells.push(dayCell(d, key, e, inMonth, today));
}
const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })]; const heads = [h('span', { class: 'rota-wd', title: 'ISO week number', text: 'Wk' })];
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
@@ -292,38 +284,104 @@ function scheduleCard() {
heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) })); heads.push(h('span', { class: 'rota-wd', text: weekdayFmt.format(new Date(2024, 0, 1 + i)) }));
} }
// The range form is the way to fill a whole shift at once, but it is not
// what the page is for, so it stays folded away until asked for.
let bulk = null;
if (isOwner()) {
const form = h('div', { class: 'rota-bulk-form', hidden: true }, assignForm());
const toggle = h('button', {
class: 'btn', type: 'button', 'aria-expanded': 'false',
onclick: () => {
form.hidden = !form.hidden;
toggle.setAttribute('aria-expanded', String(!form.hidden));
},
}, icon('calendar'), 'Assign a range of days');
bulk = [
h('div', { class: 'rota-foot' },
h('span', { class: 'muted small', text: 'Tap a day to change who holds it.' }), toggle),
form,
];
}
const thisMonth = isoDate(monthStart) === isoDate(firstOfMonth(new Date()));
return [ return [
h('div', { class: 'page-head' }, h('div', { class: 'page-head' },
h('h2', { text: 'On-call rota' }), h('h2', { text: 'On-call rota' }),
h('div', { class: 'week-nav' }, h('div', { class: 'week-nav' },
h('button', { h('button', {
class: 'btn btn-ghost btn-icon', type: 'button', class: 'btn btn-icon week-arrow', type: 'button',
'aria-label': 'Previous month', onclick: () => shiftMonth(-1), 'aria-label': 'Previous month', onclick: () => shiftMonth(-1),
}, icon('chevronLeft')), }, icon('chevronLeft')),
h('span', { class: 'month-label', text: monthFmt.format(monthStart) }),
h('button', { h('button', {
class: 'btn btn-ghost label', type: 'button', class: 'btn btn-icon week-arrow', type: 'button',
title: 'Back to this month',
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
text: monthFmt.format(monthStart),
}),
h('button', {
class: 'btn btn-ghost btn-icon', type: 'button',
'aria-label': 'Next month', onclick: () => shiftMonth(1), 'aria-label': 'Next month', onclick: () => shiftMonth(1),
}, icon('chevronRight')), }, icon('chevronRight')),
h('button', {
class: 'btn btn-sm', type: 'button', text: 'Today', disabled: thisMonth,
onclick: () => { monthStart = firstOfMonth(new Date()); refresh(); },
}),
), ),
), ),
h('div', { class: 'card' }, h('div', { class: 'card' },
h('div', { class: 'rota-grid' }, heads, cells), h('div', { class: 'rota-grid' }, h('div', { class: 'rota-heads' }, heads), rows),
h('div', { class: 'rota-foot' }, legend(seen), coverNote(byDate)), coverNote(byDate),
// The range form is the way to fill a whole shift at once, but it is not bulk,
// what the page is for, so it stays folded away under the month it edits.
isOwner() && h('details', { class: 'rota-bulk' },
h('summary', { text: 'Assign a range of days' }),
assignForm()),
), ),
]; ];
} }
// One Monday-to-Sunday row: the week number, a tappable cell per day, and the
// shifts drawn over the days as bars. A bar is a run of consecutive days held
// by the same person, so a week handed to one person is one bar with one name
// on it. The bars ignore the pointer, so a tap lands on the day underneath.
function weekRow(monday, byDate, today, month) {
const days = Array.from({ length: 7 }, (_, i) => addDays(monday, i));
const keys = days.map(isoDate);
const entries = keys.map((k) => byDate.get(k) || null);
const uid = (i) => (entries[i] ? entries[i].user_id : null);
const before = byDate.get(isoDate(addDays(monday, -1)));
const after = byDate.get(isoDate(addDays(monday, 7)));
const cells = [weekCell(monday, byDate, today)];
days.forEach((d, i) => {
const cell = dayCell(d, keys[i], entries[i], d.getMonth() === month, today);
cell.style.gridColumn = String(i + 2);
cells.push(cell);
});
for (let i = 0; i < 7;) {
let j = i;
while (j + 1 < 7 && uid(j + 1) === uid(i)) j++;
const entry = entries[i];
// A flat end says the shift carries on past this row, not that it stops.
const contL = !!entry && i === 0 && before?.user_id === entry.user_id;
const contR = !!entry && j === 6 && after?.user_id === entry.user_id;
cells.push(shiftBar(entry, i, j, { days, keys, month, today, contL, contR }));
i = j + 1;
}
return h('div', { class: 'rota-row' }, cells);
}
function shiftBar(entry, i, j, o) {
const span = j - i + 1;
const outside = o.days.slice(i, j + 1).every((d) => d.getMonth() !== o.month);
const cls = ['rota-bar', entry ? colorClass(entry.user_id) : 'gap',
outside && 'outside', o.keys[j] < o.today && 'past', o.contL && 'cont-l', o.contR && 'cont-r']
.filter(Boolean).join(' ');
// One day is too narrow for a name on a phone; the initial stands in and the
// full name is in the day's own label underneath.
const name = entry
? `${entry.username}${entry.user_id === myID() ? ' (you)' : ''}`
: (span >= 3 ? 'Nobody on call' : '');
const bar = h('div', {
class: cls, 'aria-hidden': 'true', title: name || 'Nobody on call',
text: entry && span === 1 ? initial(entry.username) : name,
});
bar.style.gridColumn = `${i + 2} / span ${span}`;
return bar;
}
// The ISO week number at the start of a row. For an owner it is a button: one // The ISO week number at the start of a row. For an owner it is a button: one
// tap fills the week, which is the way a rota is usually handed out — a person // tap fills the week, which is the way a rota is usually handed out — a person
// takes a week, not seven separate days. // takes a week, not seven separate days.
@@ -342,15 +400,10 @@ function weekCell(monday, byDate, today) {
} }
function dayCell(d, key, e, inMonth, today) { function dayCell(d, key, e, inMonth, today) {
const cls = ['rota-day', !inMonth && 'outside', key === today && 'today', key < today && 'past'] const cls = ['rota-day', !inMonth && 'outside', key === today && 'today']
.filter(Boolean).join(' '); .filter(Boolean).join(' ');
const label = `${key} · ${e ? e.username : 'nobody'}`; const label = `${key} · ${e ? e.username : 'nobody'}`;
const body = [ const body = [h('span', { class: 'rota-num', text: String(d.getDate()) })];
h('span', { class: 'rota-num', text: String(d.getDate()) }),
e
? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) })
: h('span', { class: 'rota-chip none' }),
];
// A member sees the same grid without the affordance, the way every other // A member sees the same grid without the affordance, the way every other
// control on this page is hidden rather than shown and refused. // control on this page is hidden rather than shown and refused.
return isOwner() return isOwner()
@@ -369,17 +422,7 @@ function colorClass(userID) {
return `rc${((i < 0 ? userID : i) % 6) + 1}`; return `rc${((i < 0 ? userID : i) % 6) + 1}`;
} }
function legend(seen) { // The gap count, which is the one thing the bars state only by omission. Days
if (!seen.size) return null;
return h('div', { class: 'rota-legend' },
[...seen].map(([id, name]) => h('span', { class: 'rota-key' },
h('span', { class: `rota-chip ${colorClass(id)}`, text: initial(name) }),
h('span', { text: name }),
id === myID() && h('span', { class: 'you', text: 'you' }),
)));
}
// The gap count, which is the one thing the grid states only by omission. Days
// already past are not counted: an empty Tuesday last week is history, not a // already past are not counted: an empty Tuesday last week is history, not a
// hole somebody still has to fill. // hole somebody still has to fill.
function coverNote(byDate) { function coverNote(byDate) {
@@ -390,10 +433,14 @@ function coverNote(byDate) {
const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day)); const key = isoDate(new Date(monthStart.getFullYear(), monthStart.getMonth(), day));
if (key >= today && !byDate.has(key)) gaps++; if (key >= today && !byDate.has(key)) gaps++;
} }
if (gaps === 0) return h('p', { class: 'rota-note', text: 'Every day left this month has somebody on call.' }); if (gaps === 0) {
return h('p', { class: 'rota-note' }, return h('div', { class: 'rota-banner ok' }, icon('checkCircle'),
h('span', { text: 'Every day left this month has somebody on call.' }));
}
return h('div', { class: 'rota-banner warn', role: 'status' }, icon('alertTriangle'),
h('span', {},
h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }), h('strong', { text: gaps === 1 ? '1 day' : `${gaps} days` }),
' left this month with nobody on call.'); ' left this month with nobody on call.'));
} }
// One week, in the sheet: who holds each day of it, and one person to put on // One week, in the sheet: who holds each day of it, and one person to put on
@@ -518,7 +565,7 @@ function assignForm() {
// API enforces: a plain assignment that silently moved a shift would move // API enforces: a plain assignment that silently moved a shift would move
// who gets paged without telling either of them. // who gets paged without telling either of them.
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'), h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
h('button', { class: 'btn', type: 'submit', text: 'Assign' })); h('button', { class: 'btn btn-primary', type: 'submit', text: 'Assign' }));
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
@@ -562,23 +609,49 @@ function escalationCard() {
const esc = data.escalation || {}; const esc = data.escalation || {};
const levels = esc.levels || []; const levels = esc.levels || [];
const rows = levels.map((l) => h('tr', {}, // The ladder as steps, top to bottom: each level, the wait before the next,
h('td', {}, h('strong', { text: `Level ${l.position}` })), // and what happens when the last one is not answered. The wait belongs to the
h('td', {}, levelBadge(l.status)), // level above it ("then after 5m"), which is how the API states it.
h('td', { class: 'wrap' }, ...l.targets.map(targetLine)), const steps = [];
h('td', { class: 'muted small', text: duration(l.timeout_seconds * 1000) }), levels.forEach((l) => {
h('td', { class: 'small' }, l.waiting?.length steps.push(h('li', { class: 'step' },
? l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })]) h('div', { class: 'step-rail' },
: h('span', { class: 'muted', text: '—' })), h('span', { class: `step-node st-${l.status}`, text: String(l.position) }),
)); h('span', { class: 'step-line' })),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' }, h('strong', { text: `Level ${l.position}` }), levelBadge(l.status)),
h('div', { class: 'step-pages' }, ...l.targets.map(targetLine)),
l.waiting?.length > 0 && h('div', { class: 'step-waiting' }, 'Waiting now: ',
...l.waiting.flatMap((id, i) => [i > 0 && ', ', h('a', { href: `/incidents/${id}`, text: `#${id}` })])))));
steps.push(h('li', { class: 'step step-wait' },
h('div', { class: 'step-rail' }, h('span', { class: 'step-line' })),
h('div', { class: 'step-gap' }, icon('clock', 'icon'), `then after ${duration(l.timeout_seconds * 1000)}`)));
});
if (levels.length) {
steps.push(h('li', { class: 'step' },
h('div', { class: 'step-rail' }, h('span', { class: `step-node end${esc.fallback_topic ? '' : ' warn'}` },
icon(esc.fallback_topic ? 'bell' : 'flag', 'icon'))),
h('div', { class: 'step-body' },
h('div', { class: 'step-top' },
h('strong', { text: esc.fallback_topic ? 'Fallback topic' : 'End of the chain' })),
esc.fallback_topic
? h('div', { class: 'step-pages' }, h('code', { text: esc.fallback_topic }), ' is paged once.')
: h('div', { class: 'callout', role: 'status' }, icon('alertTriangle', 'icon'),
h('div', {},
h('strong', { text: 'No fallback topic' }),
h('p', { text: 'After the last level the chain just ends and nobody else is woken.' }),
isOwner() && h('button', {
class: 'btn btn-sm', type: 'button', onclick: () => openLadderEditor({ focusFallback: true }),
}, icon('plus'), 'Add fallback'))))));
}
const facts = []; const facts = [];
if (levels.length) { if (levels.length) {
const n = esc.repeat_count || 0; const n = esc.repeat_count || 0;
if (n) facts.push(`Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`); facts.push(n
facts.push(esc.fallback_topic ? `Then the whole ladder repeats ${n} more ${n === 1 ? 'time' : 'times'}.`
? ['Finally the ntfy topic ', h('code', { text: esc.fallback_topic }), ' is paged once.'] : 'Nothing repeats: the ladder runs once.');
: 'No fallback topic: after the last level the chain just ends.');
facts.push(esc.last_escalated_at facts.push(esc.last_escalated_at
? ['Last escalated ', ? ['Last escalated ',
h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }), h('span', { title: when(esc.last_escalated_at), text: ago(esc.last_escalated_at) }),
@@ -590,23 +663,18 @@ function escalationCard() {
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Escalation' }), h('h2', { text: 'Escalation' }),
isOwner() && h('button', { isOwner() && h('button', {
class: 'btn', type: 'button', onclick: openLadderEditor, class: 'btn btn-primary', type: 'button', onclick: () => openLadderEditor(),
text: levels.length ? 'Edit ladder' : 'Set up ladder', text: levels.length ? 'Edit ladder' : 'Set up ladder',
})), })),
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'When a level’s wait passes and nobody has acknowledged, the next level is ', 'When a level’s wait passes and nobody has acknowledged, the next level is ',
'paged. Acknowledging or resolving stops it; snoozing pauses it.'), 'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
levels.length levels.length
? h('div', { class: 'table-scroll' }, ? h('ol', { class: 'ladder' }, steps)
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Level' }), h('th', { text: 'Status' }), h('th', { text: 'Pages' }),
h('th', { text: 'Then after' }), h('th', { text: 'Waiting now' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted' }, : h('p', { class: 'muted' },
'No ladder. An unacknowledged incident re-pages the same person every ', 'No ladder. An unacknowledged incident re-pages the same person every ',
'reminder interval and nobody else is woken.'), 'reminder interval and nobody else is woken.'),
...facts.map((f) => h('p', { class: 'muted small' }, f)), facts.length > 0 && h('div', { class: 'ladder-facts' }, ...facts.map((f) => h('p', { class: 'muted small' }, f))),
); );
} }
@@ -614,7 +682,7 @@ function escalationCard() {
// it wholesale: the levels are an order, and patching one rung would leave the // it wholesale: the levels are an order, and patching one rung would leave the
// numbering of the others undecided. The draft lives in the sheet, so a poll of // numbering of the others undecided. The draft lives in the sheet, so a poll of
// the page underneath cannot throw away half an edit. // the page underneath cannot throw away half an edit.
function openLadderEditor() { function openLadderEditor({ focusFallback = false } = {}) {
const esc = data.escalation || {}; const esc = data.escalation || {};
const draft = { const draft = {
repeat_count: esc.repeat_count || 0, repeat_count: esc.repeat_count || 0,
@@ -626,6 +694,7 @@ function openLadderEditor() {
}; };
const body = h('div', { class: 'ladder-editor' }); const body = h('div', { class: 'ladder-editor' });
let fallbackInput = null;
const problem = h('p', { class: 'load-error', hidden: true }); const problem = h('p', { class: 'load-error', hidden: true });
const paint = () => { const paint = () => {
@@ -665,7 +734,7 @@ function openLadderEditor() {
value: String(draft.repeat_count), value: String(draft.repeat_count),
oninput: (e) => { draft.repeat_count = Number(e.target.value); }, oninput: (e) => { draft.repeat_count = Number(e.target.value); },
}); });
const fallback = h('input', { const fallback = fallbackInput = h('input', {
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all', type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
oninput: (e) => { draft.fallback_topic = e.target.value; }, oninput: (e) => { draft.fallback_topic = e.target.value; },
}); });
@@ -696,6 +765,7 @@ function openLadderEditor() {
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }), h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
save), save),
]); ]);
if (focusFallback) requestAnimationFrame(() => fallbackInput?.focus());
} }
function targetRow(level, target, index, repaint) { function targetRow(level, target, index, repaint) {
@@ -735,8 +805,7 @@ function minutesInput(seconds, onChange) {
// --- integrations ---------------------------------------------------------- // --- integrations ----------------------------------------------------------
function integrationsCard() { function integrationsCard() {
const rows = (data.integrations || []).map((i) => const rows = (data.integrations || []).map((i) => clickableRow(h('tr', {},
h('tr', {},
h('td', {}, sourceBadge(i.status)), h('td', {}, sourceBadge(i.status)),
h('td', { class: 'wrap' }, h('td', { class: 'wrap' },
h('strong', { text: i.name }), h('strong', { text: i.name }),
@@ -748,29 +817,13 @@ function integrationsCard() {
h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours', h('td', { class: 'muted small num', title: 'Distinct alerts refreshed in the last 24 hours',
text: String(i.alerts_24h ?? 0) }), text: String(i.alerts_24h ?? 0) }),
h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })), h('td', { class: 'muted small' }, h('span', { title: when(i.created_at), text: ago(i.created_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' }, ), () => openSourceDetail(i)));
h('button', {
class: 'btn-sm', type: 'button', text: 'Rename', onclick: () => openRenameSource(i),
}),
h('button', {
class: 'btn-sm danger', type: 'button', text: 'Revoke',
onclick: async () => {
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
}))),
));
return h('div', { class: 'card' }, return h('div', { class: 'card' },
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Alert sources' }), h('h2', { text: 'Alert sources' }),
isOwner() && h('button', { isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New source', onclick: openNewSource, class: 'btn btn-primary', type: 'button', text: 'New source', onclick: openNewSource,
})), })),
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Alerts arrive on an integration key, which says both that the sender may ', 'Alerts arrive on an integration key, which says both that the sender may ',
@@ -780,9 +833,9 @@ function integrationsCard() {
? h('div', { class: 'table-scroll' }, ? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' }, h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {}, h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Source' }), h('th', { text: 'Status' }), h('th', { text: 'Name' }),
h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }), h('th', { text: 'Last webhook' }), h('th', { text: 'Last alert' }),
h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }), h('th'))), h('th', { class: 'num', text: 'Alerts 24h' }), h('th', { text: 'Created' }))),
h('tbody', {}, rows))) h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }), : h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
); );
@@ -844,6 +897,34 @@ function openNameSheet({ title, submit, value, run }) {
openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]); openSheet(() => [h('h2', { class: 'sheet-title', text: title }), form]);
} }
function openSourceDetail(i) {
openDetailSheet(i.name, [
sheetFact('Status', sourceBadge(i.status)),
sheetFact('Kind', i.kind),
sheetFact('Last webhook', timeCell(i.last_used_at)),
sheetFact('Last alert', timeCell(i.last_alert_at)),
sheetFact('Alerts, 24h', String(i.alerts_24h ?? 0)),
sheetFact('Created', h('span', { title: when(i.created_at), text: ago(i.created_at) })),
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Rename',
onclick: () => { closeSheet(); openRenameSource(i); },
}),
isOwner() && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Revoke',
onclick: async () => {
closeSheet();
if (!(await confirm({
title: `Revoke ${i.name}?`,
text: 'Anything posting with this key stops delivering immediately. Alerts it already delivered stay.',
confirmLabel: 'Revoke',
danger: true,
}))) return;
act(() => api.deleteIntegration(teamID, i.id));
},
}));
}
function openNewSource() { function openNewSource() {
openNameSheet({ openNameSheet({
title: 'New source', submit: 'Add source', value: '', title: 'New source', submit: 'Add source', value: '',
@@ -899,6 +980,34 @@ const triggeredCell = (iso, incidentID) => {
: h('span', { title: when(iso), text: ago(iso) }); : h('span', { title: when(iso), text: ago(iso) });
}; };
// A list row that opens its details: the list is for finding the thing, the
// sheet it opens is where the buttons are. Links inside the row keep working.
function clickableRow(tr, open) {
tr.classList.add('clickable');
tr.tabIndex = 0;
tr.addEventListener('click', (e) => {
if (!e.target.closest('a')) open();
});
tr.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target === tr) open();
});
return tr;
}
// Label/value rows for a details sheet, and the sheet itself: facts above, then
// Close and whatever the viewer may do. Falsy actions (a non-owner's) drop out.
const sheetFact = (label, value) => [h('dt', { text: label }), h('dd', {}, value)];
function openDetailSheet(title, facts, ...actions) {
openSheet(() => [
h('h2', { class: 'sheet-title', text: title }),
h('dl', { class: 'switch-facts' }, facts),
h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Close', onclick: () => closeSheet() }),
...actions),
]);
}
function switchRows(sw) { function switchRows(sw) {
const main = h('tr', {}, const main = h('tr', {},
h('td', {}, switchBadge(sw.status)), h('td', {}, switchBadge(sw.status)),
@@ -908,19 +1017,8 @@ function switchRows(sw) {
h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)), h('td', { class: 'muted small' }, timeCell(sw.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)), h('td', { class: 'muted small' }, triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }), h('td', { class: 'muted small', text: duration(sw.timeout_seconds * 1000) }),
h('td', {}, isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove',
onclick: async () => {
if (!(await confirm({
title: `Remove ${sw.name}?`,
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
},
})),
); );
clickableRow(main, () => openSwitchDetail(sw));
// One heartbeat is the switch's own times; several are worth telling apart, // One heartbeat is the switch's own times; several are worth telling apart,
// since a live cluster must not hide a dead one. // since a live cluster must not hide a dead one.
@@ -935,7 +1033,7 @@ function switchRows(sw) {
&& h('code', { class: 'small', text: src.fingerprint })), && h('code', { class: 'small', text: src.fingerprint })),
h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)), h('td', { class: 'muted small' }, timeCell(src.last_heartbeat_at)),
h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)), h('td', { class: 'muted small' }, triggeredCell(src.last_triggered_at, src.incident_id)),
h('td'), h('td'))) h('td')))
: []; : [];
return [main, ...sources]; return [main, ...sources];
} }
@@ -947,7 +1045,7 @@ function deadmanCard() {
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Dead man’s switches' }), h('h2', { text: 'Dead man’s switches' }),
isOwner() && h('button', { isOwner() && h('button', {
class: 'btn', type: 'button', text: 'New switch', onclick: openNewSwitch, class: 'btn btn-primary', type: 'button', text: 'New switch', onclick: () => openSwitchForm(),
})), })),
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ', 'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
@@ -956,9 +1054,9 @@ function deadmanCard() {
? h('div', { class: 'table-scroll' }, ? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' }, h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {}, h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Switch' }), h('th', { text: 'Status' }), h('th', { text: 'Name' }),
h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }), h('th', { text: 'Last heartbeat' }), h('th', { text: 'Last triggered' }),
h('th', { text: 'Silent after' }), h('th'))), h('th', { text: 'Silent after' }))),
h('tbody', {}, switches.flatMap(switchRows)))) h('tbody', {}, switches.flatMap(switchRows))))
: h('p', { class: 'muted', text: 'Nothing watched.' }), : h('p', { class: 'muted', text: 'Nothing watched.' }),
); );
@@ -966,16 +1064,22 @@ function deadmanCard() {
// The form lives in the sheet, not on the page: most visits are to look at the // The form lives in the sheet, not on the page: most visits are to look at the
// list, and a form that is always open is the page this replaced. // list, and a form that is always open is the page this replaced.
function openNewSwitch() { function openSwitchForm(existing) {
const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true }); const name = h('input', { type: 'text', placeholder: 'Prod Watchdog', autofocus: true });
const matcher = h('input', { const matcher = h('input', {
type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true, type: 'text', placeholder: 'alertname=Watchdog,cluster=prod', class: 'wide', required: true,
}); });
const timeout = h('input', { const timeout = h('input', {
type: 'number', min: '1', value: '15', class: 'setting-value', required: true, type: 'number', min: '1', step: 'any', value: '15', class: 'setting-value', required: true,
}); });
const severity = h('select', {}, const severity = h('select', {},
...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s }))); ...['critical', 'error', 'warning', 'info'].map((s) => h('option', { value: s, text: s })));
if (existing) {
name.value = existing.name;
matcher.value = existing.matcher;
timeout.value = String(existing.timeout_seconds / 60);
severity.value = existing.severity;
}
const problem = h('p', { class: 'load-error', hidden: true }); const problem = h('p', { class: 'load-error', hidden: true });
const form = h('form', { class: 'stacked-form' }, const form = h('form', { class: 'stacked-form' },
@@ -990,17 +1094,20 @@ function openNewSwitch() {
problem, problem,
h('div', { class: 'sheet-actions' }, h('div', { class: 'sheet-actions' },
h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }), h('button', { class: 'btn', type: 'button', text: 'Cancel', onclick: () => closeSheet(false) }),
h('button', { class: 'btn btn-primary', type: 'submit', text: 'Add switch' }))); h('button', { class: 'btn btn-primary', type: 'submit', text: existing ? 'Save' : 'Add switch' })));
form.addEventListener('submit', async (e) => { form.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
await api.createDeadmanSwitch(teamID, { const body = {
name: name.value.trim(), name: name.value.trim(),
matcher: matcher.value.trim(), matcher: matcher.value.trim(),
timeout_seconds: Math.round(Number(timeout.value) * 60), timeout_seconds: Math.round(Number(timeout.value) * 60),
severity: severity.value, severity: severity.value,
}); };
await (existing
? api.updateDeadmanSwitch(teamID, existing.id, body)
: api.createDeadmanSwitch(teamID, body));
} catch (err) { } catch (err) {
problem.textContent = err.message; problem.textContent = err.message;
problem.hidden = false; problem.hidden = false;
@@ -1010,7 +1117,47 @@ function openNewSwitch() {
refresh(); refresh();
}); });
openSheet(() => [h('h2', { class: 'sheet-title', text: 'New switch' }), form]); openSheet(() => [
h('h2', { class: 'sheet-title', text: existing ? 'Edit switch' : 'New switch' }), form]);
}
// What the list row has no room for, and where Edit and Delete live.
function openSwitchDetail(sw) {
const sources = sw.sources.length > 1
? [sheetFact('Sources', h('div', {}, ...sw.sources.map((src) => h('div', { class: 'source-labels' },
switchBadge(src.status),
...Object.entries(src.labels || {})
.filter(([k]) => k !== 'alertname')
.map(([k, v]) => labelChip(k, v)),
h('span', { class: 'muted small' }, ' ', timeCell(src.last_heartbeat_at))))))]
: [];
openDetailSheet(sw.name, [
sheetFact('Status', switchBadge(sw.status)),
sheetFact('Matcher', h('code', { text: sw.matcher })),
sheetFact('Silent after', duration(sw.timeout_seconds * 1000)),
sheetFact('Severity', sw.severity),
sheetFact('Last heartbeat', timeCell(sw.last_heartbeat_at)),
sheetFact('Last triggered', triggeredCell(sw.last_triggered_at, sw.open_incident_id)),
sources,
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit',
onclick: () => { closeSheet(); openSwitchForm(sw); },
}),
isOwner() && h('button', {
class: 'btn btn-danger', type: 'button', text: 'Delete',
onclick: async () => {
closeSheet();
if (!(await confirm({
title: `Remove ${sw.name}?`,
text: 'It stops being watched. An incident it already opened stays open until it is resolved.',
confirmLabel: 'Remove',
danger: true,
}))) return;
act(() => api.deleteDeadmanSwitch(teamID, sw.id));
},
}));
} }
// --- members --------------------------------------------------------------- // --- members ---------------------------------------------------------------
@@ -1050,23 +1197,27 @@ function shiftCell(m) {
function oidcGroupsCard() { function oidcGroupsCard() {
if (!state.auth?.oidc?.enabled) return null; if (!state.auth?.oidc?.enabled) return null;
const g = data.oidcGroups || { member_group: '', owner_group: '' }; const g = data.oidcGroups || { member_group: '', owner_group: '' };
// With nothing set, Edit is the one thing to do here, so it leads.
const unset = !g.member_group && !g.owner_group;
return h('div', { class: 'card' }, return h('div', { class: 'card' },
h('div', { class: 'card-head' }, h('div', { class: 'card-head' },
h('h2', { text: 'Single sign-on' }), h('h2', { text: 'Single sign-on' }),
isOwner() && h('button', { isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor, class: unset ? 'btn btn-primary' : 'btn', type: 'button', text: 'Edit', onclick: openOidcGroupsEditor,
})), })),
h('p', { class: 'muted small' }, h('p', { class: 'muted small' },
'Members of the group below are added to this team automatically at ', 'Members of the group below are added to this team automatically at ',
'sign-in; members of the owner group become owners. Leave a field ', 'sign-in; members of the owner group become owners. Leave a field ',
'blank to grant nothing this way.'), 'blank to grant nothing this way.'),
h('dl', { class: 'user-facts' }, h('dl', { class: 'user-facts' },
fact('Member group', g.member_group || '—'), fact('Member group', g.member_group || notConfigured()),
fact('Owner group', g.owner_group || '—'), fact('Owner group', g.owner_group || notConfigured()),
), ),
); );
} }
const notConfigured = () => h('span', { class: 'muted', text: 'Not configured' });
function fact(label, value) { function fact(label, value) {
return [h('dt', { text: label }), h('dd', { text: value })]; return [h('dt', { text: label }), h('dd', { text: value })];
} }
@@ -1114,9 +1265,7 @@ function membersCard() {
const members = data.members || []; const members = data.members || [];
const owners = members.filter((m) => m.role === 'owner').length; const owners = members.filter((m) => m.role === 'owner').length;
const rows = members.map((m) => { const rows = members.map((m) => clickableRow(h('tr', {},
const lastOwner = m.role === 'owner' && owners === 1;
return h('tr', {},
h('td', {}, memberBadge(m)), h('td', {}, memberBadge(m)),
h('td', { class: 'wrap' }, h('td', { class: 'wrap' },
h('strong', { text: m.username }), h('strong', { text: m.username }),
@@ -1127,20 +1276,52 @@ function membersCard() {
h('td', { class: 'muted small' }, timeCell(m.last_active_at)), h('td', { class: 'muted small' }, timeCell(m.last_active_at)),
h('td', { class: 'muted small' }, h('td', { class: 'muted small' },
h('span', { title: when(m.joined_at), text: ago(m.joined_at) })), h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
h('td', {}, isOwner() && h('div', { class: 'row-actions' }, ), () => openMemberDetail(m, owners)));
h('button', {
class: 'btn-sm', type: 'button', text: 'Edit', return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn btn-primary', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Name' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
)];
}
function openMemberDetail(m, owners) {
const lastOwner = m.role === 'owner' && owners === 1;
openDetailSheet(m.username, [
sheetFact('Status', memberBadge(m)),
sheetFact('Role', h('span', {}, m.role, m.source === 'oidc' && ssoBadge())),
sheetFact('Rota', shiftCell(m)),
sheetFact('Last active', timeCell(m.last_active_at)),
sheetFact('Joined', h('span', { title: when(m.joined_at), text: ago(m.joined_at) })),
m.problem && sheetFact('Problem', h('span', { class: 'target-problem', text: m.problem })),
],
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Edit',
// The server refuses to edit a membership the groups grant. // The server refuses to edit a membership the groups grant.
disabled: m.source === 'oidc', disabled: m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED : null, title: m.source === 'oidc' ? SSO_MANAGED : null,
onclick: () => openEditMember(m), onclick: () => { closeSheet(); openEditMember(m); },
}), }),
h('button', { isOwner() && h('button', {
class: 'btn-sm danger', type: 'button', text: 'Remove', class: 'btn btn-danger', type: 'button', text: 'Remove',
disabled: lastOwner || m.source === 'oidc', disabled: lastOwner || m.source === 'oidc',
title: m.source === 'oidc' ? SSO_MANAGED title: m.source === 'oidc' ? SSO_MANAGED
: lastOwner ? 'A team needs an owner. Make somebody else one first.' : null, : lastOwner ? 'A team needs an owner. Make somebody else one first.' : null,
onclick: async () => { onclick: async () => {
closeSheet();
if (!(await confirm({ if (!(await confirm({
title: `Remove ${m.username}?`, title: `Remove ${m.username}?`,
text: 'They lose access to this team. Rota days already assigned to them are not ' text: 'They lose access to this team. Rota days already assigned to them are not '
@@ -1150,29 +1331,7 @@ function membersCard() {
}))) return; }))) return;
act(() => api.removeTeamMember(teamID, m.user_id)); act(() => api.removeTeamMember(teamID, m.user_id));
}, },
}))), }));
);
});
return [oidcGroupsCard(), h('div', { class: 'card' },
h('div', { class: 'card-head' },
h('h2', { text: 'Members' }),
isOwner() && h('button', {
class: 'btn', type: 'button', text: 'Add member', onclick: openAddMember,
})),
h('p', { class: 'muted small' },
'Owners set up the team; members work its incidents. Somebody who can’t be ',
'paged is worth fixing before their next shift.'),
members.length
? h('div', { class: 'table-scroll' },
h('table', { class: 'admin-table status-table' },
h('thead', {}, h('tr', {},
h('th', { text: 'Status' }), h('th', { text: 'Member' }), h('th', { text: 'Role' }),
h('th', { text: 'Rota' }), h('th', { text: 'Last active' }), h('th', { text: 'Joined' }),
h('th'))),
h('tbody', {}, rows)))
: h('p', { class: 'muted', text: 'Nobody is in this team.' }),
)];
} }
// One sheet for both jobs a member's row has: who, and as what. Adding is // One sheet for both jobs a member's row has: who, and as what. Adding is
+45
View File
@@ -0,0 +1,45 @@
// Theme override: System (no attribute, the OS decides), Light or Dark.
//
// A classic script loaded from <head>, not a module, so the saved choice is on
// <html> before the first paint and a Dark user on a light OS never sees a
// flash. The CSP allows no inline script, hence a file of its own. Account
// (account.js) reads and writes the choice through window.terdutTheme.
(function () {
var KEY = 'terdut.theme';
var COLORS = { light: '#f5f6f8', dark: '#0f1115' };
function get() {
try {
var v = localStorage.getItem(KEY);
return v === 'light' || v === 'dark' ? v : 'system';
} catch (e) {
return 'system'; // storage unavailable
}
}
// The browser chrome colour follows the choice too; the two <meta>s are
// media-keyed to the OS, so a forced theme sets both to the same colour.
function apply(theme) {
var root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
var metas = document.querySelectorAll('meta[name="theme-color"]');
for (var i = 0; i < metas.length; i++) {
var scheme = /dark/.test(metas[i].media) ? 'dark' : 'light';
metas[i].content = COLORS[theme === 'system' ? scheme : theme];
}
}
function set(theme) {
try {
if (theme === 'system') localStorage.removeItem(KEY);
else localStorage.setItem(KEY, theme);
} catch (e) {
/* storage unavailable: applies for this page view only */
}
apply(theme);
}
window.terdutTheme = { get: get, set: set };
apply(get());
})();
+40
View File
@@ -1,5 +1,7 @@
// DOM helpers, the bottom sheet, confirmation and toasts. // DOM helpers, the bottom sheet, confirmation and toasts.
import { severityClass, originClass } from './format.js';
// h builds an element. attrs: class, text, on<event>, dataset, aria/other // h builds an element. attrs: class, text, on<event>, dataset, aria/other
// attributes; boolean true sets an empty attribute, false/null skips it. // attributes; boolean true sets an empty attribute, false/null skips it.
export function h(tag, attrs = {}, ...children) { export function h(tag, attrs = {}, ...children) {
@@ -30,8 +32,29 @@ export function clear(el, ...children) {
return el; return el;
} }
// Marks a horizontally scrolling strip with data-more while there is more of it
// to the right, which the CSS turns into a fade on that edge. The fade is a
// mask on the strip itself, so it stays put at the edge of the screen: a fade
// drawn as a child of the scroller scrolls away with the content. Returns the
// update function, for a strip whose contents change under it.
export function fadeOnOverflow(el) {
const update = () => {
el.toggleAttribute('data-more', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
};
el.addEventListener('scroll', update, { passive: true });
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(el);
update();
return update;
}
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything. // Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
const ICONS = { const ICONS = {
server: ['rect:3,4,18,6,2', 'rect:3,14,18,6,2', 'M7 7h.01', 'M7 17h.01'],
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
arrowUp: ['M12 19V5', 'M6 11l6-6 6 6'],
plus: ['M12 5v14M5 12h14'],
back: ['M15 18l-6-6 6-6'], back: ['M15 18l-6-6 6-6'],
more: ['M5 12h.01M12 12h.01M19 12h.01'], more: ['M5 12h.01M12 12h.01M19 12h.01'],
queueList: ['M4 6h16M4 12h16M4 18h10'], queueList: ['M4 6h16M4 12h16M4 18h10'],
@@ -171,6 +194,23 @@ export function badge(text, cls = '') {
return h('span', { class: `badge ${cls}`, text }); return h('span', { class: `badge ${cls}`, text });
} }
// Where it came from (the cluster): a chip in that origin's colour, with a
// server icon so it reads as a place and not as a status.
export function originChip(value) {
return h('span', { class: `origin-chip ${originClass(value)}`, title: `Origin: ${value}` },
icon('server', 'icon badge-icon'), value);
}
// The severity as a badge with a shape as well as a colour: a triangle for
// critical, a circle with ! for warning, a circle with i for anything else, so
// the coloured bar and badge are not the only thing that says it.
const SEVERITY_ICON = { 'sev-critical': 'alertTriangle', 'sev-warning': 'alertCircle' };
export function severityBadge(severity) {
const cls = severityClass(severity);
return h('span', { class: `badge plain ${cls}` },
icon(SEVERITY_ICON[cls] || 'infoCircle', 'icon badge-icon'), severity);
}
// Access granted by the identity provider's groups. The server refuses to edit // Access granted by the identity provider's groups. The server refuses to edit
// it by hand (it would be undone at the next sign-in), so the controls that // it by hand (it would be undone at the next sign-in), so the controls that
// would try are disabled with this as their reason. // would try are disabled with this as their reason.