diff --git a/frontend/src/components/monitoring/alertText.js b/frontend/src/components/monitoring/alertText.js new file mode 100644 index 00000000..80e720b9 --- /dev/null +++ b/frontend/src/components/monitoring/alertText.js @@ -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 " 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, + }; +} diff --git a/frontend/src/components/monitoring/alertText.test.mjs b/frontend/src/components/monitoring/alertText.test.mjs new file mode 100644 index 00000000..0c9ec5b4 --- /dev/null +++ b/frontend/src/components/monitoring/alertText.test.mjs @@ -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'); +}); diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index deba9002..6f3b430b 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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" }, diff --git a/frontend/src/pages/Incidents.jsx b/frontend/src/pages/Incidents.jsx index 02806416..a54c1695 100644 --- a/frontend/src/pages/Incidents.jsx +++ b/frontend/src/pages/Incidents.jsx @@ -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.