screentinker/Examples/PIP-Incident-Webhook/server.js
ScreenTinker ab771ec595 Add PiP overlay example recipes
Self-contained examples for the PiP overlay API (POST /api/pip), each
with a CSP-safe query-param overlay (external JS), config.example.json,
zero runtime deps, an offline test, and a README:

- PIP-Announce-Broadcast    manual one-shot message to a screen/group
- PIP-Weather-Widget        Open-Meteo current conditions (keyless)
- PIP-Air-Quality           Open-Meteo US AQI widget (keyless)
- PIP-Crypto-Ticker         CoinGecko price strip (keyless)
- PIP-News-Ticker           scrolling RSS/Atom headlines
- PIP-Room-Status-Calendar  ICS-driven Available/Busy room sign
- PIP-Event-Countdown       client-side countdown, auto-clears at zero
- PIP-Welcome-Board         rotating welcome/birthday cards from CSV
- PIP-Fundraiser-Thermometer goal-progress bar from local/URL JSON
- PIP-QR-Rotator            rotating QR codes, encoded client-side
- PIP-Incident-Webhook      event-driven: red on firing, clear on resolved

Also includes the CAP-AU (NSW RFS) and US NWS/NOAA emergency-alert
monitors that push expiry-aware PiP overlays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 20:17:38 -05:00

236 lines
9 KiB
JavaScript

'use strict';
// Event-driven PiP: an inbound webhook receiver. Instead of polling a feed, it waits for
// your monitoring stack to PUSH it incidents, then shows / clears a ScreenTinker PiP overlay
// in real time:
// - status "firing" -> POST /api/pip (red overlay, kept until cleared)
// - status "resolved" -> POST /api/pip/clear
//
// Accepts two payload shapes on POST /webhook:
// (a) generic { status:"firing"|"resolved", key, title, detail, severity }
// (b) Alertmanager{ status, alerts:[{ status, labels:{alertname,severity,...},
// annotations:{summary,description}, fingerprint }] }
//
// node server.js [path/to/config.json]
//
// Node 18+ (built-in http + global fetch). Needs an st_ API token with the 'full' scope.
const fs = require('fs');
const path = require('path');
const http = require('http');
// --- pure logic (unit-tested in test.js; no network) -------------------------------------
// severity -> overlay band colour (#RRGGBB, the PiP colour contract).
const SEV_COLORS = { critical: '7B0000', warning: 'E8730C', info: 'F2C200' };
const DEFAULT_COLOR = 'CC0000';
function colorFor(severity) {
return SEV_COLORS[String(severity || '').toLowerCase()] || DEFAULT_COLOR;
}
// Map "firing"/"resolved" (and Alertmanager's per-alert status) to our two states.
function stateOf(status) {
return String(status || '').toLowerCase() === 'resolved' ? 'resolved' : 'firing';
}
// Normalise either payload shape into a flat list of incidents:
// { key, state:"firing"|"resolved", title, detail, severity }
// `key` is the stable identity used to match a later resolve to its overlay.
function normalise(payload) {
const p = payload || {};
const out = [];
if (Array.isArray(p.alerts)) {
// Alertmanager group webhook. Each alert may carry its own status; fall back to the
// group status. fingerprint is Alertmanager's stable per-alert id.
for (const a of p.alerts) {
const labels = a.labels || {};
const ann = a.annotations || {};
const name = labels.alertname || ann.summary || 'alert';
out.push({
key: a.fingerprint || `${name}:${JSON.stringify(labels.instance || labels.job || '')}`,
state: stateOf(a.status || p.status),
title: ann.summary || name,
detail: ann.description || '',
severity: (labels.severity || 'warning').toLowerCase(),
});
}
return out;
}
// Generic single-incident shape.
const name = p.title || p.key || 'incident';
out.push({
key: p.key || name,
state: stateOf(p.status),
title: p.title || name,
detail: p.detail || '',
severity: (p.severity || 'warning').toLowerCase(),
});
return out;
}
// Build the overlay iframe URL from an incident.
function overlayUri(base, inc, sourceLabel, nowIso) {
const q = new URLSearchParams({
level: 'incident',
title: inc.title || '',
detail: inc.detail || '',
severity: inc.severity || '',
color: colorFor(inc.severity),
source: sourceLabel || '',
updated: nowIso || '',
});
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
}
module.exports = { colorFor, stateOf, normalise, overlayUri, SEV_COLORS, DEFAULT_COLOR };
// --- server (only when run directly) -----------------------------------------------------
if (require.main === module) {
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
let cfg;
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
const PORT = cfg.listen_port || 8088;
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
const API_TOKEN = cfg.api_token;
const OVERLAY_BASE = cfg.overlay_base_url;
const DEVICE = cfg.device_id;
const POSITION = cfg.position || 'top-right';
const SOURCE_LABEL = cfg.source_label || 'Monitoring';
const SECRET = cfg.shared_secret || null;
const OVERLAY = cfg.overlay || {};
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
process.exit(1);
}
// key -> pip_id of the overlay currently showing for that incident.
const active = new Map();
const nowIso = () => new Date().toISOString();
async function pipShow(inc) {
const body = {
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, inc, SOURCE_LABEL, nowIso()),
position: POSITION,
width: OVERLAY.width || 760, height: OVERLAY.height || 280,
duration: 0, // keep until we clear it on resolve
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
close_button: false,
title: inc.title,
};
const res = await fetch(`${API_BASE}/api/pip`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
return json.pip_id;
}
async function pipClear(pipId) {
const res = await fetch(`${API_BASE}/api/pip/clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
}
}
async function handleIncidents(incidents) {
const summary = { fired: 0, cleared: 0, skipped: 0 };
for (const inc of incidents) {
if (!inc.key) { summary.skipped++; continue; }
try {
if (inc.state === 'firing') {
if (active.has(inc.key)) { // refresh: clear the old card, show the new
try { await pipClear(active.get(inc.key)); } catch { /* best effort */ }
}
const pipId = await pipShow(inc);
active.set(inc.key, pipId);
summary.fired++;
console.log(`[${nowIso()}] FIRING "${inc.title}" (${inc.severity}) key=${inc.key} pip=${pipId}`);
} else {
const pipId = active.get(inc.key);
if (pipId) {
await pipClear(pipId);
active.delete(inc.key);
summary.cleared++;
console.log(`[${nowIso()}] RESOLVED key=${inc.key} pip=${pipId} (cleared)`);
} else {
summary.skipped++;
console.log(`[${nowIso()}] RESOLVED key=${inc.key} (nothing showing)`);
}
}
} catch (e) {
summary.skipped++;
console.error(`[${nowIso()}] error for key=${inc.key}: ${e.message}`);
}
}
return summary;
}
function authOk(req, url) {
if (!SECRET) return true;
const hdr = req.headers['x-webhook-secret'];
const qs = url.searchParams.get('secret');
return hdr === SECRET || qs === SECRET;
}
function readBody(req, cap = 1_000_000) {
return new Promise((resolve, reject) => {
let n = 0; const chunks = [];
req.on('data', (c) => { n += c.length; if (n > cap) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${PORT}`);
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.method === 'GET' && url.pathname === '/healthz') {
return send(200, { ok: true, active: active.size });
}
if (req.method !== 'POST' || url.pathname !== '/webhook') {
return send(404, { error: 'POST /webhook or GET /healthz' });
}
if (!authOk(req, url)) return send(401, { error: 'bad or missing shared secret' });
let payload;
try { payload = JSON.parse(await readBody(req) || '{}'); }
catch (e) { return send(400, { error: `invalid JSON: ${e.message}` }); }
const incidents = normalise(payload);
const summary = await handleIncidents(incidents);
send(200, { ok: true, received: incidents.length, ...summary });
});
server.listen(PORT, () => {
console.log(`Incident webhook receiver listening on :${PORT}`);
console.log(` POST /webhook (generic or Alertmanager JSON)${SECRET ? ' [shared secret required]' : ''}`);
console.log(` GET /healthz`);
console.log(` -> device ${DEVICE} @ ${API_BASE}, overlay ${OVERLAY_BASE}, position ${POSITION}`);
});
async function shutdown() {
console.log('\nclearing active overlays before exit...');
for (const pipId of active.values()) { try { await pipClear(pipId); } catch { /* best effort */ } }
server.close(() => process.exit(0));
setTimeout(() => process.exit(0), 1500).unref();
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
}