Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 43f004499b | |||
| 559be6de6e | |||
| e77f04b55e | |||
| e536fdd2c0 | |||
| 429d5fdda3 | |||
| 3cdd5aee1f |
@@ -48,10 +48,10 @@ curl -H "Authorization: Bearer $KEY" http://localhost:8080/api/users
|
|||||||
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
The server serves a web UI at `/`: the incident queue, each incident's alerts
|
||||||
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
and timeline with every action (acknowledge, assign, snooze, note, resolve,
|
||||||
archive), who is on call, the alert feed, and an *Account* tab for your own
|
archive), who is on call, the alert feed, and an *Account* tab for your own
|
||||||
password and the ntfy topic your pages go to. It is built for a phone first. On a phone it has a bottom tab bar and a sticky action
|
password and the ntfy topic your pages go to. It is built for a phone first. On a phone
|
||||||
bar, it follows the system's dark mode, and it can be added to the home screen.
|
it navigates through a hamburger menu and has a sticky action bar, it follows the
|
||||||
From 900px wide it switches to a sidebar with the queue and the incident side by
|
system's dark mode, and it can be added to the home screen. From 900px wide it switches
|
||||||
side. Statistics remain in
|
to a sidebar with the queue and the incident side by side. Statistics remain in
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
||||||
|
|
||||||
You sign in with a username and password. Users have no password until one is
|
You sign in with a username and password. Users have no password until one is
|
||||||
@@ -85,11 +85,16 @@ How a browser stays signed in:
|
|||||||
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
With `TERDUT_PUBLIC_URL` set, tapping a push notification opens the incident in
|
||||||
the web UI (`/incidents/{id}`).
|
the web UI (`/incidents/{id}`).
|
||||||
|
|
||||||
A **Team** tab holds everything a team owns: the on-call rota, the escalation
|
A **Team** tab holds everything a team owns, in five sub-sections with a URL
|
||||||
ladder, the alert sources with their keys, the dead man's switches and the
|
each and a strip across the top to move between them: the on-call rota
|
||||||
membership. An owner edits it; a member sees the same page read-only, because
|
(`/team/rota`), the membership (`/team/members`), the escalation ladder
|
||||||
the server refuses their writes anyway. Somebody in more than one team picks
|
(`/team/escalation`), the alert sources with their keys (`/team/sources`) and
|
||||||
between them at the top.
|
the dead man's switches (`/team/deadman`). `/team` itself is an overview — who
|
||||||
|
is on call today, how many members and owners, how many ladder levels, how many
|
||||||
|
keys and how many switches — so a page fetches only what it shows. An owner
|
||||||
|
edits it; a member sees the same pages read-only, because the server refuses
|
||||||
|
their writes anyway. Somebody in more than one team picks between them above
|
||||||
|
the strip, since the choice changes the subject of all five.
|
||||||
|
|
||||||
The rota is a month at a time, one coloured initial per day with a legend
|
The rota is a month at a time, one coloured initial per day with a legend
|
||||||
underneath, and it says how many days are left uncovered — the question a rota
|
underneath, and it says how many days are left uncovered — the question a rota
|
||||||
|
|||||||
@@ -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.18.0
|
version: 0.20.1
|
||||||
appVersion: "v0.18.0"
|
appVersion: "v0.20.1"
|
||||||
|
|||||||
+26
-4
@@ -5,6 +5,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,6 +16,19 @@ import (
|
|||||||
//go:embed migrations
|
//go:embed migrations
|
||||||
var migrationsFS embed.FS
|
var migrationsFS embed.FS
|
||||||
|
|
||||||
|
// pingAttempts and pingRetryDelay bound the retry on the first connection.
|
||||||
|
// This pod's own IP can reach the Postgres pod's node before that node's
|
||||||
|
// NetworkPolicy enforcement (kube-router, reacting to the pod's creation
|
||||||
|
// event) has added it to the allowed-source set, which fails the ping with
|
||||||
|
// "connection refused" rather than a timeout. That race resolves within
|
||||||
|
// several seconds in practice; five attempts two seconds apart give it
|
||||||
|
// comfortable room without turning a genuinely absent database into a long
|
||||||
|
// hang.
|
||||||
|
const (
|
||||||
|
pingAttempts = 5
|
||||||
|
pingRetryDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
|
// Open connects to Postgres. dsn is a libpq connection string or URL, e.g.
|
||||||
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
|
// postgres://terdut:secret@localhost:5432/terdut?sslmode=disable.
|
||||||
//
|
//
|
||||||
@@ -33,11 +47,19 @@ func Open(dsn string) (*sql.DB, error) {
|
|||||||
db.SetMaxOpenConns(10)
|
db.SetMaxOpenConns(10)
|
||||||
db.SetMaxIdleConns(5)
|
db.SetMaxIdleConns(5)
|
||||||
db.SetConnMaxLifetime(time.Hour)
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
db.Close()
|
for attempt := 1; ; attempt++ {
|
||||||
return nil, fmt.Errorf("ping: %w", err)
|
err = db.Ping()
|
||||||
|
if err == nil {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
if attempt == pingAttempts {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("ping: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("open db: ping attempt %d/%d failed, retrying in %s: %v", attempt, pingAttempts, pingRetryDelay, err)
|
||||||
|
time.Sleep(pingRetryDelay)
|
||||||
}
|
}
|
||||||
return db, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate applies every embedded migration that has not been applied yet, in
|
// Migrate applies every embedded migration that has not been applied yet, in
|
||||||
|
|||||||
+35
-35
@@ -48,7 +48,11 @@
|
|||||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
|
||||||
--topbar-h: 52px;
|
--topbar-h: 52px;
|
||||||
--tabbar-h: 58px;
|
/* No bottom tab bar on any breakpoint any more — mobile uses the hamburger
|
||||||
|
menu in the topbar, desktop the sidebar — so this stays 0. Kept as a
|
||||||
|
variable rather than deleted since .view, .toast and .nav's own height
|
||||||
|
calc still read it. */
|
||||||
|
--tabbar-h: 0px;
|
||||||
--safe-top: env(safe-area-inset-top, 0px);
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
}
|
}
|
||||||
@@ -196,7 +200,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
-webkit-backdrop-filter: saturate(1.4) blur(12px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.topbar-title { font-size: 18px; font-weight: 700; letter-spacing: -0.01em; }
|
.topbar-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.topbar-title {
|
||||||
|
font-size: 18px; font-weight: 700; letter-spacing: -0.01em;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
|
||||||
|
}
|
||||||
|
#menu-btn { position: relative; }
|
||||||
|
.nav-badge.menu-btn-badge { top: 2px; left: auto; right: 2px; }
|
||||||
|
|
||||||
.open-pill {
|
.open-pill {
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
@@ -209,15 +219,12 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
|||||||
.open-pill.has-triggered::before { background: var(--crit); }
|
.open-pill.has-triggered::before { background: var(--crit); }
|
||||||
.open-pill.all-acked::before { background: var(--warn); }
|
.open-pill.all-acked::before { background: var(--warn); }
|
||||||
|
|
||||||
/* Bottom tab bar on phones. */
|
/* Hidden on phones — mobile navigates through the hamburger menu in the
|
||||||
|
topbar instead (see #menu-btn / openNavMenu in app.js). Reappears as the
|
||||||
|
left sidebar from 900px, where the desktop block below redeclares display. */
|
||||||
.nav {
|
.nav {
|
||||||
|
display: none;
|
||||||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
position: fixed; left: 0; right: 0; bottom: 0; z-index: 20;
|
||||||
/* One column per link, however many there are. This was repeat(4, 1fr) when
|
|
||||||
there were four tabs; Team and Admin arriving pushed six items into four
|
|
||||||
columns, which on a phone is how they stopped fitting. Auto columns mean
|
|
||||||
the next tab cannot break the row either — and Admin is only rendered for
|
|
||||||
an administrator, so the count genuinely varies between viewers. */
|
|
||||||
display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
|
|
||||||
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
height: calc(var(--tabbar-h) + var(--safe-bottom));
|
||||||
padding-bottom: var(--safe-bottom);
|
padding-bottom: var(--safe-bottom);
|
||||||
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
background: color-mix(in srgb, var(--surface) 92%, transparent);
|
||||||
@@ -239,16 +246,6 @@ 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; }
|
||||||
|
|
||||||
/* Narrow phones, where six tabs each get about 55-65px. Tightening is enough:
|
|
||||||
the widest label, "On-call", is about 38px at this size, so nothing has to
|
|
||||||
be hidden. The ellipsis above is the backstop if a future tab is named
|
|
||||||
something longer. */
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.nav-link { font-size: 10px; gap: 1px; }
|
|
||||||
.nav-link svg { width: 21px; height: 21px; }
|
|
||||||
.nav-badge { left: calc(50% + 4px); min-width: 16px; height: 16px; font-size: 10px; line-height: 16px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link[aria-current="page"] { color: var(--accent); }
|
.nav-link[aria-current="page"] { color: var(--accent); }
|
||||||
.nav-badge {
|
.nav-badge {
|
||||||
position: absolute; top: 6px; left: calc(50% + 6px);
|
position: absolute; top: 6px; left: calc(50% + 6px);
|
||||||
@@ -601,8 +598,6 @@ kbd {
|
|||||||
/* ---------- desktop ---------- */
|
/* ---------- desktop ---------- */
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
@media (min-width: 900px) {
|
||||||
:root { --tabbar-h: 0px; }
|
|
||||||
|
|
||||||
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
.app { display: grid; grid-template-columns: 220px 1fr; height: 100dvh; }
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -836,11 +831,14 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
|
|
||||||
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
.signup-intro { margin: 0 0 4px; font-size: 14px; color: var(--muted); }
|
||||||
|
|
||||||
/* --- admin sub-navigation ------------------------------------------------
|
/* --- sub-navigation -------------------------------------------------------
|
||||||
A strip of links across the top of every admin page, one per sub-section.
|
A strip of links across the top of every Admin and every Team page, one per
|
||||||
Deliberately not .chip: chips filter what a page already shows, here and in
|
sub-section. Deliberately not .chip: chips filter what a page already shows,
|
||||||
the queue, and these four go somewhere. Same aria-current convention as the
|
here and in the queue, and these go somewhere. Same aria-current convention
|
||||||
tab bar, so the state lives on the attribute rather than in a class. */
|
as the tab bar, so the state lives on the attribute rather than in a class.
|
||||||
|
|
||||||
|
Six entries do not fit a phone's width, which is what the horizontal scroll
|
||||||
|
below is for -- the Team tab's strip is the one that needs it. */
|
||||||
.subnav {
|
.subnav {
|
||||||
display: flex; gap: 2px;
|
display: flex; gap: 2px;
|
||||||
margin: 12px auto 0;
|
margin: 12px auto 0;
|
||||||
@@ -857,13 +855,15 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
.subnav-link:hover { color: var(--text); }
|
.subnav-link:hover { color: var(--text); }
|
||||||
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
.subnav-link[aria-current="page"] { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
/* The overview at /admin. The strip above already links to the three, so these
|
/* The overview a tab opens on, at /admin and at /team. The strip above already
|
||||||
carry the counts, which is the part a menu cannot say. */
|
links to the sections, so these carry the counts, which is the part a menu
|
||||||
.admin-menu { display: grid; gap: 10px; margin-top: 16px; }
|
cannot say. Not named for either tab: both use it, and the one that renamed
|
||||||
|
.user-link to .row-link is the same rename for the same reason. */
|
||||||
|
.overview-menu { display: grid; gap: 10px; margin-top: 16px; }
|
||||||
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
/* The grid's gap is the spacing here, so .card + .card must not add its own. */
|
||||||
.admin-menu .card + .card { margin-top: 0; }
|
.overview-menu .card + .card { margin-top: 0; }
|
||||||
.admin-menu-item { display: block; padding: 14px; }
|
.overview-item { display: block; padding: 14px; }
|
||||||
.admin-menu-item:hover { background: var(--surface-hover); }
|
.overview-item:hover { background: var(--surface-hover); }
|
||||||
.admin-menu-head { display: flex; align-items: baseline; gap: 8px; }
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.admin-menu-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
.admin-menu-item p { margin: 4px 0 0; }
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
|||||||
@@ -108,7 +108,13 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
<div class="topbar-left">
|
||||||
|
<button class="btn btn-ghost btn-icon" id="menu-btn" type="button" aria-label="Menu" aria-haspopup="menu">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||||
|
<span class="nav-badge menu-btn-badge" data-badge hidden></span>
|
||||||
|
</button>
|
||||||
|
<h1 class="topbar-title" id="topbar-title">Queue</h1>
|
||||||
|
</div>
|
||||||
<span class="open-pill" id="open-pill" hidden></span>
|
<span class="open-pill" id="open-pill" hidden></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// gate.
|
// gate.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import { h, clear, spinner, confirm } from './ui.js';
|
import { h, clear, spinner, confirm, menuCard } 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');
|
||||||
@@ -123,26 +123,17 @@ function overview() {
|
|||||||
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
const people = [`${admins} ${admins === 1 ? 'administrator' : 'administrators'}`];
|
||||||
if (disabled > 0) people.push(`${disabled} disabled`);
|
if (disabled > 0) people.push(`${disabled} disabled`);
|
||||||
|
|
||||||
return h('div', { class: 'admin-menu' },
|
return h('div', { class: 'overview-menu' },
|
||||||
menuItem('/admin/teams', 'Teams', data.teams.length,
|
menuCard('/admin/teams', 'Teams', data.teams.length,
|
||||||
open > 0
|
open > 0
|
||||||
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
? `${open} open ${open === 1 ? 'incident' : 'incidents'} between them.`
|
||||||
: 'Nothing open anywhere.'),
|
: 'Nothing open anywhere.'),
|
||||||
menuItem('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
menuCard('/admin/users', 'Users', data.users.length, `${people.join(', ')}.`),
|
||||||
menuItem('/admin/settings', 'Settings', null,
|
menuCard('/admin/settings', 'Settings', null,
|
||||||
'How the server behaves, and where it is plugged in.'),
|
'How the server behaves, and where it is plugged in.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function menuItem(href, label, count, note) {
|
|
||||||
return h('a', { class: 'card admin-menu-item', href },
|
|
||||||
h('div', { class: 'admin-menu-head' },
|
|
||||||
h('strong', { text: label }),
|
|
||||||
count != null && h('span', { class: 'admin-menu-count', text: String(count) })),
|
|
||||||
h('p', { class: 'muted small', text: note }),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- teams -----------------------------------------------------------------
|
// --- teams -----------------------------------------------------------------
|
||||||
|
|
||||||
function teamsCard() {
|
function teamsCard() {
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ const SECTIONS = {
|
|||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The mobile hamburger menu's contents — the same sections the desktop
|
||||||
|
// sidebar's .nav-link list carries in index.html, in the same order.
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ path: '/', section: 'queue', label: 'Queue', icon: 'queueList' },
|
||||||
|
{ path: '/oncall', section: 'oncall', label: 'On-call', icon: 'calendar' },
|
||||||
|
{ path: '/alerts', section: 'alerts', label: 'Alerts', icon: 'bell' },
|
||||||
|
{ path: '/team', section: 'team', label: 'Team', icon: 'team' },
|
||||||
|
{ path: '/admin', section: 'admin', label: 'Admin', icon: 'shield', adminOnly: true },
|
||||||
|
{ path: '/more', section: 'more', label: 'Account', icon: 'user' },
|
||||||
|
];
|
||||||
|
|
||||||
function parseRoute(pathname) {
|
function parseRoute(pathname) {
|
||||||
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
const m = pathname.match(/^\/incidents\/(\d+)\/?$/);
|
||||||
if (m) return { section: 'queue', incident: Number(m[1]) };
|
if (m) return { section: 'queue', incident: Number(m[1]) };
|
||||||
@@ -40,22 +51,26 @@ function parseRoute(pathname) {
|
|||||||
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
const g = pathname.match(/^\/admin\/teams\/(\d+)\/?$/);
|
||||||
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
if (g) return { section: 'adminteam', team: Number(g[1]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
// The Admin tab's sub-sections are routes of their own. admin.js owns the
|
// The Admin and Team tabs' sub-sections are routes of their own. Each view
|
||||||
// table of them, since it also builds the strip that links to them.
|
// owns the table of its own, since each also builds the strip that links to
|
||||||
|
// them; /team is in team.TABS as the overview, so it is matched here too.
|
||||||
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
const t = admin.TABS.find((x) => x.path === `/${name}`);
|
||||||
if (t) return { section: 'admin', tab: t.tab };
|
if (t) return { section: 'admin', tab: t.tab };
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'more') return { section: name };
|
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||||
|
if (tt) return { section: 'team', tab: tt.tab };
|
||||||
|
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the top bar and the document title call this route. Admin's sub-sections
|
// What the top bar and the document title call this route. The sub-sections of
|
||||||
// are pages in their own right, so they say which one rather than "Admin" four
|
// Admin and Team are pages in their own right, so they say which one rather
|
||||||
// times; the overview keeps the tab's own name.
|
// than the tab's name four or six times; either overview keeps the tab's own
|
||||||
|
// name. A tab may carry a `title` where its strip label is too short to name a
|
||||||
|
// page on its own.
|
||||||
function title(r) {
|
function title(r) {
|
||||||
const t = r.section === 'admin' && r.tab
|
const tabs = r.section === 'admin' ? admin.TABS : r.section === 'team' ? team.TABS : null;
|
||||||
? admin.TABS.find((x) => x.tab === r.tab)
|
const t = tabs && r.tab ? tabs.find((x) => x.tab === r.tab) : null;
|
||||||
: null;
|
return t ? (t.title || t.label) : SECTIONS[r.section].title;
|
||||||
return t ? t.label : SECTIONS[r.section].title;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let route = parseRoute(location.pathname);
|
let route = parseRoute(location.pathname);
|
||||||
@@ -128,6 +143,35 @@ function render() {
|
|||||||
updateTitle();
|
updateTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- nav menu ----------
|
||||||
|
|
||||||
|
// The mobile hamburger menu: same shape as the sheet-based action menus in
|
||||||
|
// incident.js (openSheet + a <ul class="menu"> of menu-item buttons), one
|
||||||
|
// item per NAV_ITEMS entry, resolving with a path for navigate() to use.
|
||||||
|
function openNavMenu() {
|
||||||
|
const current = SECTIONS[route.section].nav || route.section;
|
||||||
|
const triggered = state.open.filter((i) => i.status === 'triggered').length;
|
||||||
|
const items = NAV_ITEMS.filter((n) => !n.adminOnly || state.me?.user?.is_admin);
|
||||||
|
ui.openSheet(() => [
|
||||||
|
ui.h('h2', { class: 'sheet-title', text: 'Sections' }),
|
||||||
|
ui.h('ul', { class: 'menu', role: 'menu' }, items.map((n) =>
|
||||||
|
ui.h('li', {}, ui.h('button', {
|
||||||
|
class: 'menu-item',
|
||||||
|
type: 'button',
|
||||||
|
role: 'menuitemradio',
|
||||||
|
'aria-checked': String(n.section === current),
|
||||||
|
onclick: () => ui.closeSheet(n.path),
|
||||||
|
},
|
||||||
|
ui.icon(n.icon),
|
||||||
|
n.label,
|
||||||
|
n.section === 'queue' && triggered > 0 && ui.badge(String(triggered), 'st-triggered menu-sub'),
|
||||||
|
))),
|
||||||
|
),
|
||||||
|
]).then((path) => {
|
||||||
|
if (path) navigate(path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- refresh + badges ----------
|
// ---------- refresh + badges ----------
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
@@ -154,9 +198,12 @@ function updateBadges() {
|
|||||||
pill.classList.toggle('has-triggered', triggered > 0);
|
pill.classList.toggle('has-triggered', triggered > 0);
|
||||||
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
pill.classList.toggle('all-acked', open > 0 && triggered === 0);
|
||||||
|
|
||||||
const badge = document.querySelector('[data-badge]');
|
// Two badges carry this count: the sidebar's Queue tab (desktop) and the
|
||||||
badge.hidden = triggered === 0;
|
// hamburger button (mobile) — only one of the two is ever visible at once.
|
||||||
badge.textContent = String(triggered);
|
for (const badge of document.querySelectorAll('[data-badge]')) {
|
||||||
|
badge.hidden = triggered === 0;
|
||||||
|
badge.textContent = String(triggered);
|
||||||
|
}
|
||||||
updateTitle();
|
updateTitle();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +223,7 @@ async function boot() {
|
|||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
$('login-form').addEventListener('submit', onLogin);
|
$('login-form').addEventListener('submit', onLogin);
|
||||||
$('signup-form').addEventListener('submit', onSignup);
|
$('signup-form').addEventListener('submit', onSignup);
|
||||||
|
$('menu-btn').addEventListener('click', openNavMenu);
|
||||||
|
|
||||||
// /signup is the one route that works without a session.
|
// /signup is the one route that works without a session.
|
||||||
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
if (location.pathname.replace(/\/$/, '') === '/signup') {
|
||||||
|
|||||||
+151
-27
@@ -1,8 +1,16 @@
|
|||||||
// Team settings: the rota, who is in the team, where its alerts come from,
|
// One team: the rota, who is in it, where its alerts come from, what it
|
||||||
// what it escalates through, and which of its alerts are heartbeats.
|
// escalates through, and which of its alerts are heartbeats.
|
||||||
//
|
//
|
||||||
// Everything here was API-only until now, which meant a team owner had to use
|
// Everything here was API-only until v0.12.0, which meant a team owner had to
|
||||||
// curl to set up escalation — the feature this whole line of work exists for.
|
// use curl to set up escalation — the feature this whole line of work exists
|
||||||
|
// for.
|
||||||
|
//
|
||||||
|
// Each of those five is a route of its own behind a strip across the top, with
|
||||||
|
// /team an overview, the way 07914d5 split the Admin tab. The same reasons
|
||||||
|
// applied here and more sharply: five cards on one page meant no way to link
|
||||||
|
// somebody to the escalation ladder, no way to the switches but past a month
|
||||||
|
// of rota, and a poll that refetched six endpoints however little of the page
|
||||||
|
// you were looking at.
|
||||||
//
|
//
|
||||||
// The server decides what a role may do: an owner's edits succeed, a member's
|
// The server decides what a role may do: an owner's edits succeed, a member's
|
||||||
// are refused with 403, and a non-member gets 404 for the lot. This view hides
|
// are refused with 403, and a non-member gets 404 for the lot. This view hides
|
||||||
@@ -10,19 +18,48 @@
|
|||||||
// 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 } from './ui.js';
|
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard } from './ui.js';
|
||||||
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
import { state, currentTeam, users as allUsers, myID } from './state.js';
|
||||||
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
import { isoDate, addDays, mondayOf, initial } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
const view = () => document.getElementById('view-team');
|
||||||
|
|
||||||
|
// The sub-sections, in the order the strip shows them. The overview is /team
|
||||||
|
// itself, so it has no tab of its own. This table is the only place the six
|
||||||
|
// routes are written down: app.js parses against it and the strip is built
|
||||||
|
// from it, the same contract admin.js has.
|
||||||
|
//
|
||||||
|
// `label` is what the strip says and `title` what the top bar and the document
|
||||||
|
// title say, where a strip label alone would be too thin to name a page —
|
||||||
|
// "Sources · terdut" in a browser tab does not say sources of what.
|
||||||
|
export const TABS = [
|
||||||
|
{ tab: null, path: '/team', label: 'Overview' },
|
||||||
|
{ tab: 'rota', path: '/team/rota', label: 'Rota', title: 'On-call rota' },
|
||||||
|
{ tab: 'members', path: '/team/members', label: 'Members' },
|
||||||
|
{ tab: 'escalation', path: '/team/escalation', label: 'Escalation' },
|
||||||
|
{ tab: 'sources', path: '/team/sources', label: 'Sources', title: 'Alert sources' },
|
||||||
|
{ tab: 'deadman', path: '/team/deadman', label: 'Switches', title: 'Dead man’s switches' },
|
||||||
|
];
|
||||||
|
|
||||||
let teamID = null;
|
let teamID = null;
|
||||||
let data = null; // { team, members, integrations, escalation, deadman, schedule, users }
|
// Which sub-section is open. Remembered rather than passed, because the poll
|
||||||
|
// loop calls refresh() with no route.
|
||||||
|
let tab = null;
|
||||||
|
let data = null; // { team, ... }; which fields are present varies by tab
|
||||||
let error = null;
|
let error = null;
|
||||||
let freshKey = null; // an integration key, shown once, until the view is left
|
let freshKey = null; // an integration key, shown once, until the view is left
|
||||||
|
|
||||||
export function show() {
|
export function show(route) {
|
||||||
if (!data) clear(view(), spinner());
|
const next = route?.tab ?? null;
|
||||||
|
// A different sub-section wants different data, so the old answer goes
|
||||||
|
// rather than being shown under the new heading until the fetch lands. The
|
||||||
|
// ladder draft goes with it: it is an edit of the page being left.
|
||||||
|
if (next !== tab) {
|
||||||
|
tab = next;
|
||||||
|
data = null;
|
||||||
|
draft = null;
|
||||||
|
}
|
||||||
|
if (!data) clear(view(), subnav(), spinner());
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,18 +76,8 @@ export async function refresh() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
teamID = team.id;
|
teamID = team.id;
|
||||||
const grid = gridDays();
|
|
||||||
try {
|
try {
|
||||||
// A member may read all of this; only the writes are owner-only.
|
data = { team, ...(await load(team.id)) };
|
||||||
const [members, integrations, escalation, deadman, schedule, users] = await Promise.all([
|
|
||||||
api.teamMembers(team.id),
|
|
||||||
api.integrations(team.id),
|
|
||||||
api.escalation(team.id),
|
|
||||||
api.deadman(team.id),
|
|
||||||
api.schedule(team.id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
|
||||||
allUsers(),
|
|
||||||
]);
|
|
||||||
data = { team, members, integrations, escalation, deadman, schedule, users };
|
|
||||||
error = null;
|
error = null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err.message;
|
error = err.message;
|
||||||
@@ -58,6 +85,45 @@ export async function refresh() {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only what the open sub-section shows. A member may read all of it; only the
|
||||||
|
// writes are owner-only.
|
||||||
|
//
|
||||||
|
// Three of the five need the member list besides their own endpoint, and for
|
||||||
|
// the same reason each time: a rota, a ladder target and a role are all a
|
||||||
|
// person, and the page has to be able to name them. The overview is the one
|
||||||
|
// that fetches everything, because saying how much of each there is means
|
||||||
|
// asking each of them.
|
||||||
|
async function load(id) {
|
||||||
|
if (tab === 'rota') {
|
||||||
|
const grid = gridDays();
|
||||||
|
const [members, schedule] = await Promise.all([
|
||||||
|
api.teamMembers(id),
|
||||||
|
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||||
|
]);
|
||||||
|
return { members, schedule };
|
||||||
|
}
|
||||||
|
if (tab === 'members') {
|
||||||
|
const [members, users] = await Promise.all([api.teamMembers(id), allUsers()]);
|
||||||
|
return { members, users };
|
||||||
|
}
|
||||||
|
if (tab === 'escalation') {
|
||||||
|
const [members, escalation] = await Promise.all([api.teamMembers(id), api.escalation(id)]);
|
||||||
|
return { members, escalation };
|
||||||
|
}
|
||||||
|
if (tab === 'sources') return { integrations: await api.integrations(id) };
|
||||||
|
if (tab === 'deadman') return { deadman: await api.deadman(id) };
|
||||||
|
|
||||||
|
const grid = gridDays();
|
||||||
|
const [members, integrations, escalation, deadman, schedule] = await Promise.all([
|
||||||
|
api.teamMembers(id),
|
||||||
|
api.integrations(id),
|
||||||
|
api.escalation(id),
|
||||||
|
api.deadman(id),
|
||||||
|
api.schedule(id, isoDate(grid.start), isoDate(addDays(grid.start, grid.count - 1))),
|
||||||
|
]);
|
||||||
|
return { members, integrations, escalation, deadman, schedule };
|
||||||
|
}
|
||||||
|
|
||||||
function isOwner() {
|
function isOwner() {
|
||||||
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||||||
}
|
}
|
||||||
@@ -70,19 +136,43 @@ function render() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clear(view(),
|
clear(view(),
|
||||||
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
subnav(),
|
||||||
teamPicker(),
|
teamPicker(),
|
||||||
!isOwner() && h('div', { class: 'card' },
|
// Said once on the overview rather than on all six pages: it explains why
|
||||||
|
// the controls further down are missing, and a page of nothing but the
|
||||||
|
// rota has no controls to explain.
|
||||||
|
!isOwner() && tab === null && h('div', { class: 'card' },
|
||||||
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||||||
scheduleCard(),
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
escalationCard(),
|
section(),
|
||||||
integrationsCard(),
|
|
||||||
deadmanCard(),
|
|
||||||
membersCard(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function section() {
|
||||||
|
if (tab === 'rota') return scheduleCard();
|
||||||
|
if (tab === 'members') return membersCard();
|
||||||
|
if (tab === 'escalation') return escalationCard();
|
||||||
|
if (tab === 'sources') return integrationsCard();
|
||||||
|
if (tab === 'deadman') return deadmanCard();
|
||||||
|
return overview();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strip across the top of every team page. Ordinary links rather than
|
||||||
|
// buttons, because these are six URLs: app.js intercepts the click, the
|
||||||
|
// browser's Back walks them, and a reload lands where you were.
|
||||||
|
function subnav() {
|
||||||
|
return h('nav', { class: 'subnav', 'aria-label': 'Team' },
|
||||||
|
TABS.map((t) => h('a', {
|
||||||
|
class: 'subnav-link',
|
||||||
|
href: t.path,
|
||||||
|
text: t.label,
|
||||||
|
'aria-current': t.tab === tab ? 'page' : null,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
// Only shown to somebody in more than one team, like the queue's filter chips.
|
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||||||
|
// It is above the sections rather than inside one because it changes the
|
||||||
|
// subject of all six.
|
||||||
function teamPicker() {
|
function teamPicker() {
|
||||||
if ((state.teams || []).length < 2) {
|
if ((state.teams || []).length < 2) {
|
||||||
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||||
@@ -95,11 +185,45 @@ function teamPicker() {
|
|||||||
teamID = Number(select.value);
|
teamID = Number(select.value);
|
||||||
data = null;
|
data = null;
|
||||||
freshKey = null;
|
freshKey = null;
|
||||||
show();
|
draft = null;
|
||||||
|
refresh();
|
||||||
});
|
});
|
||||||
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- overview --------------------------------------------------------------
|
||||||
|
|
||||||
|
// /team itself. The strip already links to the five, so this earns its place
|
||||||
|
// the way /admin's does: by saying how much of each there is, which is the one
|
||||||
|
// thing a menu cannot.
|
||||||
|
function overview() {
|
||||||
|
const today = isoDate(new Date());
|
||||||
|
const onToday = (data.schedule || []).find((e) => e.date === today);
|
||||||
|
const owners = (data.members || []).filter((m) => m.role === 'owner').length;
|
||||||
|
const levels = (data.escalation?.levels || []).length;
|
||||||
|
const keys = (data.integrations || []).length;
|
||||||
|
const unused = (data.integrations || []).filter((i) => !i.last_used_at).length;
|
||||||
|
const switches = (data.deadman?.matchers || '')
|
||||||
|
.split(';').map((x) => x.trim()).filter(Boolean).length;
|
||||||
|
|
||||||
|
return h('div', { class: 'overview-menu' },
|
||||||
|
menuCard('/team/rota', 'Rota', null,
|
||||||
|
onToday ? `${onToday.username} is on call today.` : 'Nobody is on call today.'),
|
||||||
|
menuCard('/team/members', 'Members', (data.members || []).length,
|
||||||
|
owners === 1 ? 'One owner.' : `${owners} owners.`),
|
||||||
|
menuCard('/team/escalation', 'Escalation', levels || null,
|
||||||
|
levels
|
||||||
|
? `${levels === 1 ? 'One level' : `${levels} levels`}${data.escalation.fallback_topic ? ', then a fallback topic.' : '.'}`
|
||||||
|
: 'No ladder — nobody but the first person is woken.'),
|
||||||
|
menuCard('/team/sources', 'Alert sources', keys || null,
|
||||||
|
keys
|
||||||
|
? (unused ? `${unused} of them never used.` : 'All in use.')
|
||||||
|
: 'No key yet, so nothing can reach this team.'),
|
||||||
|
menuCard('/team/deadman', 'Dead man’s switches', switches || null,
|
||||||
|
switches ? 'Alerts whose absence opens an incident.' : 'Nothing watched.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// --- schedule --------------------------------------------------------------
|
// --- schedule --------------------------------------------------------------
|
||||||
|
|
||||||
// The rota is one person per UTC day. The on-call page shows it; this is where
|
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export function clear(el, ...children) {
|
|||||||
const ICONS = {
|
const ICONS = {
|
||||||
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'],
|
||||||
|
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||||||
|
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||||||
|
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||||||
check: ['M5 12.5l4.5 4.5L19 7'],
|
check: ['M5 12.5l4.5 4.5L19 7'],
|
||||||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||||||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||||||
@@ -64,6 +68,14 @@ export function icon(name, cls = 'icon') {
|
|||||||
node.setAttribute('cx', cx);
|
node.setAttribute('cx', cx);
|
||||||
node.setAttribute('cy', cy);
|
node.setAttribute('cy', cy);
|
||||||
node.setAttribute('r', r);
|
node.setAttribute('r', r);
|
||||||
|
} else if (d.startsWith('rect:')) {
|
||||||
|
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||||||
|
node = document.createElementNS(SVG, 'rect');
|
||||||
|
node.setAttribute('x', x);
|
||||||
|
node.setAttribute('y', y);
|
||||||
|
node.setAttribute('width', w);
|
||||||
|
node.setAttribute('height', hgt);
|
||||||
|
if (rx) node.setAttribute('rx', rx);
|
||||||
} else {
|
} else {
|
||||||
node = document.createElementNS(SVG, 'path');
|
node = document.createElementNS(SVG, 'path');
|
||||||
node.setAttribute('d', d);
|
node.setAttribute('d', d);
|
||||||
@@ -160,6 +172,18 @@ export function labelChip(k, v) {
|
|||||||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One entry in a section's overview: a card that is a link, carrying the count
|
||||||
|
// only that section can state. Both the Admin tab and the Team tab open on one
|
||||||
|
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||||||
|
export function menuCard(href, label, count, note) {
|
||||||
|
return h('a', { class: 'card overview-item', href },
|
||||||
|
h('div', { class: 'overview-head' },
|
||||||
|
h('strong', { text: label }),
|
||||||
|
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||||||
|
h('p', { class: 'muted small', text: note }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function emptyState(title, text, iconName) {
|
export function emptyState(title, text, iconName) {
|
||||||
return h('div', { class: 'empty' },
|
return h('div', { class: 'empty' },
|
||||||
iconName && icon(iconName),
|
iconName && icon(iconName),
|
||||||
|
|||||||
Reference in New Issue
Block a user