fix(ui): incident rows say host offline / service down instead of 'offline over limit'

This commit is contained in:
Juan Denis committed 2026-11-03 13:10:03 -05:00
1 parent a3c243c952
commit eeb2792d06
4 files changed
+83 -11

No files matched your search

@@ -0,0 +1,37 @@
import { METRIC_LABELS } from './fleetMetrics.js';
const SERVICE_PREFIX = 'svc:';
const isNumber = (value) => typeof value === 'number' && !Number.isNaN(value);
const hasValue = (value) => value !== null && value !== undefined && value !== '';
// Wording for a fleet alert row. Threshold alerts read "<metric> over limit";
// host-offline and service-down alerts have no limit, so they never say it.
// `detail` drops every missing fragment instead of printing "—" or "null".
export function fleetAlertText(t, alert, subject) {
const { metric, value, threshold } = alert;
if (metric === 'offline') {
return {
title: t('app.incidents.hostOffline', 'Host offline'),
detail: t('app.incidents.hostOfflineDetail', '{{subject}} stopped reporting', { subject }),
};
}
if (typeof metric === 'string' && metric.startsWith(SERVICE_PREFIX)) {
const service = metric.slice(SERVICE_PREFIX.length);
return {
title: t('app.incidents.serviceDown', '{{service}} is down', { service }),
detail: t('app.incidents.serviceDownDetail', 'on {{subject}}', { subject }),
};
}
const label = METRIC_LABELS[metric] || metric;
const parts = [];
if (isNumber(value)) parts.push(`${label} ${value.toFixed(1)}`);
if (hasValue(threshold)) parts.push(t('app.incidents.limitValue', 'Limit: {{value}}', { value: threshold }));
return {
title: t('app.incidents.overLimitOn', '{{metric}} over limit on {{subject}}', { metric: label, subject }),
detail: parts.join(' · ') || label,
};
}
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fleetAlertText } from './alertText.js';
const t = (_key, fallback, vars = {}) => fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k]);
test('host offline never says "over limit" or prints null', () => {
const text = fleetAlertText(t, { metric: 'offline', value: null, threshold: null }, 'web-1');
assert.deepEqual(text, { title: 'Host offline', detail: 'web-1 stopped reporting' });
});
test('service-down alert names the service', () => {
const text = fleetAlertText(t, { metric: 'svc:mysql', value: null, threshold: null }, 'web-1');
assert.equal(text.title, 'mysql is down');
assert.doesNotMatch(text.detail, /null|—|over limit/);
});
test('threshold alert shows reading and limit', () => {
const text = fleetAlertText(t, { metric: 'cpu', value: 91.234, threshold: 90 }, 'web-1');
assert.equal(text.title, 'CPU % over limit on web-1');
assert.equal(text.detail, 'CPU % 91.2 · Limit: 90');
});
test('threshold alert without a limit omits it', () => {
const text = fleetAlertText(t, { metric: 'disk', value: 50, threshold: null }, 'web-1');
assert.equal(text.detail, 'Disk % 50.0');
});
+8
View File
@@ -3301,22 +3301,30 @@
"hostAlertsFiring_one": "1 host alert firing",
"hostAlertsFiring_other": "{{count}} host alerts firing",
"hostLimitsLiveOnThe": "Host limits live on the",
"hostOffline": "Host offline",
"hostOfflineDetail": "{{subject}} stopped reporting",
"impact": "Impact",
"incident": "Incident {{state}}",
"limit": "Limit",
"limitValue": "Limit: {{value}}",
"loadingIncidents": "Loading incidents",
"metric": "Metric",
"noMonitorIsDownAndNo": "No monitor is down and no host is over its limit.",
"noUpdatesPostedYet": "No updates posted yet.",
"nothingIsWrongRightNow": "Nothing is wrong right now",
"overLimitOn": "{{metric}} over limit on {{subject}}",
"postAnUpdate": "Post an update",
"reading": "Reading",
"resolve": "Resolve",
"resolved": "Resolved",
"rules": "Rules",
"searchIncidentsAndAlerts": "Search incidents and alerts…",
"serviceDown": "{{service}} is down",
"serviceDownDetail": "on {{subject}}",
"subject": "Subject",
"thisServer": "This server",
"timeline": "Timeline",
"unknownServer": "Unknown server",
"whatChanged": "What changed?",
"whatHappened": "What happened"
},
+11 -11
View File
@@ -27,7 +27,7 @@ import { Input } from '@/components/ui/input';
import { useTableSort } from '@/hooks/useTableSort';
import { useColumnVisibility } from '@/hooks/useColumnVisibility';
import { useTopbarActions, useTopbarChrome } from '@/hooks/useTopbarActions';
import { METRIC_LABELS } from '../components/monitoring/fleetMetrics';
import { fleetAlertText } from '../components/monitoring/alertText';
import { impactTone, INCIDENT_STATES } from '../components/monitoring/monitorShared';
import { useTranslation } from 'react-i18next';
import { toastError } from '@/utils/errorMessage';
@@ -238,14 +238,14 @@ export default function Incidents() {
});
const hostDetail = (alert) => (alert.type
? `${alert.type} ${formatValue(alert.value)} / ${alert.threshold}`
: 'This server');
? `${alert.type} ${formatValue(alert.value)}${alert.threshold == null ? '' : ` / ${alert.threshold}`}`
: t('app.incidents.thisServer', 'This server'));
const fromActive = activeAlerts.map((alert, index) => ({
kind: 'alert',
key: `active-${alert.type}-${index}`,
title: alert.message,
subject: 'This server',
subject: t('app.incidents.thisServer', 'This server'),
detail: hostDetail(alert),
state: 'firing',
tone: 'red',
@@ -262,7 +262,7 @@ export default function Incidents() {
kind: 'alert',
key: `history-${alert.timestamp || index}-${index}`,
title: alert.message,
subject: 'This server',
subject: t('app.incidents.thisServer', 'This server'),
detail: hostDetail(alert),
state: alert.severity,
tone: statusKind(alert.severity),
@@ -282,19 +282,19 @@ export default function Incidents() {
// sources above describe the panel's own box and have no server-side
// lifecycle to ack or resolve.
const fromFleet = fleetAlerts.map((alert) => {
const metric = METRIC_LABELS[alert.metric] || alert.metric;
const subject = alert.server_name || 'Unknown server';
const subject = alert.server_name || t('app.incidents.unknownServer', 'Unknown server');
const { title, detail } = fleetAlertText(t, alert, subject);
return {
kind: 'alert',
key: `fleet-${alert.id}`,
alertId: alert.id,
title: `${metric} over limit on ${subject}`,
title,
subject,
detail: `${metric} ${formatValue(alert.value)} / ${alert.threshold}`,
detail,
state: alert.status,
tone: alertStatusKind(alert.status),
impact: alert.severity,
metric,
metric: alert.metric,
value: alert.value,
threshold: alert.threshold,
when: alert.created_at,
@@ -305,7 +305,7 @@ export default function Incidents() {
return [...fromActive, ...fromFleet, ...fromIncidents, ...fromHistory]
.sort((a, b) => new Date(b.when || 0) - new Date(a.when || 0));
}, [incidents, activeAlerts, alertHistory, fleetAlerts, monitorsById]);
}, [incidents, activeAlerts, alertHistory, fleetAlerts, monitorsById, t]);
// Search only. The active/resolved split used to live here as a segment
// filter; it is a column rule now, applied inside the table.