Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 734cd9c5fd | |||
| 423ed9b3a3 | |||
| 43f004499b | |||
| 559be6de6e | |||
| e77f04b55e | |||
| e536fdd2c0 |
@@ -48,11 +48,12 @@ 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. The Stats page shows
|
||||||
[terdut-tui](https://github.com/yeniklas/terdut-tui) for now.
|
incident counts, MTTA and MTTR, and alert frequency by name, hour and day over a
|
||||||
|
chosen range.
|
||||||
|
|
||||||
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
|
||||||
set, and a user without one can only use API keys:
|
set, and a user without one can only use API keys:
|
||||||
|
|||||||
@@ -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.19.0
|
version: 0.21.0
|
||||||
appVersion: "v0.19.0"
|
appVersion: "v0.21.0"
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+53
-21
@@ -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 {
|
||||||
@@ -872,3 +867,40 @@ button.rota-day:hover { background: var(--surface-2); }
|
|||||||
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
.overview-head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
.overview-count { margin-left: auto; color: var(--muted); font-size: 18px; font-weight: 700; }
|
||||||
.overview-item p { margin: 4px 0 0; }
|
.overview-item p { margin: 4px 0 0; }
|
||||||
|
|
||||||
|
/* ---------- stats page ---------- */
|
||||||
|
|
||||||
|
#view-stats .chips { padding-left: 0; padding-right: 0; }
|
||||||
|
.stats { display: grid; gap: 14px; padding-bottom: 16px; }
|
||||||
|
.stat-tiles { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.stat-tile {
|
||||||
|
--sev: var(--border-strong);
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--sev); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 12px;
|
||||||
|
}
|
||||||
|
.stat-tile.st-triggered { --sev: var(--crit); }
|
||||||
|
.stat-tile.st-acknowledged { --sev: var(--warn); }
|
||||||
|
.stat-tile.st-resolved { --sev: var(--ok); }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||||
|
.stat-label { margin-top: 2px; font-size: 12px; color: var(--muted); }
|
||||||
|
.chart-card + .chart-card { margin-top: 0; }
|
||||||
|
.chart-title {
|
||||||
|
margin-bottom: 10px; font-size: 13px; font-weight: 700;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted);
|
||||||
|
}
|
||||||
|
.hbars { list-style: none; display: grid; gap: 6px; }
|
||||||
|
.hbar { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 13px; }
|
||||||
|
.hbar-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.hbar-track { height: 10px; border-radius: 5px; background: var(--surface-2); overflow: hidden; }
|
||||||
|
.hbar-fill { display: block; height: 100%; border-radius: 5px; background: var(--ok); }
|
||||||
|
.hbar-count { min-width: 2ch; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.columns { display: block; width: 100%; height: auto; }
|
||||||
|
.columns .axis { stroke: var(--border-strong); stroke-width: 1; }
|
||||||
|
.columns .col-hit { fill: transparent; }
|
||||||
|
.columns .col-bar { fill: var(--accent); }
|
||||||
|
.columns .col:hover .col-bar { opacity: 0.75; }
|
||||||
|
.columns .col-label { fill: var(--faint); font-size: 10px; font-family: var(--font); }
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.stat-tiles { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,10 @@
|
|||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z"/><path d="M10 20.5a2 2 0 0 0 4 0"/></svg>
|
||||||
<span class="nav-label">Alerts</span>
|
<span class="nav-label">Alerts</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-link" href="/stats" data-section="stats" aria-label="Stats">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h16M7 20v-7M12 20V6M17 20v-10"/></svg>
|
||||||
|
<span class="nav-label">Stats</span>
|
||||||
|
</a>
|
||||||
<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>
|
||||||
@@ -108,7 +112,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>
|
||||||
|
|
||||||
@@ -122,6 +132,7 @@
|
|||||||
|
|
||||||
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
<section id="view-oncall" class="view view-page" data-view="oncall" hidden></section>
|
||||||
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
<section id="view-alerts" class="view view-page" data-view="alerts" hidden></section>
|
||||||
|
<section id="view-stats" class="view view-page" data-view="stats" hidden></section>
|
||||||
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
<section id="view-team" class="view view-page" data-view="team" hidden></section>
|
||||||
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
<section id="view-admin" class="view view-page" data-view="admin" hidden></section>
|
||||||
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
<!-- One person, at /admin/users/{id}: reached from the Admin tab's user
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ function render() {
|
|||||||
|
|
||||||
h('div', { class: 'page-head' }),
|
h('div', { class: 'page-head' }),
|
||||||
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
h('button', { class: 'btn btn-block', type: 'button', onclick: signOut }, icon('logout'), 'Sign out'),
|
||||||
h('p', { class: 'foot-note', text: 'Statistics are in terdut-tui for now.' }),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export const unarchive = (id) => call('DELETE', `/incidents/${id}/archive`);
|
|||||||
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
export const addNote = (id, content) => call('POST', `/incidents/${id}/notes`, { body: { content } });
|
||||||
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
export const deleteNote = (id, eventID) => call('DELETE', `/incidents/${id}/notes/${eventID}`);
|
||||||
|
|
||||||
|
// stats
|
||||||
|
export const statsIncidents = (query) => call('GET', '/stats/incidents', { query });
|
||||||
|
export const statsTop = (query) => call('GET', '/stats/alerts/top', { query });
|
||||||
|
export const statsByHour = (query) => call('GET', '/stats/alerts/by-hour', { query });
|
||||||
|
export const statsByDay = (query) => call('GET', '/stats/alerts/by-day', { query });
|
||||||
|
|
||||||
// alerts
|
// alerts
|
||||||
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
export const alerts = (query, opts) => call('GET', '/alerts', { query, ...opts });
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import * as queue from './queue.js';
|
|||||||
import * as incident from './incident.js';
|
import * as incident from './incident.js';
|
||||||
import * as oncall from './oncall.js';
|
import * as oncall from './oncall.js';
|
||||||
import * as alerts from './alerts.js';
|
import * as alerts from './alerts.js';
|
||||||
|
import * as stats from './stats.js';
|
||||||
import * as account from './account.js';
|
import * as account from './account.js';
|
||||||
import * as team from './team.js';
|
import * as team from './team.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
@@ -23,6 +24,7 @@ const SECTIONS = {
|
|||||||
queue: { title: 'Queue', view: queue },
|
queue: { title: 'Queue', view: queue },
|
||||||
oncall: { title: 'On-call', view: oncall },
|
oncall: { title: 'On-call', view: oncall },
|
||||||
alerts: { title: 'Alerts', view: alerts },
|
alerts: { title: 'Alerts', view: alerts },
|
||||||
|
stats: { title: 'Stats', view: stats },
|
||||||
team: { title: 'Team', view: team },
|
team: { title: 'Team', view: team },
|
||||||
admin: { title: 'Admin', view: admin },
|
admin: { title: 'Admin', view: admin },
|
||||||
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
adminuser: { title: 'User', view: adminuser, nav: 'admin' },
|
||||||
@@ -30,6 +32,18 @@ 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: '/stats', section: 'stats', label: 'Stats', icon: 'chart' },
|
||||||
|
{ 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]) };
|
||||||
@@ -47,7 +61,7 @@ function parseRoute(pathname) {
|
|||||||
if (t) return { section: 'admin', tab: t.tab };
|
if (t) return { section: 'admin', tab: t.tab };
|
||||||
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
const tt = team.TABS.find((x) => x.path === `/${name}`);
|
||||||
if (tt) return { section: 'team', tab: tt.tab };
|
if (tt) return { section: 'team', tab: tt.tab };
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'stats' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +146,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() {
|
||||||
@@ -158,9 +201,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +226,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') {
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Statistics: how many incidents, how fast they are answered, and when and
|
||||||
|
// what the alerts are. The same figures the TUI's Stats tab shows, over a
|
||||||
|
// range picked with the chips. The server scopes them to the caller's teams.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, emptyState, spinner } from './ui.js';
|
||||||
|
import { duration } from './format.js';
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// `days` counts back from today, inclusive; the server reads from/to as UTC
|
||||||
|
// dates, so these are too.
|
||||||
|
const RANGES = [
|
||||||
|
{ id: 'today', label: 'Today', days: 1 },
|
||||||
|
{ id: '7d', label: '7d', days: 7 },
|
||||||
|
{ id: '30d', label: '30d', days: 30 },
|
||||||
|
{ id: '90d', label: '90d', days: 90 },
|
||||||
|
{ id: 'all', label: 'All', days: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-stats');
|
||||||
|
|
||||||
|
let range = '30d';
|
||||||
|
let data = null;
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
function query(r) {
|
||||||
|
if (!r.days) return {};
|
||||||
|
const now = Date.now();
|
||||||
|
return { from: utcDate(now - (r.days - 1) * DAY_MS), to: utcDate(now) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
render();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const requested = range;
|
||||||
|
const q = query(RANGES.find((x) => x.id === range));
|
||||||
|
try {
|
||||||
|
const [incidents, top, byHour, byDay] = await Promise.all([
|
||||||
|
api.statsIncidents(q),
|
||||||
|
api.statsTop({ ...q, limit: 10 }),
|
||||||
|
api.statsByHour(q),
|
||||||
|
api.statsByDay(q),
|
||||||
|
]);
|
||||||
|
if (requested !== range) return;
|
||||||
|
data = { incidents, top, byHour, byDay };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
if (requested !== range) return;
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRange(id) {
|
||||||
|
if (id === range) return;
|
||||||
|
range = id;
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Time range' },
|
||||||
|
RANGES.map((r) => h('button', {
|
||||||
|
class: 'chip',
|
||||||
|
type: 'button',
|
||||||
|
role: 'tab',
|
||||||
|
'aria-selected': String(r.id === range),
|
||||||
|
onclick: () => setRange(r.id),
|
||||||
|
text: r.label,
|
||||||
|
})));
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if (error && !data) body = h('div', { class: 'load-error', text: error });
|
||||||
|
else if (!data) body = spinner();
|
||||||
|
else if (!data.incidents.total && !data.byHour.some((x) => x.count)) {
|
||||||
|
body = emptyState('No data in this range', '', 'chart');
|
||||||
|
} else {
|
||||||
|
body = h('div', { class: 'stats' },
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
tiles(data.incidents),
|
||||||
|
data.top.length > 0 && card('Top alerts', topAlerts(data.top)),
|
||||||
|
card('Alerts by hour (UTC)', columns(
|
||||||
|
data.byHour.map((x) => ({ label: String(x.hour), value: x.count, tick: x.hour % 6 === 0 })),
|
||||||
|
'Alerts by hour of day')),
|
||||||
|
card('Alerts by day', columns(
|
||||||
|
data.byDay.map((x) => ({ label: x.day_name.slice(0, 3), value: x.count, tick: true })),
|
||||||
|
'Alerts by day of week')));
|
||||||
|
}
|
||||||
|
clear(view(), h('div', {}, chips, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing mean means nothing has been acknowledged or resolved yet.
|
||||||
|
const mean = (s) => (s == null ? '—' : duration(s * 1000));
|
||||||
|
|
||||||
|
function tiles(s) {
|
||||||
|
const tile = (label, value, cls = '') => h('div', { class: `stat-tile ${cls}` },
|
||||||
|
h('div', { class: 'stat-value', text: String(value) }),
|
||||||
|
h('div', { class: 'stat-label', text: label }));
|
||||||
|
return h('div', { class: 'stat-tiles' },
|
||||||
|
tile('Incidents', s.total),
|
||||||
|
tile('Triggered', s.triggered, 'st-triggered'),
|
||||||
|
tile('Acknowledged', s.acknowledged, 'st-acknowledged'),
|
||||||
|
tile('Resolved', s.resolved, 'st-resolved'),
|
||||||
|
tile('Mean time to acknowledge', mean(s.mtta_seconds)),
|
||||||
|
tile('Mean time to resolve', mean(s.mttr_seconds)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(title, content) {
|
||||||
|
return h('section', { class: 'chart-card card card-pad' },
|
||||||
|
h('h3', { class: 'chart-title', text: title }), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranked names with a bar scaled to the busiest one.
|
||||||
|
function topAlerts(items) {
|
||||||
|
const max = Math.max(...items.map((x) => x.count), 1);
|
||||||
|
return h('ol', { class: 'hbars' }, items.map((x) => {
|
||||||
|
const fill = h('span', { class: 'hbar-fill' });
|
||||||
|
fill.style.width = `${Math.max(2, (x.count / max) * 100)}%`;
|
||||||
|
return h('li', { class: 'hbar' },
|
||||||
|
h('span', { class: 'hbar-name', title: x.name, text: x.name }),
|
||||||
|
h('span', { class: 'hbar-track' }, fill),
|
||||||
|
h('span', { class: 'hbar-count', text: String(x.count) }));
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function svg(tag, attrs = {}, text) {
|
||||||
|
const el = document.createElementNS(SVG_NS, tag);
|
||||||
|
for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
|
||||||
|
if (text != null) el.textContent = text;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A column chart: one bar per item, the value in a tooltip, and a label under
|
||||||
|
// the items marked `tick`.
|
||||||
|
function columns(items, label) {
|
||||||
|
const W = 480;
|
||||||
|
const H = 140;
|
||||||
|
const base = H - 18;
|
||||||
|
const step = W / items.length;
|
||||||
|
const max = Math.max(...items.map((x) => x.value), 1);
|
||||||
|
const root = svg('svg', {
|
||||||
|
class: 'columns', viewBox: `0 0 ${W} ${H}`, role: 'img', 'aria-label': label,
|
||||||
|
});
|
||||||
|
root.appendChild(svg('line', { class: 'axis', x1: 0, x2: W, y1: base, y2: base }));
|
||||||
|
items.forEach((it, i) => {
|
||||||
|
const bh = it.value ? Math.max(2, (it.value / max) * (base - 6)) : 0;
|
||||||
|
const x = i * step + step * 0.15;
|
||||||
|
const g = svg('g', { class: 'col' });
|
||||||
|
g.appendChild(svg('title', {}, `${it.label}: ${it.value}`));
|
||||||
|
// A full-height transparent hit area, so a tiny bar is still hoverable.
|
||||||
|
g.appendChild(svg('rect', { class: 'col-hit', x: i * step, y: 0, width: step, height: base }));
|
||||||
|
if (bh) g.appendChild(svg('rect', { class: 'col-bar', x, y: base - bh, width: step * 0.7, height: bh, rx: 2 }));
|
||||||
|
root.appendChild(g);
|
||||||
|
if (it.tick) {
|
||||||
|
root.appendChild(svg('text', { class: 'col-label', x: i * step + step / 2, y: H - 4, 'text-anchor': 'middle' }, it.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
@@ -34,11 +34,16 @@ 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'],
|
||||||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||||||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||||||
|
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
|
||||||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||||||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||||||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||||||
@@ -64,6 +69,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);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatsPageIsEmbedded(t *testing.T) {
|
||||||
|
sub, err := fs.Sub(files, "static")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
index, err := fs.ReadFile(sub, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{`id="view-stats"`, `data-section="stats"`} {
|
||||||
|
if !strings.Contains(string(index), want) {
|
||||||
|
t.Errorf("index.html lacks %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fs.Stat(sub, "js/stats.js"); err != nil {
|
||||||
|
t.Errorf("js/stats.js not embedded: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user