Merge pull request 'Put a team's own settings in the web UI' (#18) from team-settings-ui into main
Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -85,6 +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
|
||||||
|
ladder, the alert sources with their keys, the dead man's switches and the
|
||||||
|
membership. An owner edits it; a member sees the same page read-only, because
|
||||||
|
the server refuses their writes anyway. Somebody in more than one team picks
|
||||||
|
between them at the top.
|
||||||
|
|
||||||
|
The **Admin** tab appears only for a system administrator, and holds what
|
||||||
|
belongs to the whole server rather than to one team: every team, every user, and
|
||||||
|
the settings that used to be environment variables.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -52,6 +52,13 @@ type querier interface {
|
|||||||
|
|
||||||
const incidentSelectFrom = `
|
const incidentSelectFrom = `
|
||||||
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
SELECT i.id, i.team_id, t.name, i.group_key, i.title, i.group_labels, i.status, i.severity,
|
||||||
|
i.escalation_level,
|
||||||
|
-- When this level runs out. Computed here rather than in Go because
|
||||||
|
-- the timeout lives beside the level in the policy, and one join is
|
||||||
|
-- cheaper than a second query per incident in a list.
|
||||||
|
(SELECT i.escalation_level_at + el.timeout_seconds
|
||||||
|
FROM escalation_levels el
|
||||||
|
WHERE el.team_id = i.team_id AND el.position = i.escalation_level),
|
||||||
i.triggered_at,
|
i.triggered_at,
|
||||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||||
i.assigned_to, asg.username, i.snoozed_until,
|
i.assigned_to, asg.username, i.snoozed_until,
|
||||||
@@ -65,10 +72,11 @@ func scanIncident(s scanner) (models.Incident, error) {
|
|||||||
var i models.Incident
|
var i models.Incident
|
||||||
var groupLabelsJSON string
|
var groupLabelsJSON string
|
||||||
var triggeredAt int64
|
var triggeredAt int64
|
||||||
var ackAt, snoozedUntil, resolvedAt, archivedAt *int64
|
var ackAt, snoozedUntil, resolvedAt, archivedAt, escalationDue *int64
|
||||||
|
|
||||||
if err := s.Scan(
|
if err := s.Scan(
|
||||||
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
&i.ID, &i.TeamID, &i.TeamName, &i.GroupKey, &i.Title, &groupLabelsJSON, &i.Status, &i.Severity,
|
||||||
|
&i.EscalationLevel, &escalationDue,
|
||||||
&triggeredAt,
|
&triggeredAt,
|
||||||
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
&i.AcknowledgedByID, &ackAt, &i.AcknowledgedByUser,
|
||||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||||
@@ -83,6 +91,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
|||||||
i.SnoozedUntil = unixPtr(snoozedUntil)
|
i.SnoozedUntil = unixPtr(snoozedUntil)
|
||||||
i.ResolvedAt = unixPtr(resolvedAt)
|
i.ResolvedAt = unixPtr(resolvedAt)
|
||||||
i.ArchivedAt = unixPtr(archivedAt)
|
i.ArchivedAt = unixPtr(archivedAt)
|
||||||
|
i.EscalationDueAt = unixPtr(escalationDue)
|
||||||
return i, nil
|
return i, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ import "time"
|
|||||||
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
// the webhook and the sweeper may flip to "resolved" once every member alert has
|
||||||
// stopped firing.
|
// stopped firing.
|
||||||
type Incident struct {
|
type Incident struct {
|
||||||
|
// EscalationLevel is which rung of its team's ladder this incident is on,
|
||||||
|
// 0 for none — either the team has no ladder, or somebody has answered.
|
||||||
|
// EscalationDueAt is when the current level runs out, so a client can say
|
||||||
|
// how long is left rather than only what already happened.
|
||||||
|
EscalationLevel int64 `json:"escalation_level"`
|
||||||
|
EscalationDueAt *time.Time `json:"escalation_due_at,omitempty"`
|
||||||
|
|
||||||
// TeamID is the team that owns this incident, fixed when it opens: an
|
// TeamID is the team that owns this incident, fixed when it opens: an
|
||||||
// incident never moves between teams. TeamName rides along so the combined
|
// incident never moves between teams. TeamName rides along so the combined
|
||||||
// queue can badge each row without a second request.
|
// queue can badge each row without a second request.
|
||||||
|
|||||||
@@ -657,3 +657,33 @@ kbd {
|
|||||||
.admin-settings .setting-unit { max-width: 8em; }
|
.admin-settings .setting-unit { max-width: 8em; }
|
||||||
.admin-settings button[type="submit"] { margin-top: 12px; }
|
.admin-settings button[type="submit"] { margin-top: 12px; }
|
||||||
.small { font-size: 13px; }
|
.small { font-size: 13px; }
|
||||||
|
|
||||||
|
/* --- team settings -------------------------------------------------------
|
||||||
|
Forms with a label above each control, rather than the queue's rows of
|
||||||
|
links. The escalation ladder is the only nested structure in the app, so it
|
||||||
|
gets a little indentation to make the levels read as an order. */
|
||||||
|
.stacked-form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; align-items: flex-start; }
|
||||||
|
.stacked-form label { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.stacked-form label.checkbox { gap: 8px; }
|
||||||
|
.stacked-form input.wide { min-width: min(420px, 100%); }
|
||||||
|
.team-picker { margin-top: 8px; max-width: 100%; }
|
||||||
|
|
||||||
|
.ladder-level {
|
||||||
|
border-left: 3px solid var(--border-strong);
|
||||||
|
padding: 8px 0 8px 12px; margin: 12px 0;
|
||||||
|
}
|
||||||
|
.ladder-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
||||||
|
.ladder-targets { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
||||||
|
.target-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* An integration key is shown exactly once, so it should look like something
|
||||||
|
to act on rather than another row of text. */
|
||||||
|
.key-panel {
|
||||||
|
margin-top: 12px; padding: 12px;
|
||||||
|
border: 1px solid var(--accent); border-radius: 8px; background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.key-panel pre {
|
||||||
|
overflow-x: auto; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; padding: 8px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.key-url code { word-break: break-all; }
|
||||||
|
|||||||
@@ -59,6 +59,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="/team" data-section="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>
|
||||||
|
<span class="nav-label">Team</span>
|
||||||
|
</a>
|
||||||
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
<!-- Hidden unless the signed-in user is a system administrator; app.js
|
||||||
unhides it once /api/me says so. The server refuses every admin
|
unhides it once /api/me says so. The server refuses every admin
|
||||||
endpoint regardless, so this is a courtesy and not a gate. -->
|
endpoint regardless, so this is a courtesy and not a gate. -->
|
||||||
@@ -87,6 +91,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-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>
|
||||||
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
<section id="view-more" class="view view-page" data-view="more" hidden></section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,6 +92,30 @@ export const createTeam = (name) => call('POST', '/teams', { body: { name } });
|
|||||||
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
export const renameTeam = (id, name) => call('PUT', `/teams/${id}`, { body: { name } });
|
||||||
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
|
export const deleteTeam = (id) => call('DELETE', `/teams/${id}`);
|
||||||
|
|
||||||
|
// A team's own settings. Every write is owner-only and every read is
|
||||||
|
// member-only; the server answers 403 and 404 respectively, so the UI shows
|
||||||
|
// what the role allows rather than guarding it.
|
||||||
|
export const teamMembers = (id) => call('GET', `/teams/${id}/members`);
|
||||||
|
export const addTeamMember = (id, userID, role) =>
|
||||||
|
call('POST', `/teams/${id}/members`, { body: { user_id: userID, role } });
|
||||||
|
export const removeTeamMember = (id, userID) => call('DELETE', `/teams/${id}/members/${userID}`);
|
||||||
|
|
||||||
|
export const integrations = (id) => call('GET', `/teams/${id}/integrations`);
|
||||||
|
export const createIntegration = (id, name) =>
|
||||||
|
call('POST', `/teams/${id}/integrations`, { body: { name } });
|
||||||
|
export const deleteIntegration = (id, integrationID) =>
|
||||||
|
call('DELETE', `/teams/${id}/integrations/${integrationID}`);
|
||||||
|
|
||||||
|
export const deadman = (id) => call('GET', `/teams/${id}/deadman`);
|
||||||
|
export const setDeadman = (id, body) => call('PUT', `/teams/${id}/deadman`, { body });
|
||||||
|
|
||||||
|
export const escalation = (id) => call('GET', `/teams/${id}/escalation`);
|
||||||
|
export const setEscalation = (id, body) => call('PUT', `/teams/${id}/escalation`, { body });
|
||||||
|
|
||||||
|
export const assignSchedule = (id, userID, dates, replace = false) =>
|
||||||
|
call('POST', `/teams/${id}/schedule`, { body: { user_id: userID, dates, replace } });
|
||||||
|
export const unassignSchedule = (id, entryID) => call('DELETE', `/teams/${id}/schedule/${entryID}`);
|
||||||
|
|
||||||
// Administration. Every one of these is refused with 403 for anybody without
|
// Administration. Every one of these is refused with 403 for anybody without
|
||||||
// the flag, so the UI hides the section rather than guarding it.
|
// the flag, so the UI hides the section rather than guarding it.
|
||||||
export const adminTeams = () => call('GET', '/admin/teams');
|
export const adminTeams = () => call('GET', '/admin/teams');
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 account from './account.js';
|
import * as account from './account.js';
|
||||||
|
import * as team from './team.js';
|
||||||
import * as admin from './admin.js';
|
import * as admin from './admin.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -18,6 +19,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 },
|
||||||
|
team: { title: 'Team', view: team },
|
||||||
admin: { title: 'Admin', view: admin },
|
admin: { title: 'Admin', view: admin },
|
||||||
more: { title: 'Account', view: account },
|
more: { title: 'Account', view: account },
|
||||||
};
|
};
|
||||||
@@ -26,7 +28,7 @@ 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]) };
|
||||||
const name = pathname.replace(/^\/|\/$/g, '');
|
const name = pathname.replace(/^\/|\/$/g, '');
|
||||||
if (name === 'oncall' || name === 'alerts' || name === 'admin' || name === 'more') return { section: name };
|
if (name === 'oncall' || name === 'alerts' || name === 'team' || name === 'admin' || name === 'more') return { section: name };
|
||||||
return { section: 'queue', incident: null };
|
return { section: 'queue', incident: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,15 @@ function statusBadges() {
|
|||||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||||
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
out.push(badge(`Snoozed · ${until(inc.snoozed_until)} left`, 'st-snoozed'));
|
||||||
}
|
}
|
||||||
|
// Where it is on the ladder, while it is still climbing. The queue shows
|
||||||
|
// what happened; this says what happens next, which is the question somebody
|
||||||
|
// looking at an unacknowledged incident actually has.
|
||||||
|
if (inc.escalation_level > 0) {
|
||||||
|
const left = inc.escalation_due_at && isFuture(inc.escalation_due_at)
|
||||||
|
? ` · next in ${until(inc.escalation_due_at)}`
|
||||||
|
: ' · next page due';
|
||||||
|
out.push(badge(`Escalating · level ${inc.escalation_level}${left}`, 'st-triggered'));
|
||||||
|
}
|
||||||
if (inc.archived_at) out.push(badge('Archived', 'plain'));
|
if (inc.archived_at) out.push(badge('Archived', 'plain'));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -115,6 +124,10 @@ function facts() {
|
|||||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||||
add('Snoozed until', when(inc.snoozed_until));
|
add('Snoozed until', when(inc.snoozed_until));
|
||||||
}
|
}
|
||||||
|
if (inc.escalation_level > 0 && inc.escalation_due_at) {
|
||||||
|
add('Escalates next', when(inc.escalation_due_at),
|
||||||
|
h('span', { class: 'sub', text: ` · level ${inc.escalation_level}` }));
|
||||||
|
}
|
||||||
if (inc.resolved_at) {
|
if (inc.resolved_at) {
|
||||||
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
const how = inc.resolution_source === 'manual' ? 'by hand' : 'alerts stopped firing';
|
||||||
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
add('Resolved', when(inc.resolved_at), h('span', { class: 'sub', text: ` · ${how}` }));
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
// Team settings: the rota, who is in the team, where its alerts come from,
|
||||||
|
// what it escalates through, and which of its alerts are heartbeats.
|
||||||
|
//
|
||||||
|
// Everything here was API-only until now, which meant a team owner had to use
|
||||||
|
// curl to set up escalation — the feature this whole line of work exists for.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// the controls a member cannot use, because a form that always fails is worse
|
||||||
|
// than no form, but it is not the thing enforcing anything.
|
||||||
|
|
||||||
|
import * as api from './api.js';
|
||||||
|
import { h, clear, spinner, confirm } from './ui.js';
|
||||||
|
import { state, currentTeam, users as allUsers } from './state.js';
|
||||||
|
import { isoDate, addDays } from './format.js';
|
||||||
|
|
||||||
|
const view = () => document.getElementById('view-team');
|
||||||
|
|
||||||
|
let teamID = null;
|
||||||
|
let data = null; // { team, members, integrations, escalation, deadman, schedule, users }
|
||||||
|
let error = null;
|
||||||
|
let freshKey = null; // an integration key, shown once, until the view is left
|
||||||
|
|
||||||
|
export function show() {
|
||||||
|
if (!data) clear(view(), spinner());
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedTeam() {
|
||||||
|
const teams = state.teams || [];
|
||||||
|
return teams.find((t) => t.id === teamID) || currentTeam();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refresh() {
|
||||||
|
const team = selectedTeam();
|
||||||
|
if (!team) {
|
||||||
|
data = null;
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
teamID = team.id;
|
||||||
|
try {
|
||||||
|
// A member may read all of this; only the writes are owner-only.
|
||||||
|
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(new Date()), isoDate(addDays(new Date(), 30))),
|
||||||
|
allUsers(),
|
||||||
|
]);
|
||||||
|
data = { team, members, integrations, escalation, deadman, schedule, users };
|
||||||
|
error = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwner() {
|
||||||
|
return data?.team?.role === 'owner' || state.me?.user?.is_admin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
if (!data) {
|
||||||
|
clear(view(), error
|
||||||
|
? h('div', { class: 'load-error', text: error })
|
||||||
|
: h('div', { class: 'card' }, h('p', { class: 'muted', text: 'You are not in a team yet.' })));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear(view(),
|
||||||
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
||||||
|
teamPicker(),
|
||||||
|
!isOwner() && h('div', { class: 'card' },
|
||||||
|
h('p', { class: 'muted small', text: 'You are a member of this team. Only an owner can change its settings.' })),
|
||||||
|
scheduleCard(),
|
||||||
|
escalationCard(),
|
||||||
|
integrationsCard(),
|
||||||
|
deadmanCard(),
|
||||||
|
membersCard(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only shown to somebody in more than one team, like the queue's filter chips.
|
||||||
|
function teamPicker() {
|
||||||
|
if ((state.teams || []).length < 2) {
|
||||||
|
return h('div', { class: 'card' }, h('h2', { text: data.team.name }));
|
||||||
|
}
|
||||||
|
const select = h('select', { class: 'team-picker' },
|
||||||
|
...state.teams.map((t) => h('option', {
|
||||||
|
value: String(t.id), text: t.name, selected: t.id === teamID,
|
||||||
|
})));
|
||||||
|
select.addEventListener('change', () => {
|
||||||
|
teamID = Number(select.value);
|
||||||
|
data = null;
|
||||||
|
freshKey = null;
|
||||||
|
show();
|
||||||
|
});
|
||||||
|
return h('div', { class: 'card' }, h('h2', { text: 'Team' }), select);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- schedule --------------------------------------------------------------
|
||||||
|
|
||||||
|
// The rota is one person per UTC day. The on-call page shows it; this is where
|
||||||
|
// it is set, which until now was the TUI's job and the TUI cannot do it any
|
||||||
|
// more.
|
||||||
|
function scheduleCard() {
|
||||||
|
const rows = (data.schedule || []).map((e) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', { text: e.date }),
|
||||||
|
h('td', {}, h('strong', { text: e.username })),
|
||||||
|
h('td', {}, isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Clear',
|
||||||
|
onclick: () => act(() => api.unassignSchedule(teamID, e.id)),
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'On-call rota' }),
|
||||||
|
h('p', { class: 'muted small', text: 'One person per UTC day, for the next 30 days.' }),
|
||||||
|
rows.length
|
||||||
|
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
||||||
|
: h('p', { class: 'muted', text: 'Nobody is scheduled.' }),
|
||||||
|
isOwner() && assignForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignForm() {
|
||||||
|
const who = memberSelect();
|
||||||
|
const from = h('input', { type: 'date', required: true, value: isoDate(new Date()) });
|
||||||
|
const days = h('input', { type: 'number', min: '1', max: '31', value: '1', class: 'setting-value' });
|
||||||
|
const replace = h('input', { type: 'checkbox' });
|
||||||
|
|
||||||
|
const form = h('form', { class: 'stacked-form' },
|
||||||
|
h('label', {}, 'Who ', who),
|
||||||
|
h('label', {}, 'From ', from),
|
||||||
|
h('label', {}, 'Days ', days),
|
||||||
|
// Taking a day somebody else holds has to be asked for, the same rule the
|
||||||
|
// API enforces: a plain assignment that silently moved a shift would move
|
||||||
|
// who gets paged without telling either of them.
|
||||||
|
h('label', { class: 'checkbox' }, replace, ' Take days somebody else holds'),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Assign' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const start = new Date(from.value + 'T00:00:00Z');
|
||||||
|
const dates = [];
|
||||||
|
for (let i = 0; i < Number(days.value || 1); i++) dates.push(isoDate(addDays(start, i)));
|
||||||
|
act(() => api.assignSchedule(teamID, Number(who.value), dates, replace.checked));
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberSelect(selected) {
|
||||||
|
return h('select', {},
|
||||||
|
...(data.members || []).map((m) => h('option', {
|
||||||
|
value: String(m.user_id), text: m.username, selected: m.user_id === selected,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- escalation ------------------------------------------------------------
|
||||||
|
|
||||||
|
// The ladder is edited as a whole and sent as a whole, because the API replaces
|
||||||
|
// it wholesale: the levels are an order, and patching one rung would leave the
|
||||||
|
// numbering of the others undecided.
|
||||||
|
let draft = null;
|
||||||
|
|
||||||
|
function escalationCard() {
|
||||||
|
const esc = data.escalation;
|
||||||
|
if (!draft) {
|
||||||
|
draft = {
|
||||||
|
repeat_count: esc.repeat_count || 0,
|
||||||
|
fallback_topic: esc.fallback_topic || '',
|
||||||
|
levels: (esc.levels || []).map((l) => ({
|
||||||
|
timeout_seconds: l.timeout_seconds,
|
||||||
|
targets: (l.targets || []).map((t) => ({ kind: t.kind, user_id: t.user_id })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = [];
|
||||||
|
if (!draft.levels.length) {
|
||||||
|
body.push(h('p', { class: 'muted' },
|
||||||
|
'No ladder. An unacknowledged incident re-pages the same person every ',
|
||||||
|
'reminder interval and nobody else is woken.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.levels.forEach((level, i) => {
|
||||||
|
body.push(h('div', { class: 'ladder-level' },
|
||||||
|
h('div', { class: 'ladder-head' },
|
||||||
|
h('strong', { text: `Level ${i + 1}` }),
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
onclick: () => { draft.levels.splice(i, 1); render(); },
|
||||||
|
})),
|
||||||
|
h('label', {}, 'Wait ', minutesInput(level.timeout_seconds, (secs) => {
|
||||||
|
level.timeout_seconds = secs;
|
||||||
|
}), ' before the next level'),
|
||||||
|
h('div', { class: 'ladder-targets' },
|
||||||
|
...level.targets.map((t, ti) => targetRow(level, t, ti)),
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: '+ target',
|
||||||
|
onclick: () => { level.targets.push({ kind: 'oncall' }); render(); },
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isOwner()) {
|
||||||
|
body.push(h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: '+ level',
|
||||||
|
onclick: () => {
|
||||||
|
draft.levels.push({ timeout_seconds: 300, targets: [{ kind: 'oncall' }] });
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const repeat = h('input', {
|
||||||
|
type: 'number', min: '0', max: '10', class: 'setting-value',
|
||||||
|
value: String(draft.repeat_count),
|
||||||
|
oninput: (e) => { draft.repeat_count = Number(e.target.value); },
|
||||||
|
});
|
||||||
|
const fallback = h('input', {
|
||||||
|
type: 'text', value: draft.fallback_topic, placeholder: 'terdut-oncall-all',
|
||||||
|
oninput: (e) => { draft.fallback_topic = e.target.value; },
|
||||||
|
});
|
||||||
|
body.push(h('label', {}, 'Repeat the whole ladder ', repeat, ' more times'));
|
||||||
|
body.push(h('label', {}, 'Then page this ntfy topic once ', fallback));
|
||||||
|
body.push(h('button', {
|
||||||
|
class: 'btn', type: 'button', text: 'Save ladder',
|
||||||
|
onclick: () => act(() => api.setEscalation(teamID, draft), { resetDraft: true }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Escalation' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'When a level’s wait passes and nobody has acknowledged, the next level is ',
|
||||||
|
'paged. Acknowledging or resolving stops it; snoozing pauses it.'),
|
||||||
|
...body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetRow(level, target, index) {
|
||||||
|
const kind = h('select', {},
|
||||||
|
h('option', { value: 'oncall', text: 'Whoever is on call', selected: target.kind === 'oncall' }),
|
||||||
|
h('option', { value: 'user', text: 'A specific person', selected: target.kind === 'user' }));
|
||||||
|
kind.addEventListener('change', () => {
|
||||||
|
target.kind = kind.value;
|
||||||
|
target.user_id = kind.value === 'user' ? (data.members[0] || {}).user_id : undefined;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
|
||||||
|
const who = target.kind === 'user'
|
||||||
|
? memberSelect(target.user_id)
|
||||||
|
: null;
|
||||||
|
if (who) {
|
||||||
|
who.addEventListener('change', () => { target.user_id = Number(who.value); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('div', { class: 'target-row' }, kind, who,
|
||||||
|
isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: '×',
|
||||||
|
title: 'Remove this target',
|
||||||
|
onclick: () => { level.targets.splice(index, 1); render(); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesInput(seconds, onChange) {
|
||||||
|
const input = h('input', {
|
||||||
|
type: 'number', min: '1', class: 'setting-value',
|
||||||
|
value: String(Math.max(1, Math.round(seconds / 60))),
|
||||||
|
oninput: (e) => onChange(Number(e.target.value) * 60),
|
||||||
|
});
|
||||||
|
return h('span', {}, input, ' minutes');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- integrations ----------------------------------------------------------
|
||||||
|
|
||||||
|
function integrationsCard() {
|
||||||
|
const rows = (data.integrations || []).map((i) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: i.name })),
|
||||||
|
h('td', { class: 'muted small', text: i.kind }),
|
||||||
|
h('td', { class: 'muted small', text: i.last_used_at ? 'in use' : 'never used' }),
|
||||||
|
h('td', {}, isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Revoke',
|
||||||
|
onclick: async () => {
|
||||||
|
if (!(await confirm({
|
||||||
|
title: `Revoke ${i.name}?`,
|
||||||
|
text: 'Anything posting with this key stops delivering immediately.',
|
||||||
|
confirmLabel: 'Revoke',
|
||||||
|
danger: true,
|
||||||
|
}))) return;
|
||||||
|
act(() => api.deleteIntegration(teamID, i.id));
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Alert sources' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Alerts arrive on an integration key, which says both that the sender may ',
|
||||||
|
'post and which team the alerts belong to.'),
|
||||||
|
rows.length
|
||||||
|
? h('table', { class: 'admin-table' }, h('tbody', {}, rows))
|
||||||
|
: h('p', { class: 'muted', text: 'No alert source yet, so nothing can reach this team.' }),
|
||||||
|
freshKey && newKeyPanel(),
|
||||||
|
isOwner() && !freshKey && newIntegrationForm(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key is returned exactly once. Say so, show it large, and give the
|
||||||
|
// Alertmanager snippet with it already in place — the next thing anybody does
|
||||||
|
// with it is paste it into a config.
|
||||||
|
function newKeyPanel() {
|
||||||
|
const url = freshKey.url || `${location.origin}/api/integrations/${freshKey.key}/alertmanager`;
|
||||||
|
const snippet = `receivers:
|
||||||
|
- name: terdut
|
||||||
|
webhook_configs:
|
||||||
|
- url: ${url}
|
||||||
|
send_resolved: true`;
|
||||||
|
|
||||||
|
return h('div', { class: 'key-panel' },
|
||||||
|
h('strong', { text: 'Copy this now — it is not shown again.' }),
|
||||||
|
h('pre', { class: 'key-url' }, h('code', { text: url })),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Copy URL',
|
||||||
|
onclick: () => navigator.clipboard?.writeText(url),
|
||||||
|
}),
|
||||||
|
h('p', { class: 'muted small', text: 'Alertmanager receiver:' }),
|
||||||
|
h('pre', {}, h('code', { text: snippet })),
|
||||||
|
h('button', {
|
||||||
|
class: 'btn-sm', type: 'button', text: 'Done',
|
||||||
|
onclick: () => { freshKey = null; render(); },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newIntegrationForm() {
|
||||||
|
const name = h('input', { type: 'text', placeholder: 'prod alertmanager', required: true });
|
||||||
|
const form = h('form', { class: 'inline-form' }, name,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
freshKey = await api.createIntegration(teamID, name.value.trim());
|
||||||
|
await refresh();
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dead man's switches ---------------------------------------------------
|
||||||
|
|
||||||
|
function deadmanCard() {
|
||||||
|
const d = data.deadman || {};
|
||||||
|
const matchers = h('input', {
|
||||||
|
type: 'text', value: d.matchers || '', placeholder: 'alertname=Watchdog',
|
||||||
|
class: 'wide',
|
||||||
|
});
|
||||||
|
const timeout = h('input', {
|
||||||
|
type: 'number', min: '0', class: 'setting-value',
|
||||||
|
value: String(Math.round((d.timeout_seconds || 0) / 60)),
|
||||||
|
});
|
||||||
|
const severity = h('select', {},
|
||||||
|
...['critical', 'error', 'warning', 'info'].map((s) =>
|
||||||
|
h('option', { value: s, text: s, selected: (d.severity || 'critical') === s })));
|
||||||
|
|
||||||
|
const form = h('form', { class: 'stacked-form' },
|
||||||
|
h('label', {}, 'Heartbeat alerts ', matchers),
|
||||||
|
h('label', {}, 'Declare dead after ', timeout, ' minutes of silence'),
|
||||||
|
h('label', {}, 'Open the incident at severity ', severity),
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Save switches' }));
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.setDeadman(teamID, {
|
||||||
|
matchers: matchers.value.trim(),
|
||||||
|
timeout_seconds: Number(timeout.value) * 60,
|
||||||
|
severity: severity.value,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Dead man’s switches' }),
|
||||||
|
h('p', { class: 'muted small' },
|
||||||
|
'Alerts whose ABSENCE is the signal. Receiving one opens nothing; going ',
|
||||||
|
'quiet for longer than the timeout opens an incident. ',
|
||||||
|
h('code', { text: 'alertname=Watchdog,cluster=prod; alertname=EdgeHeartbeat' }),
|
||||||
|
' — semicolons separate switches, commas separate conditions, and every ',
|
||||||
|
'switch must name an alertname. Leave empty to watch nothing.'),
|
||||||
|
isOwner() ? form : h('p', { class: 'muted', text: d.matchers || 'Nothing watched.' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- members ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function membersCard() {
|
||||||
|
const rows = (data.members || []).map((m) =>
|
||||||
|
h('tr', {},
|
||||||
|
h('td', {}, h('strong', { text: m.username })),
|
||||||
|
h('td', { class: 'muted small', text: m.role }),
|
||||||
|
h('td', {}, isOwner() && h('button', {
|
||||||
|
class: 'btn-sm', type: 'button',
|
||||||
|
text: m.role === 'owner' ? 'Make member' : 'Make owner',
|
||||||
|
onclick: () => act(() =>
|
||||||
|
api.addTeamMember(teamID, m.user_id, m.role === 'owner' ? 'member' : 'owner')),
|
||||||
|
}), isOwner() && h('button', {
|
||||||
|
class: 'btn-sm danger', type: 'button', text: 'Remove',
|
||||||
|
onclick: () => act(() => api.removeTeamMember(teamID, m.user_id)),
|
||||||
|
})),
|
||||||
|
));
|
||||||
|
|
||||||
|
const inTeam = new Set((data.members || []).map((m) => m.user_id));
|
||||||
|
const candidates = (data.users || []).filter((u) => !inTeam.has(u.id) && !u.disabled_at);
|
||||||
|
const pick = h('select', {},
|
||||||
|
...candidates.map((u) => h('option', { value: String(u.id), text: u.username })));
|
||||||
|
const role = h('select', {},
|
||||||
|
h('option', { value: 'member', text: 'member' }),
|
||||||
|
h('option', { value: 'owner', text: 'owner' }));
|
||||||
|
const form = h('form', { class: 'inline-form' }, pick, role,
|
||||||
|
h('button', { class: 'btn', type: 'submit', text: 'Add' }));
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return h('div', { class: 'card' },
|
||||||
|
h('h2', { text: 'Members' }),
|
||||||
|
h('table', { class: 'admin-table' }, h('tbody', {}, rows)),
|
||||||
|
isOwner() && candidates.length > 0 && form,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plumbing --------------------------------------------------------------
|
||||||
|
|
||||||
|
// act runs a write and reloads. Errors are shown rather than thrown away: a
|
||||||
|
// 409 from the last-owner guard or the schedule's conflict rule is the server
|
||||||
|
// explaining itself, and the reader needs to see it.
|
||||||
|
async function act(fn, { resetDraft = false } = {}) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
error = null;
|
||||||
|
if (resetDraft) draft = null;
|
||||||
|
} catch (err) {
|
||||||
|
error = err.message;
|
||||||
|
}
|
||||||
|
if (!resetDraft) draft = null;
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user