Compare commits

..

4 Commits

Author SHA1 Message Date
Niklas Ye def0f68d00 Set the chart's placeholder version to 0.42.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 20s
CI / test (push) Successful in 5m11s
Release / test (push) Successful in 9s
Release / chart (push) Successful in 2s
Release / binaries (push) Successful in 25s
Release / image (push) Successful in 1m6s
Release / scan-image (push) Successful in 25s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with b848143 (0.41.3) and 7665e5e (0.41.2) before it, because a
tree heading for v0.42.0 that still says 0.41.3 tells its reader
something false.
2026-10-08 17:26:16 +02:00
Niklas Ye fb86a18988 Show which cluster an incident came from, as a chip
A team with one Alertmanager per Kubernetes cluster could not tell at a
glance where an incident started: the cluster was only a word inside the
title. When an alert carries a `cluster` label, the queue rows, the incident
page and the alerts list now show it as a chip in that cluster's colour, a
stable pick from the existing six-colour palette. A queue row drops
`cluster=...` from its title, since the chip says it, and the incident page
keeps the full title.

An incident has the label only when it is in Alertmanager's group_by, which
is also what keeps two clusters' identical alerts apart: incidents are matched
on the team and the groupKey, and the groupKey does not include external
labels. The README has a section on the two settings (Prometheus
externalLabels and group_by). The alerts list reads the label from the alert
itself, so it shows the chip with only the external label.

Web UI and docs only: no endpoint or JSON shape changed, so nothing to mirror
in terdut-tui. Nothing changes for a team whose alerts have no cluster label.
2026-10-08 17:24:52 +02:00
Niklas Ye b848143471 Set the chart's placeholder version to 0.41.3
CI / security (push) Successful in 4m45s
Release / test (push) Successful in 7s
Release / chart (push) Successful in 3s
CI / test (push) Successful in 9m4s
Release / image (push) Successful in 2m15s
Release / scan-image (push) Successful in 40s
Release / binaries (push) Successful in 3m1s
CI / chart (push) Successful in 1s
Cosmetic: make helm-package passes --version and --app-version from the
tag, so these two fields decide nothing about what gets published. Still
done, as with 7665e5e (0.41.2) and 3a96b20 (0.41.1) before it, because a
tree heading for v0.41.3 that still says 0.41.2 tells its reader
something false.
2026-10-08 15:13:42 +02:00
Niklas Ye 942517c7a8 Keep the scroll fade at the screen edge on the queue's filter row
The fade that hints at more filters to the right was an element inside the
scrolling row, so it scrolled away with the chips instead of staying at the
edge; the comment on it claimed the opposite. It is now a mask on the strip
itself, applied only while there is more to scroll to (fadeOnOverflow in
ui.js sets data-more), so it stays put and disappears at the end instead of
dimming the last chip.

The Team and Admin tab strips had an always-on mask from the earlier
"Sources is cut off" fix, which dimmed their last tab even when fully
scrolled; they use the same mechanism now.

Web UI only: no endpoint or JSON shape changed, so nothing to mirror in
terdut-tui.
2026-10-08 15:12:16 +02:00
10 changed files with 119 additions and 32 deletions
+19
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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;
+2 -1
View File
@@ -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;
+5 -3
View File
@@ -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 }),
+33
View File
@@ -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 -2
View File
@@ -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)) {
+12 -5
View File
@@ -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.
+2 -1
View File
@@ -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;
+24 -1
View File
@@ -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.