/** * Rendering alerts for the operator. * * A flat table rather than a TUI: alert triage is a list-scan, not an * exploration, and a table is greppable, pipeable, and renders unchanged over * the Remote API. See design D14. * * Pure — takes rows, returns strings — so the wording and the column that says * "explained by something upstream" can be asserted without a database. */ import type { Alert, AlertState } from '../../db/schema'; import type { MonitorRunOutcomeSummary } from './run-monitor'; export interface AlertRow { key: string; /** Display label, not the stored state — `firing` renders as `FIRING`. */ state: string; severity: string; age: string; detail: string; } const STATE_LABEL: Record = { pending: 'pending', firing: 'FIRING', acked: 'acked', suppressed: 'suppressed', resolved: 'resolved', }; /** * Compact humanised age. Deliberately coarse — an operator scanning a list * needs "is this new or has it been broken all day", not seconds. */ export function humaniseAge(from: Date, now: Date): string { const seconds = Math.max(0, Math.floor((now.getTime() - from.getTime()) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; return `${Math.floor(hours / 24)}d`; } /** * Time REMAINING, rounded up. * * `humaniseAge` floors, which is right for elapsed time and wrong for a * countdown: with 1h59m left, "1h" reads as though a 2h silence were nearly * over. Rounding up never overstates how soon an alert comes back. */ export function humaniseRemaining(until: Date, now: Date): string { const seconds = Math.max(0, Math.ceil((until.getTime() - now.getTime()) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.ceil(seconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.ceil(minutes / 60); if (hours < 24) return `${hours}h`; return `${Math.ceil(hours / 24)}d`; } /** * The most useful thing to say about an alert beyond its state. * * Ordering is by what an operator would act on: an acknowledgement tells them * someone already has it, a suppression tells them not to chase this one, and * a silence tells them it is deliberate. Only when none apply does the alert's * own message earn the column. */ export function alertDetail(alert: Alert, now: Date): string { if (alert.state === 'acked') return 'acked'; if (alert.state === 'suppressed') { return alert.suppressedByWindowId ? 'deploy in progress' : 'explained by an upstream alert'; } if (alert.silencedUntil && alert.silencedUntil > now) { return `silenced for ${humaniseRemaining(alert.silencedUntil, now)}`; } if (alert.awaitingConfirmation) return 'awaiting confirmation'; if (alert.state === 'pending' && alert.graceUntil > now) return 'within grace window'; return alert.message; } export function toAlertRow(alert: Alert, now: Date): AlertRow { return { key: alert.key, state: STATE_LABEL[alert.state], severity: alert.severity, age: humaniseAge(alert.firstFiredAt, now), detail: alertDetail(alert, now), }; } /** * Render rows as a fixed-width table. * * Sorted most-actionable first: firing alerts an operator must deal with, then * pending, then everything already accounted for. Within a group, oldest first * — a problem that has been burning for a day outranks one from a minute ago. */ const STATE_ORDER: Record = { FIRING: 0, pending: 1, acked: 2, suppressed: 3, resolved: 4, }; export function sortAlertRows(rows: AlertRow[]): AlertRow[] { return [...rows].sort((a, b) => { const byState = (STATE_ORDER[a.state] ?? 9) - (STATE_ORDER[b.state] ?? 9); return byState !== 0 ? byState : a.key.localeCompare(b.key); }); } export function renderAlertTable(rows: AlertRow[]): string { if (rows.length === 0) return 'No alerts.'; const sorted = sortAlertRows(rows); const headers = ['KEY', 'STATE', 'SEVERITY', 'AGE', 'DETAIL']; const columns: (keyof AlertRow)[] = ['key', 'state', 'severity', 'age', 'detail']; const widths = columns.map((column, i) => Math.max(headers[i].length, ...sorted.map((row) => row[column].length)), ); const line = (cells: string[]) => cells .map((cell, i) => (i === cells.length - 1 ? cell : cell.padEnd(widths[i]))) .join(' ') .trimEnd(); return [line(headers), ...sorted.map((row) => line(columns.map((c) => row[c])))].join('\n'); } /** * The observed-health cell for `celilo module list`. * * "Not observed" is a finding, not a blank: a module nothing is watching is * unverified infrastructure, and rendering it as healthy is the lie this column * exists to stop telling. See design D15. */ export function moduleHealthCell(input: { monitored: boolean; firingCount: number; suppressed: boolean; }): string { if (!input.monitored) return 'not observed'; if (input.suppressed) return 'suppressed'; if (input.firingCount > 0) return `${input.firingCount} firing`; return 'ok'; } /** * The message `celilo monitor run ` reports. * * The count line stays greppable (`0 failing` is what a healthy fleet prints), * and each failing key is named underneath it with its reason — an operator * paged by this line learns WHAT failed, not just that something did (#1266). * * Pure — takes the run summary, returns the string — so the wording can be * asserted without a database or a fleet. */ export function renderMonitorRunMessage(target: string, summary: MonitorRunOutcomeSummary): string { if (summary.outcome === 'error') { return `${target}: check could not run — ${summary.errorMessage ?? 'unknown error'}`; } const lines = [ `${target}: ${summary.failingKeys.length} failing, ${summary.resolvedIds.length} resolved`, ]; for (const failing of summary.failingKeys) { lines.push(` [${failing.severity}] ${failing.key}: ${failing.message}`); } return lines.join('\n'); }