v0.26.0
113 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2b396d22d6 |
Set the chart's placeholder version to 0.26.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m56s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 4s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.26.0 |
||
|
|
1f1faa437c |
Show the escalation ladder as a list, with who it would page and where it is
Team -> Escalation was the draft form on the page, which showed the ladder only as inputs. It is now a table in the style of Switches and Sources: a row per level with a status badge, who it pages, the wait before the next level, and the open incidents currently waiting on it. Below it, the repeat count, the fallback topic and when the ladder last escalated (linking the incident). The editor moved into an "Edit ladder" sheet, so a poll of the page underneath can no longer throw away half an edit, and the page-level draft state went with it. Targets are resolved to who they mean today, and the badge says what would actually happen: Ready, Escalating (an unanswered incident has climbed to level 2 or higher), or Pages nobody. The last is the one worth seeing before an incident finds it: an empty rota, a person with no ntfy topic or a disabled account each make a rung a silence with a number on it, and the target says which. The rules are pageLevel's own, so the page cannot promise a page the notifier would skip. "Last escalated" comes from the escalated timeline events that already exist, so there is no migration. Acknowledging or resolving takes an incident off the ladder, so Escalating clears then while the history stays. API: GET /escalation gains status and waiting per level, username, reachable and problem per target, and last_escalated_at and last_escalated_incident_id. Output only and additive; PUT is unchanged and terdut-tui needs nothing. |
||
|
|
dc92f51cf8 |
Set the chart's placeholder version to 0.25.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 31s
CI / test (push) Successful in 3m20s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 20s
Release / image (push) Successful in 1m2s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.25.0 |
||
|
|
d675f8ec9b |
List alert sources with their status and last arrival on Team -> Sources
Like Team -> Switches, the page is now a table: a status badge (Active
if the key posted within a day, Quiet if it has but not lately, Never
used), when it last posted a webhook, when an alert last arrived on it,
how many distinct alerts it refreshed in the last 24 hours, and when it
was created. Adding a source moved into a "New source" sheet, and owners
can rename one from its row.
"Last alert" and the count needed alerts to remember which source they
came in on, which they never did, so migration 010 adds
alerts.integration_id and every accepted payload stamps it. Last sender
wins when two sources post the same fingerprint. It is not backfilled: a
NULL says "before this was recorded" rather than guessing, and it heals
by itself as Alertmanager re-sends each alert every repeat_interval.
Revoking a source keeps its alerts, unattributed.
Last webhook and last alert are separate on purpose: a payload with
nothing usable in it stamps the first and not the second. The Quiet
threshold is a fixed day, a colour and not an alarm, since silence that
should page is what dead man's switches are for.
The counts are indexed subqueries (alerts_integration_idx) rather than a
join, which would read every alert a source ever delivered.
API: the integrations list gains status, last_alert_at and alerts_24h,
and PATCH /api/teams/{id}/integrations/{id} renames. Both are additive;
terdut-tui needs nothing.
|
||
|
|
e8d45f9d3d |
Set the chart's placeholder version to 0.24.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m55s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 1m0s
Release / scan-image (push) Successful in 4s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.24.0 |
||
|
|
f3918b863c |
List dead man's switches with their status on Team -> Switches
The page was a bare form: it did not say which switches existed or
whether they were alive. It now lists them, each with a Healthy, Dead or
Dormant badge, when its heartbeat was last heard and when it last opened
an incident (linked while that incident is open). A matcher that several
clusters satisfy is broken down per cluster, since a live cluster must
not hide a dead one. The form moved into a "New switch" sheet, and each
row has a Remove with a confirm.
That needed a switch to be a thing, so switches are rows now
(migration 009) with their own name, matcher, timeout and severity,
instead of one string with one team-wide timeout in deadman_configs.
Existing configuration is split into one row per matcher; a team whose
timeout was zero simply has none. The sweeper and the status endpoint
share one death rule (deadmanAlert.dead), so the page cannot disagree
with the pager. Incident group keys are unchanged, so incidents that
are open across the upgrade keep working.
The environment defaults (TERDUT_DEADMAN_*) are seeded into teams once
per install, recorded in settings, so a team that deletes its last
switch does not get it back on the next restart. Installs that already
had per-team rows are marked as seeded by the migration.
Removing a switch stops the watching but leaves an incident it already
opened open until someone resolves it.
API: GET/PUT /api/teams/{id}/deadman are replaced by
GET/POST /deadman/switches and DELETE /deadman/switches/{switchID}.
terdut-tui does not call them, so nothing to mirror there.
|
||
|
|
3ee8583f6f |
Set the chart's placeholder version to 0.23.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 57s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.23.0 |
||
|
|
591d5b8df0 |
Copy an incident to the clipboard as Markdown
A button in the incident header (also `y`, and "Copy incident" in the more menu) puts everything the page knows on the clipboard, for pasting into a chat or an agent prompt with no integration involved. The text carries the facts, every alert with all its labels and annotations (the page only shows summary or description), the timeline with notes in full, and the "Seen before" resolution notes. Times are ISO 8601 and users are named rather than "you", since relative and first-person wording is ambiguous once pasted elsewhere. The async clipboard API needs a secure context and this server is often reached over plain HTTP, so it falls back to execCommand. Web UI only: no endpoint or JSON shape changed, so nothing to mirror in terdut-tui. |
||
|
|
d2cdcc9776 |
Set the chart's placeholder version to 0.22.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m45s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.22.1 |
||
|
|
8b2789b9b2 |
Let the filter chips wrap in the desktop incident list
The list pane is 340-420px wide and its chip row scrolled sideways with the scrollbar hidden. That works by swipe on a phone, but a mouse has nothing to grab, so Archived (the last chip) could not be reached on a wide screen. In the desktop layout the row now wraps instead, and the divider between the status and team chips is hidden there, since it would sit mid-line. Phones keep the sideways scroll: the rule is inside the min-width: 900px block. Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU |
||
|
|
71d7e1853a |
Set the chart's placeholder version to 0.22.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m41s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 5s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.22.0 |
||
|
|
60ebb75cd2 |
Show notes from similar earlier incidents
Each incident gets a signature: the alert name plus the group labels that
say what is broken, minus the ones that only say where it ran (instance,
pod, container, ...). GET /api/incidents/{id}/similar returns resolved
incidents in the same team with the same signature that have notes.
Notes can be marked as the resolution note, "what fixed it", either with a
resolution field on resolve or pinned on a note. Those lead the similar
list, show on the incident page as "Seen before", and the triggered
notification carries the latest one.
Claude-Session: https://claude.ai/code/session_01MMados3BD1oSjevHxbmVqU
|
||
|
|
734cd9c5fd |
Set the chart's placeholder version to 0.21.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m52s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 3s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.21.0 |
||
|
|
423ed9b3a3 |
Add a Stats page to the web UI
Statistics used to live only in terdut-tui; the account page said so. The page shows the same figures as the TUI's Stats tab -- incident counts, MTTA and MTTR, top alerts, and alert frequency by hour (UTC) and by day of week -- and adds a range picker (Today, 7d, 30d, 90d, All) that the TUI does not have. The ranges are day-granular because the server reads from/to as whole UTC dates, so there is no 24h chip. No server change: the page uses the existing /api/stats/* endpoints, which already scope to the caller's teams. Charts are inline SVG and plain elements sized from script, because the CSP forbids inline styles, inline scripts and CDN libraries. Removes the "statistics are in terdut-tui" notes from the account page and the README. |
||
|
|
43f004499b |
Set the chart's placeholder version to 0.20.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m30s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 15s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.20.1 |
||
|
|
559be6de6e |
Retry the first database ping instead of dying on it
Every start crashed once or twice before going healthy: kube-router enforces this namespace's NetworkPolicy per-node, reacting to the new pod's creation event, and the app's first connection attempt can reach Postgres's node before that node's allow-set has been updated with the new pod's IP. The result is "connection refused" -- an active reject, not a timeout, which is how it was told apart from Postgres itself not being ready (it had been up for two days in the run that was diagnosed). That race resolves within several seconds in practice, so Open now retries the ping up to five times, two seconds apart, logging each failure, before giving up with the same wrapped error as before. Nothing else about Open's behaviour changed: a genuinely absent database still fails, just after ~8s instead of immediately. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
e77f04b55e |
Set the chart's placeholder version to 0.20.0
CI / chart (push) Successful in 2s
CI / security (push) Successful in 20s
CI / test (push) Successful in 2m35s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic: make helm-package sets the published version and appVersion from the tag, so these two fields decide nothing (see the comment above them). Kept in step anyway, same asv0.20.0 |
||
|
|
e536fdd2c0 |
Replace the mobile tab bar with a hamburger menu
Six tabs (Queue, On-call, Alerts, Team, Admin, Account) had already
outgrown the bottom bar once:
|
||
|
|
429d5fdda3 |
Set the chart's placeholder version to 0.19.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m32s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 55s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway for the same reason asv0.19.0 |
||
|
|
3cdd5aee1f |
Give the Team tab sub-sections of its own
The Team tab was five cards stacked on one page: the rota, the escalation ladder, the alert sources, the dead man's switches and the membership. |
||
|
|
6a03698f65 |
Set the chart's placeholder version to 0.18.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m40s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 21s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 23s
Cosmetic, and done anyway for the same reason asv0.18.0 |
||
|
|
67d68ce058 |
Show the rota as a month rather than a list of dates
The Team tab printed the next thirty days as thirty rows of date, name and
a Clear button. That is a rota spelled out one day at a time, and it is the
one shape the question cannot be read in: what anybody wants from a rota is
who holds which stretch, and thirty names down a column hides a handover
between two rows that look the same. It was also the longest thing on the
page by a wide margin, so the escalation ladder and the alert sources sat
below a screen of dates.
It is a month now, Monday to Sunday, one coloured initial per day. A shift
becomes a run of one colour, which is the shape the answer actually has; a
gap becomes a hole you can see. The legend underneath says whose colour is
whose, and one line says how many days are left uncovered, counting only
from today -- an empty Tuesday last week is history, not a hole somebody
still has to fill.
Laid out like the on-call page's week, deliberately: heading and arrows
outside the card, days inside it. It is the same rota, and two pages
showing it two ways would be two things to learn.
Colours come from a person's place in the member list, so they hold still
as you page between months, and six of them repeat -- the initial inside
still tells two people apart, and a legend that has to explain nine hues is
not a legend. They are not the severity palette: nothing on a rota is
critical, and a red Thursday would read as one. --teal and --pink are new
in both themes for the two the palette was short.
The per-row Clear button had nowhere left to live, so a day opens the sheet
the app already uses for confirmations: who holds it, a picker, Assign and
Clear. That assign sends replace=true where the range form still asks
first, and the difference is the point -- the sheet has just named whoever
holds the day, so taking it from them is the thing that was asked for
rather than something to warn about. The range form is unchanged and folded
into a details, since filling a whole shift is what it is for; it opens on
the month above it rather than on today, so paging to March to fill March
does not hand you September.
The server is untouched. The month drawn is the month fetched -- the grid's
Monday overhang and its trailing days are real days and are fetched with
it -- so paging is one GET /api/teams/{id}/schedule per month with from and
to, where it used to be one fixed thirty-day window. No new endpoint, no
change to what the API returns, and terdut-tui is unaffected.
Nobody has looked at this in a browser either. What is checked is the
rendering: team.js's own refresh() was run against a stub fetch and a
pocket DOM for September 2026, and it produces 35 cells for a month whose
1st is a Tuesday, the right from/to on the schedule call, today marked on
the 22nd, three people in the legend with "you" on the viewer, the gap
count over a five-day hole, and -- as a member rather than an owner -- the
same grid as plain divs with no sheet and no range form. How it looks at
phone width, and whether the six colours hold up in dark mode, are not
checked.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
|
||
|
|
a6fa673e08 |
Give every team a page of its own
The Admin tab's team list was growing controls the way the user list did before |
||
|
|
ee22eb000c |
Set the chart's placeholder version to 0.17.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 13s
CI / test (push) Successful in 2m31s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 3s
Cosmetic, and done anyway for the same reason asv0.17.0 |
||
|
|
07914d5cdb |
Give the Admin tab sub-sections of its own
Administration was one scrolling page with three cards on it: the teams, the people, and the settings. There was no way to link somebody to the settings, no way back to the top of the user list but scrolling, and the poll loop refetched all three endpoints every tick however little of the page you were looking at. Each is now a route -- /admin/teams, /admin/users, /admin/settings -- reached from a strip across the top, with /admin an overview that says how many of each there are. The three cards themselves are untouched; they are simply rendered one at a time, so a tab fetches only what it shows. The Users page is the exception and fetches the teams too, since its invite form has to offer a team to invite somebody into. The strip is ordinary links rather than chips. Chips filter what a page already shows, here and in the queue, and these four go somewhere: the browser's Back walks them, a reload lands where you were, and the click is intercepted by the same handler every other link in the app uses. The current one is marked with aria-current="page", the convention the tab bar has used since it existed, so the state lives on the attribute and not in a class. admin.js owns the table of the four routes, because it also builds the strip that links to them; app.js parses against that table rather than keeping a second list to drift from it. Adding a fifth sub-section is one line. The bottom tab bar still has six items. |
||
|
|
7b9a337d25 |
Set the chart's placeholder version to 0.16.1
CI / chart (push) Successful in 1s
CI / security (push) Successful in 16s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 4s
Release / binaries (push) Successful in 28s
Release / image (push) Successful in 1m5s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version from the git tag when it packages, so neither line decides anything about what is published; they exist to be read by somebody looking at the tree before the tag does. A tree heading for v0.16.1 that says 0.16.0 tells that reader something false. Its own commit, likev0.16.1 |
||
|
|
fc8b0c8d58 |
Let people set their own ntfy topic under Account
The first-run checklist's first step is "Set where your pages go", and its
button navigated to /more — which had no field for it. Every new user was
sent to a page that could not do the thing it sent them there for, and the
only ways to actually set a topic were curl or asking an administrator.
That has been true since the checklist shipped in v0.15.0.
Account now has a Notifications section above the password form: the topic,
prefilled and saved through the endpoint that already existed, and a Send a
test push button. The test is offered only once a topic is saved, because
it publishes what the server has stored rather than what is half-typed in
the field, and a button that silently tested the previous value would be
worse than no button.
Saving assigns the response to state.me.user, so the checklist stops asking
and the test button appears without a reload. Clearing works by saving an
empty topic: the server treats that as "no topic of their own" rather than
an error, and returns a user with ntfy_topic absent — it is omitempty — so
the form reads the cleared state from the response rather than assuming it.
The copy says the topic is a shared secret, because people reach for their
own name and it is the only thing between a stranger and their pages. Same
reason the topic stays out of an incident's timeline, which every API key
can read.
No server change: PUT /api/users/{id}/notify has been self-or-admin since
#3 and needed nothing. Only the ntfy topic is per-person — the server is
the install's one TERDUT_NTFY_URL and is not something a user picks.
Also drops a line on that page still sending people to terdut-tui for user
management, which stopped being true one release ago.
Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
|
||
|
|
828cf87656 |
Set the chart's placeholder version to 0.16.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 14s
CI / test (push) Successful in 2m33s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 59s
Release / scan-image (push) Successful in 2s
Cosmetic, and done anyway. release.yaml passes --version and --app-version from the git tag when it packages, so neither line decides anything about what is published; they exist to be read by somebody looking at the tree before the tag does. A tree heading for v0.16.0 that says 0.15.1 tells that reader something false. Its own commit, likev0.16.0 |
||
|
|
ac9af8e4f5 |
Manage a person's account and teams from one page
The Admin tab could make somebody an administrator and disable them, and
nothing else. Setting a first password, deleting an account and seeing
which teams a person is in all meant curl, and the last one meant opening
each team in turn — the Team tab answers "who is in this team", which is
the wrong way round when the question is about a person.
A name in the user list now opens /admin/users/{id}: their email and when
they joined, where their notifications go, the administrator and disabled
flags, the teams they are in with their role in each, a password field
for a first or forgotten one, and deletion. A section of its own rather
than an expanding row, because memberships and the account actions
together are more than a table row can hold and still be read on a phone.
Adding somebody mints an invite link into a chosen team rather than
creating a bare account. POST /api/users makes a user with no password
and no team, who can sign in nowhere and would see nothing if they did;
the invite machinery from #7 already solves both, and the password is
chosen by the person it belongs to instead of passing through an
administrator.
One new endpoint, GET /api/users/{id}/teams, self or admin. /api/teams is
always about the caller and cannot be asked about anybody else. It 404s
for a user who does not exist, so the page can tell "in no teams" from
"no such person" — an empty list is a real answer and needed to stay one.
No authorisation changed, and the interesting part is why it did not.
requireTeamOwner has accepted the administrator flag since
|
||
|
|
8869ac864f |
Set the chart's placeholder version to 0.15.1
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 10s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 22s
Release / image (push) Successful in 56s
Release / scan-image (push) Successful in 3s
Cosmetic, as inv0.15.1 |
||
|
|
0677e74cf8 |
Merge pull request 'Let the tab bar fit however many tabs there are' (#21) from compact-nav into main
Reviewed-on: #21 |
||
|
|
56b8191a78 |
Let the tab bar fit however many tabs there are
The bottom tab bar was grid-template-columns: repeat(4, 1fr), written when there were four tabs. Team and Admin arrived in the last two releases and nothing updated that number, so six items were being laid into four columns -- which on a phone is the reported symptom, tabs that do not fit the width. grid-auto-flow: column with grid-auto-columns: 1fr makes the count follow the markup instead. That also handles a case a fixed number cannot: Admin is only rendered for an administrator, so the tab count genuinely differs between two people looking at the same install. Then the compactness. Each link gets min-width: 0 so a column may shrink below its label's natural width, and the label itself ellipsises rather than widening the bar. Under 420px the font drops to 10px, the icons to 21px and the badge shrinks to match. No icon-only breakpoint. The arithmetic says the labels fit: six tabs on a 320px phone give about 53px each, and the widest label, "On-call", is about 38px at 10px. A media query that never fires is dead code, and the ellipsis is the backstop if a future tab is named something longer. The links gained aria-labels regardless. The icons are aria-hidden, so the visible text was the accessible name, and it should not be the only one. The desktop sidebar is unaffected: it overrides display, padding and font-size itself, so none of the phone rules reach it. Not verified on a phone -- I cannot open a browser here, so this is the cause identified from the CSS and the widths worked out on paper. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
93761056eb |
Set the chart's placeholder version to 0.15.0
CI / chart (push) Successful in 4s
CI / test (push) Successful in 12s
CI / security (push) Successful in 17s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 19s
Release / image (push) Successful in 1m3s
Release / scan-image (push) Successful in 24s
Cosmetic, as inv0.15.0 |
||
|
|
a92da7dcc0 |
Merge pull request 'Add the sign-up page and the first-run checklist' (#20) from onboarding-ui into main
Reviewed-on: #20 |
||
|
|
b39aac36b7 |
Add the sign-up page and the first-run checklist
Second half of #7. The API could create accounts from invite links since the last change; this is the part somebody can actually use. /signup is the one route that works without a session. It asks the server what it may offer before showing anything: an invite link that is good names the team it leads to, a link that is not says so before somebody picks a password rather than after, and an invite-only server with no link says that instead of presenting a form it will refuse. The login card only offers "create one" when sign-up is open, so the door nobody can walk through is not advertised. Signing up signs you in and lands on the queue, because the alternative is a form saying "now go and log in" about the credential just chosen. The checklist is the other half. Four things have to be true before an alert reaches a phone -- a notification topic, somebody on the rota, an alert source, and an alert that has actually arrived -- and on a fresh install none of them are. It sits above the queue until they are. It is computed from the data rather than from stored progress: a topic is set or it is not, an integration exists or it does not. That means it cannot claim a step is done when it is not, and it comes back by itself if somebody deletes their integration a month later. The only stored state is the dismissal, which is per user and not per browser -- finishing on a laptop should not leave the phone nagging. The topic step is the only one the checklist can finish itself, and the only proof that counts is a phone buzzing, so there is a test push. POST /api/me/notify/test publishes directly rather than through the outbox, which requires an incident this deliberately does not have. Its failure is the useful part: a wrong topic, a rejected token and an ntfy that is down all look identical from the phone, which is silence, so the error comes back to the browser instead. Verified against a live server with a real ntfy stand-in, the whole path: an owner mints an invite, the sign-up page reports it valid and names the team, the invitee signs up and is signed in as a member of that team, the checklist's four questions answer correctly on a fresh install, a test push is refused with no topic and delivered with one -- "PAGED terdut-owner | terdut test" -- and the dismissal survives a reload. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
19f168ab7e |
Merge pull request 'Add self-service sign-up and invite links' (#19) from signup-invites into main
Reviewed-on: #19 |
||
|
|
d827ceedff |
Add self-service sign-up and invite links
First half of #7. Until now the only way to get an account was for somebody who already had one to create it, and the login page told people to "ask an admin" -- workable for one operator, impossible for a team. Two modes, chosen by an administrator in the settings table: invite_only, which is the default, and open. A third domain-restricted mode was considered and dropped, because with no email in this server there is nothing to verify an address against and it would only check the domain of a string somebody typed. The default is the closed door. An install that gets a public hostname before anybody has thought about sign-up should not be collecting accounts from the internet, and the failure mode of a typo in the setting is invite_only rather than open. An invite is a link, not an email. Adding SMTP to send one message would be a subsystem to run, secure and monitor; the person inviting sends the link however they already talk to the person they are inviting. A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody -- that is not a state to invite somebody into. Links are single-use by default, expire after seven days, and can be revoked before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter is incremented inside the sign-up transaction and guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password rather than after. It gives one answer for expired, revoked, used up and never existed: telling a stranger which it was tells them something about links they do not hold. Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession rather than each minting a cookie. Rate-limited per address on its own limiter, not login's: a burst of sign-ups must not lock somebody out of logging in. The settings table grew a second shape for this. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything -- a request that sets two settings and gets one wrong changes neither. Still to come in #7: the sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema carries onboarding_dismissed_at for the checklist already. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
4e8c52c28c |
Set the chart's placeholder version to 0.14.0
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 12s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 24s
Release / image (push) Successful in 53s
Release / scan-image (push) Successful in 2s
Cosmetic, as inv0.14.0 |
||
|
|
fb927aa67b |
Merge pull request 'Put a team's own settings in the web UI' (#18) from team-settings-ui into main
Reviewed-on: #18 |
||
|
|
d728af53b1 |
Put a team's own settings in the web UI
Closes #17. Everything a team owner configures was API-only: escalation, integrations, dead man's switches, membership, and the rota -- which the on-call view still described as the TUI's job, and the TUI has been broken against this server since teams landed. Setting up the feature this whole line of work exists for meant using curl. A Team tab now holds all of it, one team at a time, with a picker for somebody in more than one. An owner edits; a member sees the same page without the controls, because the server refuses their writes anyway -- hiding a button is a courtesy to the reader, not the thing enforcing anything. The escalation editor holds a draft and sends the whole ladder, because the API replaces it wholesale: the levels are an order, and patching one rung leaves the numbering of the others undecided. Adding a level defaults to five minutes and the rota, which is the shape almost every ladder starts as. An integration key is returned exactly once, so creating one opens a panel that says so, shows the URL large with a copy button, and renders the Alertmanager receiver snippet with the URL already in it -- the next thing anybody does with that key is paste it into a config. The panel stays until it is dismissed rather than disappearing on the next re-render. The incident view gains where an incident is on the ladder and when the next page is due, which is the question somebody looking at an unacknowledged incident actually has. The API carries it: the incident payload now includes escalation_level and escalation_due_at, the latter computed in the incident SELECT by joining the level's timeout, so a list costs no extra queries. Verified against a live server by making every call the page makes, including the writes: the six reads the Team tab issues, a two-level ladder saved and read back, an integration created and its key returned once, three days of rota assigned, switches set, and an incident showing level 1 with a due time five minutes out. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
53e5e03f4e |
Set the chart's placeholder version to 0.13.0
CI / test (push) Successful in 4s
CI / chart (push) Successful in 1s
CI / security (push) Successful in 11s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 18s
Release / image (push) Successful in 58s
Release / scan-image (push) Successful in 7s
Cosmetic, as inv0.13.0 |
||
|
|
4d62c1130b |
Merge pull request 'Page the next person when nobody answers' (#16) from escalation into main
Reviewed-on: #16 |
||
|
|
3183e7e5c5 |
Page the next person when nobody answers
Closes #6, and closes the thing this whole line of work was opened for. Until now an unacknowledged incident re-paged the same topic every notify_repeat forever, which is a louder version of the same silence: if the person on call is asleep, out of signal or has left the company, nothing else happened. A team can now configure an ordered ladder. Each level has a timeout and a set of targets; a target is a named person or whoever the team's rota says is on call today. That second kind is the one that keeps working when the rota changes and nobody remembers to edit the policy. When a level's timeout passes with the incident still triggered, the next level is paged; off the end the chain repeats repeat_count times and then the team's fallback topic is paged once. The incident stays open throughout, because running out of people to wake is not somebody answering. Escalation rides the notifier's existing 30-second tick and its outbox rather than adding a second scheduler, and runs before delivery so a level that comes due on a tick is paged on that tick. Each target gets its own outbox row and therefore its own Acknowledge token: the button in a notification must acknowledge as the person holding the phone, not as whoever was paged first. Acknowledging or resolving takes the incident off the ladder. Snoozing pauses it -- a deliberate "not now" holds the ladder where it is and it resumes when the snooze runs out, rather than carrying on without the person who asked for quiet. Reminders and escalation never both run. A team with a ladder gets escalation; a team without keeps today's behaviour exactly. Both would mean two pages for one silence, which is how a tool gets muted. A level whose targets cannot be reached -- no topic, a disabled account, an empty rota -- is entered anyway, recorded as "nobody reachable", and the ladder moves on. Stalling on a rung that cannot ring would be the failure this feature exists to prevent, wearing the feature's clothes. A policy with such a level cannot be created, but an older row could hold one. The API replaces the ladder wholesale rather than patching a rung, because the levels are an order: editing one has to answer what happens to the numbering of the others, and a whole-ladder PUT makes that the client's decision and the edit atomic. Verified against a live server as well as in tests: alice paged, nobody answers, bob paged, nobody answers, the fallback topic paged once and the timeline reading "level 2: bob" then "escalation exhausted: paged terdut-oncall-all" -- and a second incident acknowledged before its timeout, which woke nobody else. No UI yet. The team-settings screens for escalation, integrations and dead man's switches are all still missing, and they are one piece of work rather than three. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
94d23a593c |
Merge pull request 'Add an admin page, and move the behaviour settings into the database' (#15) from admin-settings into main
Reviewed-on: #15 |
||
|
|
b0a02c010b |
Add an admin page, and move the behaviour settings into the database
Closes #5. Three of the server's tunables were environment variables, which meant changing how long an incident waits before being paged again required editing a chart, merging it and waiting for a reconcile. They are behaviour rather than infrastructure, and the difference is who needs to change them and how often. The split is by who owns the value. What stays in the environment is where the server is plugged in: the listen address, the DSN, the ntfy URL and token, the public URL. Those are needed before the database is open and two of them are credentials -- the settings endpoint reports that ntfy is configured and that a token is set, and never what either is. What moves is how it behaves: the notify repeat interval, the stale window and the archive window. The environment variable becomes the seed rather than the setting, written once on first start and never overwritten, so a redeploy cannot put a chart's default back over an administrator's edit -- the rule the per-team dead man's switches already follow. The loops read the current value per tick, so a change at 02:00 is obeyed at 02:00. Key/value rather than a column per knob: #6 and #7 will both add settings, and a table shaped one-column-per-setting needs a migration for each. The cost is that values are text and the accessor has to say what type it wanted, which settings.go does in one place. Unknown keys are refused rather than stored -- a typo that wrote notify_repeat_second would otherwise sit in the table looking like configuration and doing nothing -- and each value has bounds loose enough to catch a slipped decimal point without having an opinion about anybody's rota. Disabling an account is new, and is not deleting one. Deleting a user nulls acknowledged_by and assigned_to, which quietly rewrites who did what during an incident months after the fact. A disabled user cannot authenticate by either credential, loses their sessions immediately, and stays the name on every acknowledgement they made. The check is part of the lookup in serveAs rather than a test afterwards, so there is no path where the row is loaded and the flag is then forgotten. The page itself is a fourth tab, shown only to an administrator and only as a courtesy: every endpoint under it is refused with 403 regardless, so somebody who types /admin gets an explanation rather than a blank screen. It lists teams with their size and open-incident count, users with their flags, and the settings with their bounds -- plus the environment half, read-only, so somebody hunting for the ntfy URL learns where it lives instead of concluding the server has none. Delete is disabled rather than offered-and-refused for a team with open incidents, and neither admin action is offered on your own account, since the server refuses both. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
303e7a3365 |
Merge pull request 'Remove the unauthenticated webhook and the SQLite migration script' (#14) from cleanup-after-teams into main
Reviewed-on: #14 |
||
|
|
7c87ae2af8 |
Remove the unauthenticated webhook and the SQLite migration script
Both existed to carry an upgrade across, and both upgrades are done. /api/alertmanager/webhook took no credential at all: anything able to reach the port could open an incident for anybody. v0.12.0 kept it, deprecated, so the teams release did not stop delivery while the Alertmanager config was edited, and logged a line per payload asking to be moved. The cluster's Alertmanager now posts on an integration key -- verified in the log, every two minutes, with no deprecation line since the rollout -- so the door can be shut rather than left ajar until somebody remembers. A sender still posting there gets the JSON 404 every unknown /api path gets. The tests move with it, which they should have done anyway: the harness mints an integration key for the default team and posts on that, so they exercise the path production uses rather than one only they still used. scripts/sqlite-to-postgres.go goes the same way. It was written to be temporary, it was the last thing needing modernc.org/sqlite, and this install migrated on 2026-09-20. `go mod tidy` drops the driver and its six transitive dependencies with it; the module graph is now chi, pgx, pgerrcode and x/crypto. Anyone still on v0.10.x can take the script out of the v0.12.0 tag, which the README now says. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7 |
||
|
|
4c85e7646c |
Set the chart's placeholder version to 0.12.0
CI / test (push) Successful in 5s
CI / chart (push) Successful in 2s
CI / security (push) Successful in 15s
Release / test (push) Successful in 4s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 20s
Release / image (push) Successful in 54s
Release / scan-image (push) Successful in 2s
Cosmetic, as inv0.12.0 |
||
|
|
05f82220a6 | Merge pull request 'Per-team dead man's switches, and the UI's team badge, filter and cards' (#13) from teams into main | ||
|
|
5227eb0d5f | Merge pull request 'Give each team its own dead man's switches, and the UI a team to show' (#12) from deadman-per-team into teams |