openvibers/openvibe.network Commits

Operator alerts read as System notices: humanized names, plain bodies, the fix command in rich_content (#378)

OP OpenVibers committed 16 days ago · f03f58476b · 3 files, +52 −15

Changes

ddiff --git a/server/notifications/notification-service.js b/server/notifications/notification-service.js
iindex 3fe8cbf3..5e6a6e43 100644
---- a/server/notifications/notification-service.js
++++ b/server/notifications/notification-service.js
@@@ -343,11 +343,12 @@ class NotificationService {
// ─── Update ────────────────────────────────────────────────
/** Edit one of a user's notifications in place (an operator alert that resolves or fires again). */
-- async revise(id, userId, { title, message, icon, is_read: isRead } = {}) {
++ async revise(id, userId, { title, message, icon, is_read: isRead, rich_content: richContent } = {}) {
const sets = []; const args = [];
if (title != null) { sets.push('title = ?'); args.push(String(title).slice(0, 200)); }
if (message != null) { sets.push('message = ?'); args.push(String(message)); }
if (icon != null) { sets.push('icon = ?'); args.push(String(icon)); }
++ if (richContent != null) { sets.push('rich_content = ?'); args.push(JSON.stringify(richContent)); }
if (isRead != null) { sets.push('is_read = ?'); args.push(isRead ? 1 : 0); }
if (!sets.length) return false;
const r = await this.db.prepare(`UPDATE notifications SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...args, id, userId);
ddiff --git a/server/operator/alerts.js b/server/operator/alerts.js
iindex a1ac644c..1927b7e7 100644
---- a/server/operator/alerts.js
++++ b/server/operator/alerts.js
@@@ -42,6 +42,35 @@ function message(a) {
const ago = (ms) => (ms < 3600e3 ? `${Math.max(1, Math.round(ms / 60e3))} min` : `${Math.round(ms / 3600e3)} h`);
++/** OpenVibeBrowserCheckFailed → "Browser check failed": how an alert's name reads in a notification. */
++function humanize(name) {
++ const s = String(name || '').replace(/^OpenVibe(?=[A-Z])/, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').trim();
++ return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Alert';
++}
++
++/**
++ * The parts of an alert a person reads, kept apart from the shell command its rule may carry (owner 10-28: raw
++ * "run: sudo ovhost …" text in the notification body). `rich_content` gives Shared's notification UI the name,
++ * state, severity, sites, release and the command for a copy button; the message stays plain words.
++ */
++function readable(a) {
++ const text = [a.summary, a.description].filter(Boolean).join('. ');
++ const command = (/run:\s*(sudo [^;]+?)(?:\.\s|;|\.$|$)/.exec(text) || [])[1] || null;
++ const sites = [...new Set((text.match(/(?:^|\.\s+)([a-z0-9][a-z0-9.-]{1,30}):\s/g) || []).map((s) => s.replace(/^\.\s+/, '').replace(/:\s$/, '')))].slice(0, 6);
++ const release = (/release ([0-9a-f]{7,40})/i.exec(text) || [])[1] || null;
++ const failing = (/\((\d+) failing check/i.exec(text) || [])[1] || null;
++ let summary = String(a.summary || '').replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '').replace(/^[a-z0-9][a-z0-9.-]{1,30}:\s/, '').trim();
++ if (release || failing) summary = [release ? `Release ${release.slice(0, 7)}` : null, failing ? `${failing} failing check${failing === '1' ? '' : 's'}` : null].filter(Boolean).join(' · ');
++ const detail = String(a.description || '').replace(/run:\s*sudo [^;]+?(?:\.\s|;|\.$|$)/g, '').trim();
++ const message = [sites.length ? sites.join(', ') : (a.service || null), summary || null, detail && detail !== summary ? detail : null].filter(Boolean).join(' · ');
++ return { command, sites, release, summary, message: message.slice(0, 500) };
++}
++
++function opsContent(a, state, r = readable(a)) {
++ return { kind: 'ops_alert', name: a.name, state, severity: a.severity, sites: r.sites, release: r.release, summary: r.summary,
++ command: r.command, service: a.service || null, since: a.started_at || null, runbook_url: STATUS_URL };
++}
++
/**
* Apply one report. `body` has passed the request contract. Answers the result document.
* notify(userId, notification) creates one notification (the notification service's create(); `silent` skips
@@@ -98,11 +127,13 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
const critical = a.severity === 'critical';
if (kind === 'opened' || kind === 'reminded') {
if (typeof notify !== 'function') continue;
++ const r = readable(a);
const n = {
type: 'OPERATOR_ALERT', category: 'admin', service: 'host', url: STATUS_URL,
priority: critical ? 'critical' : 'high', icon: critical ? '🚨' : '⚠️', silent: !critical,
-- title: `${kind === 'reminded' ? 'Still firing' : 'Alert'}: ${a.name}`.slice(0, 120),
-- message: `${a.summary}. ${message(a)}`.slice(0, 500),
++ title: `${kind === 'reminded' ? 'Still failing: ' : ''}${humanize(a.name)}`.slice(0, 120),
++ message: r.message || `${a.summary}. ${message(a)}`.slice(0, 500),
++ rich_content: opsContent(a, kind === 'reminded' ? 'reminded' : 'firing', r),
};
const kept = kind === 'reminded' ? notices(prev) : [];
for (const uid of users) {
@@@ -116,9 +147,12 @@ async function receive(db, body, { notify, revise = null, now = Date.now(), env
continue;
}
if (typeof revise !== 'function') continue;
++ const r = readable(a);
const fields = kind === 'resolved'
-- ? { title: `Resolved: ${a.name}`.slice(0, 120), icon: '✅', is_read: 1, message: `${a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }
-- : { title: `Alert: ${a.name}`.slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, message: `${a.summary}. ${message(a)} Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
++ ? { title: `Resolved: ${humanize(a.name)}`.slice(0, 120), icon: '✅', is_read: 1, rich_content: opsContent(a, 'resolved', r),
++ message: `${r.message || a.summary} (resolved after ${ago(now - (prev.opened_at || now))}${prev.reopened ? `; it fired ${prev.reopened + 1} times` : ''}).`.slice(0, 500) }
++ : { title: humanize(a.name).slice(0, 120), icon: critical ? '🚨' : '⚠️', is_read: critical ? 0 : 1, rich_content: opsContent(a, 'firing', r),
++ message: `${r.message || a.summary}. Fired again ${(prev.reopened || 0) + 1} time(s) within 12 h of resolving.`.slice(0, 500) };
for (const [uid, id] of notices(prev)) {
try { await revise(uid, id, fields); } catch (e) { console.warn('[operator-alerts] revise failed:', e.message); }
}
@@@ -132,4 +166,4 @@ async function list(db, { limit = 100 } = {}) {
return await db.prepare("SELECT source, fingerprint, name, severity, summary, service, state, started_at, opened_at, last_seen_at, notified_at, resolved_at FROM operator_alerts ORDER BY state = 'firing' DESC, COALESCE(resolved_at, last_seen_at) DESC LIMIT ?").all(limit);
}
--module.exports = { ensureTables, receive, list, operatorUserIds, REMIND_MS, FLAP_MS };
++module.exports = { ensureTables, receive, list, operatorUserIds, humanize, readable, REMIND_MS, FLAP_MS };
ddiff --git a/test/operator-alerts.test.js b/test/operator-alerts.test.js
iindex b07556d6..6fa6ab4e 100644
---- a/test/operator-alerts.test.js
++++ b/test/operator-alerts.test.js
@@@ -78,12 +78,14 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.ok(validate('network.operator-alerts-result@1', r.body).valid);
assert.deepStrictEqual(r.body, { ok: true, firing: 2, opened: 2, reminded: 0, resolved: 0, notified: 4 });
assert.deepStrictEqual(sent.map((n) => n.user_id).sort(), [1, 1, 2, 2]);
-- const backup = sent.find((n) => n.title === 'Alert: OpenVibeBackupMissed' && n.user_id === 1);
++ const backup = sent.find((n) => n.title === 'Backup missed' && n.user_id === 1);
assert.deepStrictEqual([backup.type, backup.category, backup.priority, backup.url], ['OPERATOR_ALERT', 'admin', 'critical', 'https://openvibe.network/status']);
-- assert.match(backup.message, /No successful backup in 26 hours\. Service: host\. Firing since 2026-09-26T05:00:00Z\./);
-- assert.strictEqual(sent.find((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed').priority, 'high', 'a warning pages at high');
-- assert.deepStrictEqual([backup.silent, sent.find((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed').silent], [false, true], 'only a critical alert is pushed');
-- const warnIds = sent.filter((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed').map((n, i) => n).length;
++ assert.strictEqual(backup.message, 'host · OpenVibeBackupMissed summary · No successful backup in 26 hours.');
++ assert.strictEqual(backup.rich_content.kind, 'ops_alert');
++ assert.strictEqual(backup.rich_content.state, 'firing');
++ assert.strictEqual(sent.find((n) => n.title === 'Browser check failed').priority, 'high', 'a warning pages at high');
++ assert.deepStrictEqual([backup.silent, sent.find((n) => n.title === 'Browser check failed').silent], [false, true], 'only a critical alert is pushed');
++ const warnIds = sent.filter((n) => n.title === 'Browser check failed').map((n, i) => n).length;
assert.strictEqual(warnIds, 2);
// The same set again: nothing new.
@@@ -97,7 +99,7 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.deepStrictEqual(r.body, { ok: true, firing: 1, opened: 0, reminded: 0, resolved: 1, notified: 0 });
assert.strictEqual(sent.length, 0, 'no Resolved entry piles up');
assert.strictEqual(revised.length, 2, 'one revision per operator');
-- assert.ok(revised.every((x) => x.title === 'Resolved: OpenVibeBrowserCheckFailed' && x.icon === '✅' && x.is_read === 1), JSON.stringify(revised));
++ assert.ok(revised.every((x) => x.title === 'Resolved: Browser check failed' && x.icon === '✅' && x.is_read === 1), JSON.stringify(revised));
assert.match(revised[0].message, /\(resolved after \d+ min\)\./);
const warnNotices = revised.map((x) => x.id).sort();
@@@ -106,7 +108,7 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
const later = Date.now() + alerts.REMIND_MS + 1000;
let out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: (u, n) => { sent.push({ user_id: u, ...n }); return { id: `r${sent.length}` }; }, now: later });
assert.deepStrictEqual(out, { ok: true, firing: 1, opened: 0, reminded: 1, resolved: 0, notified: 2 });
-- assert.strictEqual(sent[0].title, 'Still firing: OpenVibeBackupMissed');
++ assert.strictEqual(sent[0].title, 'Still failing: Backup missed');
out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => true, now: later + 60000 });
assert.strictEqual(out.reminded, 0);
@@@ -122,7 +124,7 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
assert.deepStrictEqual([r.body.opened, r.body.notified], [1, 0]);
assert.strictEqual(sent.length, 0);
assert.deepStrictEqual(revised.map((x) => x.id).sort(), warnNotices, 'the episode keeps its notifications');
-- assert.ok(revised.every((x) => x.title === 'Alert: OpenVibeBrowserCheckFailed' && /Fired again 1 time\(s\) within 12 h/.test(x.message)));
++ assert.ok(revised.every((x) => x.title === 'Browser check failed' && /Fired again 1 time\(s\) within 12 h/.test(x.message)));
revised.length = 0;
await post({ source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, auth);
assert.match(revised[0].message, /it fired 2 times/);
@@@ -139,7 +141,7 @@ const alert = (name, extra = {}) => ({ fingerprint: crypto.createHash('sha256').
sent.length = 0;
out = await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed'), alert('OpenVibeBrowserCheckFailed', { severity: 'warning' })] }, { notify: (u, n) => { sent.push(n); return { id: `x${sent.length}` }; }, now: much });
assert.deepStrictEqual([out.opened, out.notified], [1, 2]);
-- assert.ok(sent.every((n) => n.title === 'Alert: OpenVibeBrowserCheckFailed'));
++ assert.ok(sent.every((n) => n.title === 'Browser check failed'));
await alerts.receive(db, { source: 'prometheus', alerts: [alert('OpenVibeBackupMissed')] }, { notify: () => null, now: much + 1000 });
// An empty set resolves everything from that source; the list keeps them.