Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| def0f68d00 | |||
| fb86a18988 | |||
| b848143471 | |||
| 942517c7a8 |
@@ -444,6 +444,25 @@ high-water mark — the highest `severity` label any of its alerts has carried
|
||||
an incident that hit `critical` still reads as critical after the critical alert
|
||||
clears.
|
||||
|
||||
### Several clusters, one team
|
||||
|
||||
A team with one Alertmanager per Kubernetes cluster, each posting to its own
|
||||
source, needs two settings or the clusters run together.
|
||||
|
||||
1. Give every alert a `cluster` label at the source. In Prometheus that is
|
||||
`externalLabels: {cluster: prod-eu}` (kube-prometheus-stack:
|
||||
`prometheus.prometheusSpec.externalLabels`).
|
||||
2. Add `cluster` to `group_by` in `alertmanager.yml`.
|
||||
|
||||
The second one is the one that matters. Incidents are matched on the team and
|
||||
Alertmanager's `groupKey`, and the `groupKey` does not include external labels:
|
||||
without `cluster` in `group_by`, the same alert in two clusters has the same
|
||||
key and joins one incident. With it, each cluster gets its own, `cluster` is in
|
||||
the incident's `group_labels`, and the web UI shows it as a coloured chip on the
|
||||
queue, the incident and the alert list, instead of leaving it in the title.
|
||||
An alert that is not grouped by `cluster` still shows the chip on the alert
|
||||
list, which reads the label from the alert itself.
|
||||
|
||||
### An incident opens only on a new occurrence
|
||||
|
||||
An incident opens when an alert **transitions into firing**: a fingerprint that
|
||||
|
||||
@@ -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.41.2
|
||||
appVersion: "v0.41.2"
|
||||
version: 0.42.0
|
||||
appVersion: "v0.42.0"
|
||||
|
||||
+16
-17
@@ -395,16 +395,13 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
.chip .count { margin-left: 4px; opacity: 0.7; }
|
||||
/* Nothing in it: step back so the chips that have something stand out. */
|
||||
.chip .count.zero { opacity: 0.4; }
|
||||
/* An overlay, not a flex item: absolute against .chips' own (non-scrolling)
|
||||
box stays flush with its real right edge regardless of scroll position,
|
||||
which turned out not to be true of position:sticky here — as a flex
|
||||
item, its sticky offset interacted with the row's gap and its own
|
||||
negative margin, landing short of the edge by about one gap's width. */
|
||||
.chips-fade {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: 24px;
|
||||
background: linear-gradient(to right, transparent, var(--bg));
|
||||
pointer-events: none;
|
||||
/* A scrolling strip fades on the right edge while there is more to scroll to
|
||||
(fadeOnOverflow in ui.js sets data-more). A mask on the strip itself, not an
|
||||
element inside it: anything inside a scroller scrolls away with the content,
|
||||
which is what the old overlay did. */
|
||||
[data-more] {
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
|
||||
/* ---------- lists ---------- */
|
||||
@@ -491,7 +488,15 @@ input:focus, textarea:focus { outline: none; border-color: var(--accent); box-sh
|
||||
}
|
||||
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.badge.plain::before { display: none; }
|
||||
.badge .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
|
||||
.badge .badge-icon, .origin-chip .badge-icon { width: 12px; height: 12px; stroke-width: 2.4; }
|
||||
/* The cluster an incident or alert came from. Same shape as a badge, coloured
|
||||
from the rcN palette (see originClass in format.js), never the severity one. */
|
||||
.origin-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
|
||||
padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: 0.01em; white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.badge.st-triggered, .badge.st-firing { background: var(--crit-soft); color: var(--crit); }
|
||||
.badge.st-acknowledged { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.st-snoozed { background: var(--snooze-soft); color: var(--snooze); }
|
||||
@@ -886,7 +891,6 @@ kbd {
|
||||
/* The pane is 340-420px wide and a mouse cannot scroll a row whose scrollbar
|
||||
is hidden, so the chips wrap here instead: Archived stays reachable. */
|
||||
.pane-list .chips { flex-wrap: wrap; overflow-x: visible; }
|
||||
.chips-fade { display: none; }
|
||||
/* With nothing selected there is no detail to show next to, so the list
|
||||
takes the whole row instead of leaving the second column as dead space
|
||||
around the placeholder text. Selecting an incident (.has-detail) drops
|
||||
@@ -1236,11 +1240,6 @@ button.rota-week:hover { background: var(--surface-2); color: var(--text); }
|
||||
overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.subnav::-webkit-scrollbar { display: none; }
|
||||
/* A fade on the right edge says "there is more" where the strip overflows;
|
||||
the desktop width fits every entry, so it is phone-only. */
|
||||
@media (max-width: 899px) {
|
||||
.subnav { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent); }
|
||||
}
|
||||
.subnav-link {
|
||||
flex: none;
|
||||
padding: 8px 12px; margin-bottom: -1px;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// gate.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { h, clear, spinner, confirm, menuCard, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
|
||||
import { state, myID } from './state.js';
|
||||
|
||||
const view = () => document.getElementById('view-admin');
|
||||
@@ -111,6 +111,7 @@ function subnav() {
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
fadeOnOverflow(nav);
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// incident it belongs to, which is where anything can be done about it.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||
import { age, labelSummary } from './format.js';
|
||||
import { h, clear, badge, severityBadge, originChip, emptyState, spinner } from './ui.js';
|
||||
import { age, labelSummary, originOf, ORIGIN_LABEL } from './format.js';
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
||||
@@ -77,7 +77,8 @@ function row(a) {
|
||||
const summary = (a.annotations && a.annotations.summary) || '';
|
||||
const sev = a.labels && a.labels.severity;
|
||||
const labels = labelSummary(Object.fromEntries(
|
||||
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity')));
|
||||
Object.entries(a.labels || {}).filter(([k]) => k !== 'severity' && k !== ORIGIN_LABEL)));
|
||||
const origin = originOf(a.labels);
|
||||
const linked = a.incident_id != null;
|
||||
return h(linked ? 'a' : 'div', {
|
||||
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
|
||||
@@ -86,6 +87,7 @@ function row(a) {
|
||||
h('div', { class: 'row-title', text: a.name }),
|
||||
h('div', { class: 'row-age', title: a.starts_at, text: age(a.status === 'firing' ? a.starts_at : a.received_at) }),
|
||||
h('div', { class: 'row-meta' },
|
||||
origin && originChip(origin),
|
||||
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
||||
sev && severityBadge(sev),
|
||||
summary && h('span', { text: summary }),
|
||||
|
||||
@@ -117,3 +117,36 @@ export function labelSummary(labels, skip = 'alertname') {
|
||||
export function initial(name) {
|
||||
return (name || '?').trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
// Where an incident or alert came from, for a team with several Kubernetes
|
||||
// clusters (or other origins) behind it: the value of one label, `cluster` by
|
||||
// convention. It comes from Prometheus's externalLabels, so it is on every
|
||||
// alert; an incident carries it only when it is in Alertmanager's group_by,
|
||||
// which is also what keeps two clusters' identical alerts from merging into one
|
||||
// incident (see the README, "Several clusters, one team").
|
||||
export const ORIGIN_LABEL = 'cluster';
|
||||
|
||||
export function originOf(labels) {
|
||||
const v = labels && labels[ORIGIN_LABEL];
|
||||
return v ? String(v) : null;
|
||||
}
|
||||
|
||||
// A stable colour for an origin, from the six-colour rcN palette app.css has,
|
||||
// so "prod-eu" is the same colour on every row without anything being stored.
|
||||
export function originClass(value) {
|
||||
let n = 0;
|
||||
for (const ch of value) n = (n * 31 + ch.charCodeAt(0)) >>> 0;
|
||||
return `rc${(n % 6) + 1}`;
|
||||
}
|
||||
|
||||
// The title without its `cluster=...` part, for a row that shows the origin as
|
||||
// a chip: the server puts every grouping label in the title, so the cluster
|
||||
// would otherwise appear twice. Anything that does not look like the server's
|
||||
// "name (k=v, k=v)" shape is returned unchanged.
|
||||
export function titleWithoutOrigin(title, value) {
|
||||
if (!value) return title;
|
||||
const m = title.match(/^(.*?) \((.*)\)$/s);
|
||||
if (!m) return title;
|
||||
const rest = m[2].split(', ').filter((p) => p !== `${ORIGIN_LABEL}=${value}`);
|
||||
return rest.length ? `${m[1]} (${rest.join(', ')})` : m[1];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
import * as api from './api.js';
|
||||
import * as poll from './poll.js';
|
||||
import {
|
||||
h, clear, icon, badge, severityBadge, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||
} from './ui.js';
|
||||
import {
|
||||
ago, when, until, duration, isFuture, STATUS_LABEL,
|
||||
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
|
||||
} from './format.js';
|
||||
import { myID, users } from './state.js';
|
||||
import { back } from './app.js';
|
||||
@@ -99,6 +99,8 @@ function render() {
|
||||
|
||||
function statusBadges() {
|
||||
const out = [];
|
||||
const origin = originOf(inc.group_labels);
|
||||
if (origin) out.push(originChip(origin));
|
||||
if (inc.severity) out.push(severityBadge(inc.severity));
|
||||
out.push(badge(STATUS_LABEL[inc.status] || inc.status, `st-${inc.status}`));
|
||||
if (inc.status !== 'resolved' && isFuture(inc.snoozed_until)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// The incident queue: filter chips and a list of incident rows.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, badge, severityBadge, emptyState, spinner } from './ui.js';
|
||||
import { ago, until, isFuture, severityClass, labelSummary } from './format.js';
|
||||
import { h, clear, badge, 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';
|
||||
import { navigate } from './app.js';
|
||||
@@ -118,6 +118,10 @@ function chipCount(id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set the first time the chips are drawn: the strip element is the same one
|
||||
// for the life of the page, so one scroll listener and one observer will do.
|
||||
let chipsFade;
|
||||
|
||||
function renderChips() {
|
||||
const el = document.getElementById('queue-filters');
|
||||
const chips = FILTERS.map((f) => {
|
||||
@@ -135,9 +139,9 @@ function renderChips() {
|
||||
// There are no team chips here: the team selector in the sidebar (and in the
|
||||
// phone's top bar) is the one place the team is chosen, and chips for it
|
||||
// would be the same choice offered twice.
|
||||
chips.push(h('span', { class: 'chips-fade', 'aria-hidden': 'true' }));
|
||||
|
||||
clear(el, chips);
|
||||
chipsFade ??= fadeOnOverflow(el);
|
||||
chipsFade();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
@@ -197,15 +201,18 @@ function row(inc, index) {
|
||||
? h('span', { class: 'row-team', text: inc.team_name })
|
||||
: null;
|
||||
|
||||
const origin = originOf(inc.group_labels);
|
||||
|
||||
return h('a', {
|
||||
class: `row ${severityClass(inc.severity)} ${resolved ? 'resolved' : ''} ${index === cursor ? 'kbd-focus' : ''}`,
|
||||
href: `/incidents/${inc.id}`,
|
||||
'aria-current': inc.id === selected ? 'true' : null,
|
||||
dataset: { index: String(index) },
|
||||
},
|
||||
h('div', { class: 'row-title', text: inc.title }),
|
||||
h('div', { class: 'row-title', text: titleWithoutOrigin(inc.title, origin) }),
|
||||
h('div', { class: 'row-age', title: inc.triggered_at, text: `Triggered ${ago(inc.triggered_at)}` }),
|
||||
h('div', { class: 'row-meta' },
|
||||
origin && originChip(origin),
|
||||
!impliedStatus && status,
|
||||
// The left-border colour alone doesn't say what it means; spell it out
|
||||
// too, same badge the incident detail page uses for severity.
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// than no form, but it is not the thing enforcing anything.
|
||||
|
||||
import * as api from './api.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED } from './ui.js';
|
||||
import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js';
|
||||
import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js';
|
||||
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||
|
||||
@@ -177,6 +177,7 @@ function subnav() {
|
||||
})));
|
||||
// On a phone the strip overflows; bring the open section into view so a
|
||||
// tab past the edge (Sources, Switches) is never the one that is hidden.
|
||||
fadeOnOverflow(nav);
|
||||
requestAnimationFrame(() => nav.querySelector('[aria-current]')
|
||||
?.scrollIntoView({ inline: 'center', block: 'nearest' }));
|
||||
return nav;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||||
|
||||
import { severityClass } from './format.js';
|
||||
import { severityClass, originClass } from './format.js';
|
||||
|
||||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||||
@@ -32,8 +32,24 @@ export function clear(el, ...children) {
|
||||
return el;
|
||||
}
|
||||
|
||||
// Marks a horizontally scrolling strip with data-more while there is more of it
|
||||
// to the right, which the CSS turns into a fade on that edge. The fade is a
|
||||
// mask on the strip itself, so it stays put at the edge of the screen: a fade
|
||||
// drawn as a child of the scroller scrolls away with the content. Returns the
|
||||
// update function, for a strip whose contents change under it.
|
||||
export function fadeOnOverflow(el) {
|
||||
const update = () => {
|
||||
el.toggleAttribute('data-more', el.scrollLeft + el.clientWidth < el.scrollWidth - 1);
|
||||
};
|
||||
el.addEventListener('scroll', update, { passive: true });
|
||||
if (typeof ResizeObserver === 'function') new ResizeObserver(update).observe(el);
|
||||
update();
|
||||
return update;
|
||||
}
|
||||
|
||||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||||
const ICONS = {
|
||||
server: ['rect:3,4,18,6,2', 'rect:3,14,18,6,2', 'M7 7h.01', 'M7 17h.01'],
|
||||
alertTriangle: ['M12 4l9.5 16.5h-19z', 'M12 10v4.5', 'M12 17.5h.01'],
|
||||
alertCircle: ['circle:12,12,9', 'M12 7.5v5', 'M12 16h.01'],
|
||||
infoCircle: ['circle:12,12,9', 'M12 11v5.5', 'M12 7.5h.01'],
|
||||
@@ -178,6 +194,13 @@ export function badge(text, cls = '') {
|
||||
return h('span', { class: `badge ${cls}`, text });
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return h('span', { class: `origin-chip ${originClass(value)}`, title: `Origin: ${value}` },
|
||||
icon('server', 'icon badge-icon'), value);
|
||||
}
|
||||
|
||||
// The severity as a badge with a shape as well as a colour: a triangle for
|
||||
// critical, a circle with ! for warning, a circle with i for anything else, so
|
||||
// the coloured bar and badge are not the only thing that says it.
|
||||
|
||||
Reference in New Issue
Block a user