Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9f8494b00 | |||
| 3173abfba2 | |||
| 9360d909cb | |||
| f1754c583e | |||
| c67dc80a8f | |||
| 392eab4127 | |||
| 30b3f0ff75 |
+1
-1
@@ -4,7 +4,7 @@
|
||||
# in per platform. The CI runner has no binfmt registration and no way to get one (the
|
||||
# JS action that used to install it cannot run there), so this is not just an
|
||||
# optimisation -- it is what makes the arm64 image buildable at all.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26.9-alpine AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Terminal Duty (terdut-server)
|
||||
# terdut-server
|
||||
|
||||
Incident management for teams that already run Prometheus Alertmanager. Point Alertmanager at it,
|
||||
and alerts become incidents that get assigned to whoever is on call, paged, escalated when nobody
|
||||
|
||||
@@ -15,5 +15,5 @@ type: application
|
||||
# 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
|
||||
# metadata and drives nothing.
|
||||
version: 0.43.0
|
||||
appVersion: "v0.43.0"
|
||||
version: 0.44.1
|
||||
appVersion: "v0.44.1"
|
||||
|
||||
@@ -69,7 +69,11 @@ const incidentSelectFrom = `
|
||||
i.acknowledged_by, i.acknowledged_at, ack.username,
|
||||
i.acknowledged_by_service_account_id, acksa.name,
|
||||
i.assigned_to, asg.username, i.snoozed_until,
|
||||
i.resolved_at, i.resolution_source, i.archived_at
|
||||
i.resolved_at, i.resolution_source, i.archived_at,
|
||||
-- How many notes, so a list can flag the incidents that carry extra
|
||||
-- information without fetching each timeline.
|
||||
(SELECT count(*) FROM incident_events e WHERE e.incident_id = i.id AND e.type = 'note'),
|
||||
(SELECT count(*) FROM incident_events e WHERE e.incident_id = i.id AND e.type = 'resolution_note')
|
||||
FROM incidents i
|
||||
JOIN teams t ON t.id = i.team_id
|
||||
LEFT JOIN users ack ON ack.id = i.acknowledged_by
|
||||
@@ -90,6 +94,7 @@ func scanIncident(s scanner) (models.Incident, error) {
|
||||
&i.AcknowledgedByServiceAccountID, &i.AcknowledgedByServiceAccountName,
|
||||
&i.AssignedToID, &i.AssignedToUser, &snoozedUntil,
|
||||
&resolvedAt, &i.ResolutionSource, &archivedAt,
|
||||
&i.NoteCount, &i.ResolutionNoteCount,
|
||||
); err != nil {
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -66,6 +66,12 @@ type Incident struct {
|
||||
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
|
||||
// NoteCount and ResolutionNoteCount count the plain working notes and the
|
||||
// "what fixed it" notes on the timeline, so a list can flag the incidents
|
||||
// that carry extra information.
|
||||
NoteCount int `json:"note_count"`
|
||||
ResolutionNoteCount int `json:"resolution_note_count"`
|
||||
|
||||
// Alerts is populated by GET /api/incidents/{id} only.
|
||||
Alerts []Alert `json:"alerts,omitempty"`
|
||||
}
|
||||
|
||||
@@ -522,6 +522,8 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.badge.st-unreachable, .badge.st-unpageable { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.sev-critical { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.sev-warning { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.has-note { background: var(--info-soft); color: var(--info); }
|
||||
.badge.has-fix { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.sev-info { background: var(--info-soft); color: var(--info); }
|
||||
|
||||
/* ---------- incident detail ---------- */
|
||||
|
||||
@@ -76,7 +76,7 @@ async function load() {
|
||||
|
||||
function render() {
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(view(), h('div', { class: 'card' },
|
||||
clear(view(), h('div', { class: 'card card-pad' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +152,7 @@ function teamsCard() {
|
||||
h('td', { class: 'num', text: String(t.open_incidents) }),
|
||||
));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Open a team for who is in it, the invites into it, and renaming or ',
|
||||
@@ -229,7 +229,7 @@ function usersCard() {
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Users' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Disabling an account stops it signing in and stops its API keys, and keeps ',
|
||||
@@ -368,7 +368,7 @@ function settingsCard() {
|
||||
h('td', {}, h('code', { text: k })),
|
||||
h('td', { class: 'muted', text: v === '' ? '(unset)' : v })));
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Settings' }),
|
||||
h('p', { class: 'muted small', text: 'Saved changes take effect on the next sweep — no restart.' }),
|
||||
form,
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function refresh() {
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
clear(el, backLink(), h('div', { class: 'card card-pad' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +81,7 @@ function render() {
|
||||
return;
|
||||
}
|
||||
if (!data.team) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
clear(el, backLink(), h('div', { class: 'card card-pad' },
|
||||
h('p', { class: 'muted', text: 'No such team. It may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ function identityCard() {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('div', { class: 'user-head' }, h('h2', { text: t.name })),
|
||||
h('dl', { class: 'user-facts' },
|
||||
fact('Created', when(t.created_at)),
|
||||
@@ -216,7 +216,7 @@ function membersCard() {
|
||||
act(() => api.addTeamMember(teamID, Number(pick.value), role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Members' }),
|
||||
data.members.length === 0 && h('p', { class: 'muted small' },
|
||||
'Nobody is in this team. Its queue has no one to work it and its ',
|
||||
@@ -275,7 +275,7 @@ function invitesCard() {
|
||||
);
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Invites' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'An invite link puts somebody in this team and lets them choose their ',
|
||||
@@ -303,7 +303,7 @@ function inviteState(inv) {
|
||||
function dangerCard() {
|
||||
const t = data.team;
|
||||
const blocked = t.open_incidents > 0;
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Delete' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Its alerts, incidents, schedule and integrations go with it. This ',
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function refresh() {
|
||||
function render() {
|
||||
const el = view();
|
||||
if (!state.me?.user?.is_admin) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
clear(el, backLink(), h('div', { class: 'card card-pad' },
|
||||
h('p', { class: 'muted', text: 'Administration is for system administrators. Ask one for access.' })));
|
||||
return;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ function render() {
|
||||
return;
|
||||
}
|
||||
if (!data.user) {
|
||||
clear(el, backLink(), h('div', { class: 'card' },
|
||||
clear(el, backLink(), h('div', { class: 'card card-pad' },
|
||||
h('p', { class: 'muted', text: 'No such user. They may have just been deleted.' })));
|
||||
return;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ function identityCard() {
|
||||
const u = data.user;
|
||||
const self = u.id === myID();
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('div', { class: 'user-head' },
|
||||
h('h2', { text: u.username }),
|
||||
u.is_admin && h('span', { class: 'row-team', text: 'admin' }),
|
||||
@@ -203,7 +203,7 @@ function teamsCard() {
|
||||
act(() => api.addTeamMember(Number(pick.value), userID, role.value));
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Teams' }),
|
||||
data.teams.length === 0 && h('p', { class: 'muted small' },
|
||||
'In no team. They can sign in, but there is no queue for them to work ',
|
||||
@@ -244,7 +244,7 @@ function accountCard() {
|
||||
await refresh();
|
||||
});
|
||||
|
||||
return h('div', { class: 'card' },
|
||||
return h('div', { class: 'card card-pad' },
|
||||
h('h2', { text: 'Account' }),
|
||||
h('p', { class: 'muted small' },
|
||||
'Setting a password here is how somebody gets their first one, or a new ',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The incident queue: filter chips and a list of incident rows.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, icon, badge, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
|
||||
import { h, clear, icon, badge, noteTags, severityBadge, originChip, emptyState, spinner, fadeOnOverflow } from './ui.js';
|
||||
import { ago, until, isFuture, severityClass, labelSummary, originOf, titleWithoutOrigin } from './format.js';
|
||||
import { state, myID, onTeamChange } from './state.js';
|
||||
import * as onboarding from './onboarding.js';
|
||||
@@ -309,6 +309,7 @@ function row(inc, index) {
|
||||
// The left-border colour alone doesn't say what it means; spell it out
|
||||
// too, same badge the incident detail page uses for severity.
|
||||
inc.severity && severityBadge(inc.severity),
|
||||
...(noteTags(inc) || []),
|
||||
assignee,
|
||||
team,
|
||||
labels && h('span', { class: 'labels', text: labels }),
|
||||
|
||||
@@ -194,6 +194,19 @@ export function badge(text, cls = '') {
|
||||
return h('span', { class: `badge ${cls}`, text });
|
||||
}
|
||||
|
||||
// A tag saying an incident has notes: blue for working notes, green for a note
|
||||
// on what fixed it. Null when there are none, so it can sit in a row unguarded.
|
||||
export function noteTags(inc) {
|
||||
const tag = (n, cls, label) => n > 0 && h('span', {
|
||||
class: `badge plain ${cls}`, title: `${n} ${label}${n === 1 ? '' : 's'}`,
|
||||
}, icon('note', 'icon badge-icon'), String(n));
|
||||
const tags = [
|
||||
tag(inc.note_count, 'has-note', 'note'),
|
||||
tag(inc.resolution_note_count, 'has-fix', 'resolution note'),
|
||||
].filter(Boolean);
|
||||
return tags.length ? tags : null;
|
||||
}
|
||||
|
||||
// Where it came from (the cluster): a chip in that origin's colour, with a
|
||||
// server icon so it reads as a place and not as a status.
|
||||
export function originChip(value) {
|
||||
|
||||
Reference in New Issue
Block a user