91f03c21e8
Addresses the screenshot-review feedback in #26. No framework or build step added — all of this stays within the existing plain HTML/CSS/ vanilla-JS + go:embed architecture. - Nav: re-enable the bottom tab bar that was already built and switched off (Queue/On-call/Alerts/Team + a "More" sheet for Stats/Admin/Account), replacing the hamburger on phone width. - Queue: chip counts, a scroll fade on the filter row, a "Triggered Xh ago" + severity label per row, a chevron on the team switcher so it reads as a dropdown. - On-call: collapse repeated same-person days into shift bars (week view and "your shifts" both), show the week as a date range with the ISO week number as secondary text, split "Current shift" out from "Next shifts" with "ends in Nd", a pill badge + row highlight for "you". - Incident detail: fix the actual bug behind the duplicate "acknowledged" timeline entries (acknowledgeIncident's UPDATE had no guard on the incident's current status, so acknowledging an already-acknowledged incident silently re-logged the event — now idempotent, with regression tests on both the authenticated route and the ntfy ack-button route). Relabel escalation re-pages so they don't look like the same page landing twice. Copy the primary action up near the top. Label the "···" button. Group the timeline by phase (triggered/acknowledged/resolved). Add an "at a glance" summary row (duration/severity/responsible) and collapse the group labels by default. - Team overview: reword the vague copy ("One owner." etc.) into plain labels. - Empty states: fill in missing icons/one-liners across queue, alerts, stats and the incident timeline. - CSS: fix card padding bugs, verify link contrast already passes AA, introduce a --fs-* type-scale token set and migrate the few genuinely isolated cases onto it (left sizes tied to a fixed shape, a deliberately prominent display, or a non-negotiable constraint like the iOS-zoom-prevention input size as documented exceptions rather than guess at a render this change can't see). Verified with the full fmt/lint/test/helm-lint gate, plus a live instance against the test DB with seeded incidents and schedule data to trace the on-call grouping and timeline phase-splitting logic against real API responses. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
96 lines
3.0 KiB
JavaScript
96 lines
3.0 KiB
JavaScript
// The alert feed: Alertmanager's own records, read-only. Each row leads to the
|
|
// incident it belongs to, which is where anything can be done about it.
|
|
|
|
import * as api from './api.js';
|
|
import { h, clear, badge, emptyState, spinner } from './ui.js';
|
|
import { age, severityClass, labelSummary } from './format.js';
|
|
|
|
const FILTERS = [
|
|
{ id: 'firing', label: 'Firing', query: { status: 'firing' } },
|
|
{ id: 'resolved', label: 'Resolved', query: { status: 'resolved' } },
|
|
{ id: 'all', label: 'All', query: {} },
|
|
{ id: 'archived', label: 'Archived', query: { archived: 'true' } },
|
|
];
|
|
|
|
const view = () => document.getElementById('view-alerts');
|
|
|
|
let filter = 'firing';
|
|
let items = null;
|
|
let error = null;
|
|
|
|
export function show() {
|
|
render();
|
|
refresh();
|
|
}
|
|
|
|
export async function refresh() {
|
|
const requested = filter;
|
|
const f = FILTERS.find((x) => x.id === filter);
|
|
try {
|
|
const result = await api.alerts({ ...f.query, limit: 200 });
|
|
if (requested !== filter) return;
|
|
items = result;
|
|
error = null;
|
|
} catch (err) {
|
|
if (requested !== filter) return;
|
|
error = err.message;
|
|
}
|
|
render();
|
|
}
|
|
|
|
function setFilter(id) {
|
|
if (id === filter) return;
|
|
filter = id;
|
|
items = null;
|
|
render();
|
|
refresh();
|
|
}
|
|
|
|
function render() {
|
|
const chips = h('div', { class: 'chips', role: 'tablist', 'aria-label': 'Filter' },
|
|
FILTERS.map((f) => h('button', {
|
|
class: 'chip',
|
|
type: 'button',
|
|
role: 'tab',
|
|
'aria-selected': String(f.id === filter),
|
|
onclick: () => setFilter(f.id),
|
|
text: f.label,
|
|
})));
|
|
|
|
let body;
|
|
if (error && !items) body = h('div', { class: 'load-error', text: error });
|
|
else if (!items) body = spinner();
|
|
else if (!items.length) {
|
|
body = filter === 'firing'
|
|
? emptyState('Nothing firing', 'No alerts are currently firing.', 'checkCircle')
|
|
: emptyState('No alerts', 'None match this filter.', 'bell');
|
|
}
|
|
else {
|
|
body = h('div', { class: 'list' },
|
|
error && h('div', { class: 'load-error', text: `Showing older data: ${error}` }),
|
|
items.map(row));
|
|
}
|
|
clear(view(), h('div', {}, chips, body));
|
|
}
|
|
|
|
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')));
|
|
const linked = a.incident_id != null;
|
|
return h(linked ? 'a' : 'div', {
|
|
class: `row st-${a.status} ${linked ? '' : 'no-link'}`,
|
|
href: linked ? `/incidents/${a.incident_id}` : null,
|
|
},
|
|
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' },
|
|
badge(a.status === 'firing' ? 'Firing' : 'Resolved', `st-${a.status}`),
|
|
sev && badge(sev, `plain ${severityClass(sev)}`),
|
|
summary && h('span', { text: summary }),
|
|
labels && h('span', { class: 'labels', text: labels }),
|
|
),
|
|
);
|
|
}
|