Alert history: say what each alert is for, where, and when
Publish Container / publish (push) Successful in 3m33s
Publish Container / publish (push) Successful in 3m33s
- Plain-language meaning for common NWS products, shown in the row and expanded view - Details column: reason, area, severity, effective window; click for full text/instructions - New alerts log area, severity, urgency, certainty and sender - Older entries are filled in from NWS by alert id (cached in data/alert-details.json); alerts NWS no longer serves fall back to the built-in meaning Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72d813bd61
commit
114386a1b2
3 files changed
+231
-33
No files matched your search
+61
-3
@@ -190,6 +190,13 @@ function recordAlertLog(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fields worth keeping from an NWS alert so the history can say what it was for and where
|
||||
function nwsLogFields(p) {
|
||||
return { event: p.event, headline: p.headline, description: p.description, instruction: p.instruction,
|
||||
areaDesc: p.areaDesc, severity: p.severity, urgency: p.urgency, certainty: p.certainty, senderName: p.senderName,
|
||||
effective: p.effective, expires: p.expires };
|
||||
}
|
||||
|
||||
// Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines
|
||||
// across multiple subscribers covering the same geographic alert.
|
||||
const loggedAlertIds = new Set();
|
||||
@@ -530,7 +537,7 @@ try {
|
||||
else rebuildRecordsFromFiles();
|
||||
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
||||
setInterval(saveRecordsStore, 30 * 1000).unref();
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); process.exit(0); });
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => { saveRecordsStore(); try { saveAlertDetails(); } catch {} process.exit(0); });
|
||||
|
||||
function loadStationReadings(rangeMs) {
|
||||
const now = Date.now();
|
||||
@@ -1254,7 +1261,7 @@ async function alertWatcherCron() {
|
||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||
loggedAlertIds.add(alertId);
|
||||
const p = a.properties || {};
|
||||
recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: dashLat, lon: dashLon });
|
||||
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: dashLat, lon: dashLon });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1280,7 +1287,7 @@ async function alertWatcherCron() {
|
||||
const alertId = a.id || p.id;
|
||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||
loggedAlertIds.add(alertId);
|
||||
recordAlertLog({ type: 'nws', alertId, event: p.event, headline: p.headline, description: p.description, instruction: p.instruction, effective: p.effective, expires: p.expires, lat: sub.lat, lon: sub.lon });
|
||||
recordAlertLog({ type: 'nws', alertId, ...nwsLogFields(p), lat: sub.lat, lon: sub.lon });
|
||||
}
|
||||
}
|
||||
sub.seenAlertIds = [...new Set([...sub.seenAlertIds, ...newAlerts.map(f => f.id || f.properties?.id)])].slice(-200);
|
||||
@@ -2056,6 +2063,51 @@ app.get('/api/station/trend', requireDashboardAuth, (_req, res) => {
|
||||
});
|
||||
|
||||
// Alert history log
|
||||
// ── NWS alert details (looked up by alert id, cached on disk) ────────────────
|
||||
const ALERT_DETAIL_FILE = path.join(DATA_DIR, 'alert-details.json');
|
||||
let alertDetails = {};
|
||||
try { alertDetails = JSON.parse(fs.readFileSync(ALERT_DETAIL_FILE, 'utf8')); } catch {}
|
||||
let alertDetailsDirty = false;
|
||||
function saveAlertDetails() {
|
||||
if (!alertDetailsDirty) return;
|
||||
try { fs.writeFileSync(ALERT_DETAIL_FILE + '.tmp', JSON.stringify(alertDetails)); fs.renameSync(ALERT_DETAIL_FILE + '.tmp', ALERT_DETAIL_FILE); alertDetailsDirty = false; }
|
||||
catch (e) { console.error('[alerts] detail cache save error:', e.message); }
|
||||
}
|
||||
setInterval(saveAlertDetails, 30 * 1000).unref();
|
||||
|
||||
const ALERT_ID_RE = /^https:\/\/api\.weather\.gov\/alerts\/[A-Za-z0-9:._-]+$/;
|
||||
const alertLookupsInFlight = new Map();
|
||||
async function lookupAlertDetail(id) {
|
||||
const hit = alertDetails[id];
|
||||
if (hit && !hit.unavailable) return hit;
|
||||
if (hit && hit.unavailable && Date.now() - hit.at < 6 * 3600000) return hit; // don't hammer NWS for gone alerts
|
||||
if (alertLookupsInFlight.has(id)) return alertLookupsInFlight.get(id);
|
||||
const job = (async () => {
|
||||
try {
|
||||
const r = await fetchWithRetry(id, { attempts: 1, timeoutMs: 10000, fetchOptions: { headers: { 'User-Agent': 'weather-app-v2 (nate@necloud.us)', Accept: 'application/geo+json' } } });
|
||||
const f = await r.json();
|
||||
const p = f.properties || f;
|
||||
if (!p.event) throw new Error('no event');
|
||||
alertDetails[id] = { ...nwsLogFields(p), fetchedAt: Date.now() };
|
||||
} catch (e) {
|
||||
alertDetails[id] = { unavailable: true, at: Date.now() };
|
||||
}
|
||||
alertDetailsDirty = true;
|
||||
alertLookupsInFlight.delete(id);
|
||||
return alertDetails[id];
|
||||
})();
|
||||
alertLookupsInFlight.set(id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
app.get('/api/dashboard/alert-detail', requireDashboardAuth, async (req, res) => {
|
||||
const id = String(req.query.id || '');
|
||||
if (!ALERT_ID_RE.test(id)) return res.status(400).json({ error: 'Invalid alert id' });
|
||||
const d = await lookupAlertDetail(id);
|
||||
if (d.unavailable) return res.status(404).json({ error: 'NWS no longer provides this alert' });
|
||||
res.json(d);
|
||||
});
|
||||
|
||||
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||||
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
||||
const results = [];
|
||||
@@ -2071,6 +2123,12 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Fill in text/area for older entries from the detail cache when we have it
|
||||
for (const e of results) {
|
||||
if (e.type !== 'nws' || e.description || !e.alertId) continue;
|
||||
const d = alertDetails[e.alertId];
|
||||
if (d && !d.unavailable) Object.assign(e, Object.fromEntries(Object.entries(d).filter(([k, v]) => v != null && e[k] == null)));
|
||||
}
|
||||
results.sort((a, b) => b.ts - a.ts);
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user