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
@@ -131,3 +131,17 @@ a { color: var(--ui); }
|
|||||||
.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
|
.rec-yr { display: block; font-size: 0.68rem; color: var(--ink-light); font-weight: 600; white-space: nowrap; }
|
||||||
|
|
||||||
.rec-cal tr.empty { opacity: .4; }
|
.rec-cal tr.empty { opacity: .4; }
|
||||||
|
|
||||||
|
/* Alert history: what / where / when */
|
||||||
|
.ah-what { font-size: 0.86rem; line-height: 1.4; }
|
||||||
|
.ah-meta { margin-top: 0.25rem; font-size: 0.74rem; color: var(--ink-light); line-height: 1.5; }
|
||||||
|
.ah-sev { display: inline-block; padding: 0 0.45rem; border-radius: 99px; font-weight: 700; font-size: 0.68rem; border: 1px solid var(--border); }
|
||||||
|
.ah-sev.sev-extreme { color: #ef4444; border-color: #ef4444; } .ah-sev.sev-severe { color: #f97316; border-color: #f97316; }
|
||||||
|
.ah-sev.sev-moderate { color: #eab308; border-color: #eab308; } .ah-sev.sev-minor { color: var(--cool); border-color: var(--cool); }
|
||||||
|
.ah-row { cursor: pointer; }
|
||||||
|
.ah-row.open td { background: color-mix(in srgb, var(--ui) 6%, transparent); }
|
||||||
|
.ah-full { margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px dashed var(--border); font-size: 0.82rem; line-height: 1.5; cursor: default; }
|
||||||
|
.ah-full p { margin: 0 0 0.5rem; }
|
||||||
|
.ah-desc { white-space: pre-wrap; }
|
||||||
|
.ah-meaning { padding: 0.5rem 0.7rem; border-radius: 10px; background: color-mix(in srgb, var(--ui) 10%, transparent); }
|
||||||
|
.ah-note { font-size: 0.74rem; color: var(--ink-light); }
|
||||||
+156
-30
@@ -1603,6 +1603,151 @@ const TAG_LABELS = {
|
|||||||
|
|
||||||
let alertHistoryDays = 30;
|
let alertHistoryDays = 30;
|
||||||
|
|
||||||
|
// Plain-language meaning of common NWS products, so the history says what an alert is *for*
|
||||||
|
const ALERT_GLOSSARY = {
|
||||||
|
'heat advisory': 'Dangerously hot conditions expected. Limit time outdoors, drink plenty of water, and check on children, older adults and pets.',
|
||||||
|
'excessive heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||||
|
'excessive heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||||
|
'extreme heat watch': 'Conditions are favorable for life-threatening heat in the next day or two. Plan ahead.',
|
||||||
|
'extreme heat warning': 'Life-threatening heat is occurring or imminent. Avoid outdoor activity and stay cool.',
|
||||||
|
'severe thunderstorm watch': 'Storms capable of damaging wind or large hail are possible in the area. Stay alert and be ready to take shelter.',
|
||||||
|
'severe thunderstorm warning': 'A severe storm with damaging wind and/or large hail is happening or imminent. Move indoors away from windows.',
|
||||||
|
'tornado watch': 'Conditions are favorable for tornadoes. Stay alert and know where you would take shelter.',
|
||||||
|
'tornado warning': 'A tornado is occurring or imminent. Take shelter immediately in the lowest, most interior room.',
|
||||||
|
'special weather statement': 'A short notice about a significant but non-warning weather event, often a strong storm, gusty winds, dense fog or sudden changes.',
|
||||||
|
'flood watch': 'Flooding is possible. Be prepared to move to higher ground and avoid low-lying areas.',
|
||||||
|
'flood advisory': 'Minor flooding is occurring or expected. Avoid flooded roads and low spots.',
|
||||||
|
'flood warning': 'Flooding is happening or imminent. Move to higher ground and never drive through flood water.',
|
||||||
|
'flash flood watch': 'Flash flooding is possible. Be ready to move to higher ground quickly.',
|
||||||
|
'flash flood warning': 'Flash flooding is occurring or imminent. Move to higher ground now.',
|
||||||
|
'wind advisory': 'Sustained or gusty winds that can blow around unsecured objects and make driving difficult for high-profile vehicles.',
|
||||||
|
'high wind watch': 'Damaging winds are possible. Secure outdoor objects.',
|
||||||
|
'high wind warning': 'Damaging winds are occurring or imminent. Secure objects and avoid travel if possible.',
|
||||||
|
'freeze watch': 'A freeze is possible. Protect sensitive plants, pets and exposed pipes.',
|
||||||
|
'freeze warning': 'Temperatures at or below freezing are expected. Protect plants, pets and exposed pipes.',
|
||||||
|
'frost advisory': 'Frost is expected and can damage sensitive plants.',
|
||||||
|
'hard freeze warning': 'Severe freezing temperatures are expected. Protect plants, pets and pipes.',
|
||||||
|
'winter storm watch': 'Heavy snow and/or ice is possible. Prepare for hazardous travel.',
|
||||||
|
'winter storm warning': 'Heavy snow and/or ice is occurring or imminent. Travel may be dangerous or impossible.',
|
||||||
|
'winter weather advisory': 'Snow, sleet or freezing rain will cause slippery roads and travel difficulty.',
|
||||||
|
'ice storm warning': 'Significant ice accumulation is expected. Power outages and dangerous travel are likely.',
|
||||||
|
'blizzard warning': 'Heavy snow with strong winds and very low visibility. Avoid travel.',
|
||||||
|
'wind chill advisory': 'Dangerously cold wind chills are expected. Dress in layers and limit time outside.',
|
||||||
|
'wind chill warning': 'Life-threatening wind chills are expected. Frostbite can occur quickly.',
|
||||||
|
'dense fog advisory': 'Fog is reducing visibility to a quarter mile or less. Slow down and use low-beam headlights.',
|
||||||
|
'dense smoke advisory': 'Smoke is reducing visibility and air quality.',
|
||||||
|
'air quality alert': 'Air quality is unhealthy for some or all people. Limit prolonged outdoor exertion.',
|
||||||
|
'red flag warning': 'Dry, windy and warm conditions create a high wildfire risk. Avoid open flames and outdoor burning.',
|
||||||
|
'fire weather watch': 'Conditions may soon create a high wildfire risk.',
|
||||||
|
'rip current statement': 'Dangerous rip currents at the beach. Stay out of the water or swim near a lifeguard.',
|
||||||
|
'coastal flood advisory': 'Minor coastal flooding is expected.',
|
||||||
|
'coastal flood warning': 'Significant coastal flooding is occurring or imminent.',
|
||||||
|
'small craft advisory': 'Winds or seas hazardous to small boats.',
|
||||||
|
'tropical storm watch': 'Tropical storm conditions are possible within about 48 hours.',
|
||||||
|
'tropical storm warning': 'Tropical storm conditions are expected within about 36 hours.',
|
||||||
|
'hurricane watch': 'Hurricane conditions are possible within about 48 hours.',
|
||||||
|
'hurricane warning': 'Hurricane conditions are expected within about 36 hours.',
|
||||||
|
'hazardous weather outlook': 'An outlook of possible hazardous weather in the next several days.',
|
||||||
|
'severe weather statement': 'An update on an ongoing severe storm warning.',
|
||||||
|
'flood statement': 'An update on an ongoing flood warning.',
|
||||||
|
};
|
||||||
|
function alertMeaning(event) {
|
||||||
|
const e = String(event || '').toLowerCase().trim();
|
||||||
|
if (ALERT_GLOSSARY[e]) return ALERT_GLOSSARY[e];
|
||||||
|
if (e.endsWith('watch')) return 'A Watch: conditions are favorable for this hazard. Stay alert and be ready to act.';
|
||||||
|
if (e.endsWith('warning')) return 'A Warning: this hazard is happening or imminent. Take action now.';
|
||||||
|
if (e.endsWith('advisory')) return 'An Advisory: conditions may cause inconvenience or danger if you are not careful.';
|
||||||
|
if (e.endsWith('statement')) return 'An informational statement about developing or ongoing weather.';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ahState = { items: [], open: new Set(), loading: new Set(), gone: new Set(), queue: [], running: false };
|
||||||
|
const ahWhen = (iso) => iso ? new Date(iso).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
|
||||||
|
const ahFirstSentence = (t) => {
|
||||||
|
// NWS text is often structured "* WHAT...", "* WHERE...", "* WHEN..."; lead with the WHAT part
|
||||||
|
const what = String(t || '').match(/\*\s*WHAT\.\.\.([\s\S]*?)(?:\n\s*\n|\*\s*[A-Z ]+\.\.\.|$)/);
|
||||||
|
const c = (what ? what[1] : String(t || '')).replace(/\s+/g, ' ').trim(); const m = c.match(/^.*?[.!?](\s|$)/); const r = (m ? m[0] : c).trim(); return r.length > 220 ? r.slice(0, 217) + '…' : r;
|
||||||
|
};
|
||||||
|
const ahArea = (a) => String(a || '').replace(/;\s*/g, ', ');
|
||||||
|
const SEV_ORDER = { Extreme: 'sev-extreme', Severe: 'sev-severe', Moderate: 'sev-moderate', Minor: 'sev-minor' };
|
||||||
|
|
||||||
|
function ahRow(it, i) {
|
||||||
|
const isNws = it.type === 'nws';
|
||||||
|
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
||||||
|
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
||||||
|
const open = ahState.open.has(i);
|
||||||
|
let what, meta = [];
|
||||||
|
if (isNws) {
|
||||||
|
what = it.description ? ahFirstSentence(it.description) : alertMeaning(it.event) || it.headline || '';
|
||||||
|
if (it.areaDesc) meta.push(`📍 ${esc(ahArea(it.areaDesc))}`);
|
||||||
|
else if (ahState.loading.has(it.alertId)) meta.push('📍 looking up area…');
|
||||||
|
if (it.severity && it.severity !== 'Unknown') meta.push(`<span class="ah-sev ${SEV_ORDER[it.severity] || ''}">${esc(it.severity)}</span>`);
|
||||||
|
if (it.effective || it.expires) meta.push(`🕑 ${esc(ahWhen(it.effective))}${it.expires ? ' → ' + esc(ahWhen(it.expires)) : ''}`);
|
||||||
|
} else {
|
||||||
|
what = it.body || '';
|
||||||
|
}
|
||||||
|
let full = '';
|
||||||
|
if (open) {
|
||||||
|
if (isNws) {
|
||||||
|
const meaning = alertMeaning(it.event);
|
||||||
|
const loading = ahState.loading.has(it.alertId);
|
||||||
|
full = `<div class="ah-full">
|
||||||
|
${meaning ? `<p class="ah-meaning"><b>What this means:</b> ${esc(meaning)}</p>` : ''}
|
||||||
|
${it.headline ? `<p><b>Headline:</b> ${esc(it.headline)}</p>` : ''}
|
||||||
|
${it.areaDesc ? `<p><b>Area:</b> ${esc(ahArea(it.areaDesc))}</p>` : ''}
|
||||||
|
${it.description ? `<p class="ah-desc"><b>Details:</b>\n${esc(it.description)}</p>` : loading ? '<p class="ah-note">Fetching the full text from the National Weather Service…</p>' : ahState.gone.has(it.alertId) ? '<p class="ah-note">The National Weather Service no longer provides the full text for this older alert.</p>' : ''}
|
||||||
|
${it.instruction ? `<p class="ah-desc"><b>What to do:</b>\n${esc(it.instruction)}</p>` : ''}
|
||||||
|
<p class="ah-note">${[it.severity && 'Severity: ' + it.severity, it.urgency && 'Urgency: ' + it.urgency, it.certainty && 'Certainty: ' + it.certainty, it.senderName && 'Issued by ' + it.senderName].filter(Boolean).map(esc).join(' · ')}</p>
|
||||||
|
</div>`;
|
||||||
|
} else full = `<div class="ah-full"><p>${esc(it.body || '')}</p></div>`;
|
||||||
|
}
|
||||||
|
return `<tr class="ah-row${open ? ' open' : ''}" data-i="${i}" title="Click for the full alert">
|
||||||
|
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
||||||
|
<td>${badge}</td>
|
||||||
|
<td style="font-weight:600">${esc(label)}</td>
|
||||||
|
<td><div class="ah-what">${esc(what)}</div>${meta.length ? `<div class="ah-meta">${meta.join(' · ')}</div>` : ''}${full}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ahRender() {
|
||||||
|
const tbody = document.getElementById('ahBody');
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = ahState.items.map(ahRow).join('');
|
||||||
|
tbody.querySelectorAll('.ah-full').forEach((f) => f.addEventListener('click', (e) => e.stopPropagation())); // allow selecting/copying text
|
||||||
|
tbody.querySelectorAll('.ah-row').forEach((row) => row.addEventListener('click', () => {
|
||||||
|
const i = +row.dataset.i, it = ahState.items[i];
|
||||||
|
if (ahState.open.has(i)) ahState.open.delete(i); else { ahState.open.add(i); ahLookup(it, true); }
|
||||||
|
ahRender();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch missing text/area for older NWS entries (cached on the server), a few at a time
|
||||||
|
function ahApply(id, detail) {
|
||||||
|
for (const it of ahState.items) if (it.alertId === id) for (const [k, v] of Object.entries(detail || {})) if (v != null && it[k] == null) it[k] = v;
|
||||||
|
}
|
||||||
|
function ahLookup(it, urgent) {
|
||||||
|
if (!it || it.type !== 'nws' || !it.alertId || (it.description && it.areaDesc) || ahState.gone.has(it.alertId) || ahState.loading.has(it.alertId)) return;
|
||||||
|
if (urgent) ahState.queue.unshift(it.alertId); else ahState.queue.push(it.alertId);
|
||||||
|
ahState.loading.add(it.alertId);
|
||||||
|
ahPump();
|
||||||
|
}
|
||||||
|
async function ahPump() {
|
||||||
|
if (ahState.running) return;
|
||||||
|
ahState.running = true;
|
||||||
|
while (ahState.queue.length) {
|
||||||
|
const id = ahState.queue.shift();
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/dashboard/alert-detail?id=' + encodeURIComponent(id));
|
||||||
|
if (r.ok) ahApply(id, await r.json()); else ahState.gone.add(id);
|
||||||
|
} catch { ahState.gone.add(id); }
|
||||||
|
ahState.loading.delete(id);
|
||||||
|
ahRender();
|
||||||
|
await new Promise((res) => setTimeout(res, 120));
|
||||||
|
}
|
||||||
|
ahState.running = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAlertHistory(days) {
|
async function loadAlertHistory(days) {
|
||||||
alertHistoryDays = days;
|
alertHistoryDays = days;
|
||||||
const body = document.getElementById('alertHistoryBody');
|
const body = document.getElementById('alertHistoryBody');
|
||||||
@@ -1611,7 +1756,7 @@ async function loadAlertHistory(days) {
|
|||||||
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
|
||||||
if (!r.ok) throw new Error();
|
if (!r.ok) throw new Error();
|
||||||
const items = await r.json();
|
const items = await r.json();
|
||||||
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
|
ahState.items = items; ahState.open = new Set(); ahState.queue = []; ahState.loading = new Set(); ahState.gone = new Set();
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="alert-history-bar">
|
<div class="alert-history-bar">
|
||||||
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
|
||||||
@@ -1621,35 +1766,16 @@ async function loadAlertHistory(days) {
|
|||||||
${items.length === 0
|
${items.length === 0
|
||||||
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
|
||||||
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
: `<div class="alert-history-table-wrap"><table class="alert-history-table">
|
||||||
<thead><tr><th>Time</th><th>Type</th><th>Event</th><th>Details</th></tr></thead>
|
<thead><tr><th>Time</th><th>Type</th><th>Alert</th><th>What it's for · where · when</th></tr></thead>
|
||||||
<tbody>${items.map(it => {
|
<tbody id="ahBody"></tbody>
|
||||||
const isNws = it.type === 'nws';
|
</table></div>`}`;
|
||||||
const badge = `<span class="ah-badge ${isNws ? 'nws' : 'threshold'}">${isNws ? 'NWS' : 'Threshold'}</span>`;
|
body.querySelectorAll('[data-ahdays]').forEach(btn => btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays))));
|
||||||
const label = isNws ? (it.event || 'Alert') : (TAG_LABELS[it.tag] || it.tag || '—');
|
if (items.length) {
|
||||||
const detail = isNws ? (it.headline || '') : (it.body || '');
|
ahRender();
|
||||||
const full = isNws ? [it.headline, it.description, it.instruction && ('What to do: ' + it.instruction)].filter(Boolean).join('\n\n') : detail;
|
// Look up missing details in the background, newest first
|
||||||
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
const seen = new Set();
|
||||||
return `<tr class="ah-row" data-full="${esc(full)}" title="Click to expand">
|
for (const it of items) if (it.type === 'nws' && it.alertId && !(it.description && it.areaDesc) && !seen.has(it.alertId)) { seen.add(it.alertId); ahLookup(it, false); }
|
||||||
<td style="white-space:nowrap;color:var(--ink-light);font-size:0.78rem">${time}</td>
|
}
|
||||||
<td>${badge}</td>
|
|
||||||
<td style="font-weight:600">${label}</td>
|
|
||||||
<td><div class="ah-body">${esc(detail)}</div></td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('')}</tbody>
|
|
||||||
</table></div>`
|
|
||||||
}`;
|
|
||||||
body.querySelectorAll('.ah-row').forEach(row => {
|
|
||||||
row.addEventListener('click', () => {
|
|
||||||
const el = row.querySelector('.ah-body');
|
|
||||||
const open = row.classList.toggle('open');
|
|
||||||
el.textContent = open ? row.dataset.full : el.dataset.short;
|
|
||||||
});
|
|
||||||
const el = row.querySelector('.ah-body');
|
|
||||||
el.dataset.short = el.textContent;
|
|
||||||
});
|
|
||||||
body.querySelectorAll('[data-ahdays]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
|
||||||
}
|
}
|
||||||
|
|||||||
+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
|
// Tracks NWS alert IDs already written this process lifetime to avoid duplicate log lines
|
||||||
// across multiple subscribers covering the same geographic alert.
|
// across multiple subscribers covering the same geographic alert.
|
||||||
const loggedAlertIds = new Set();
|
const loggedAlertIds = new Set();
|
||||||
@@ -530,7 +537,7 @@ try {
|
|||||||
else rebuildRecordsFromFiles();
|
else rebuildRecordsFromFiles();
|
||||||
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
} catch (e) { console.error('[records] startup rebuild error:', e.message); }
|
||||||
setInterval(saveRecordsStore, 30 * 1000).unref();
|
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) {
|
function loadStationReadings(rangeMs) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -1254,7 +1261,7 @@ async function alertWatcherCron() {
|
|||||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||||
loggedAlertIds.add(alertId);
|
loggedAlertIds.add(alertId);
|
||||||
const p = a.properties || {};
|
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;
|
const alertId = a.id || p.id;
|
||||||
if (alertId && !loggedAlertIds.has(alertId)) {
|
if (alertId && !loggedAlertIds.has(alertId)) {
|
||||||
loggedAlertIds.add(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);
|
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
|
// 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) => {
|
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
||||||
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -2071,6 +2123,12 @@ app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} 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);
|
results.sort((a, b) => b.ts - a.ts);
|
||||||
res.json(results);
|
res.json(results);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user