Add alert history dashboard endpoint and UI panel
Publish Container / publish (push) Successful in 2m33s

GET /api/dashboard/alert-history?days=N reads daily .jsonl files from
data/alerts/ and returns entries newest-first. Dashboard gets a lazy-loaded
collapsible panel with a table showing time, type (NWS/threshold badge),
event name, and details, with 7d/30d/90d range selector.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nate EmmertandClaude Sonnet 4.6 committed 2026-09-02 08:01:41 -04:00
1 parent 926f280a28
commit 97dfae86ab
2 files changed
+110

No files matched your search

+90
View File
@@ -229,6 +229,20 @@ a { color: var(--cool); text-decoration: none; }
.heatmap-cell[title]:hover::after { content: attr(title); position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.8); color: #fff; padding: 3px 7px; border-radius: 5px; font-size: 11px; white-space: nowrap; pointer-events: none; z-index: 10; }
.heatmap-legend { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.6rem; font-size: 0.72rem; color: var(--ink-light); }
.heatmap-legend-swatch { width: 12px; height: 12px; border-radius: 2px; }
/* Alert history table */
.alert-history-bar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.85rem; flex-wrap: wrap; }
.alert-history-table-wrap { overflow-x: auto; }
.alert-history-table { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
.alert-history-table th { text-align: left; padding: 0.4rem 0.75rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-light); border-bottom: 1px solid var(--border); white-space: nowrap; }
.alert-history-table td { padding: 0.45rem 0.75rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.alert-history-table tr:last-child td { border-bottom: none; }
.alert-history-table tr:hover td { background: var(--paper-darker); }
.ah-badge { display: inline-block; font-size: 0.68rem; font-weight: 700; padding: 0.15rem 0.45rem; border-radius: 4px; text-transform: uppercase; letter-spacing: 0.04em; white-space: nowrap; }
.ah-badge.nws { background: rgba(194,90,42,0.15); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.ah-badge.threshold { background: rgba(42,107,138,0.15); color: var(--cool); border: 1px solid color-mix(in srgb, var(--cool) 40%, transparent); }
.ah-body { color: var(--ink-light); font-size: 0.78rem; margin-top: 0.1rem; line-height: 1.4; }
.ah-empty { padding: 1.5rem; text-align: center; color: var(--ink-light); font-size: 0.85rem; }
/* Settings sections */
.settings-section-hdr { font-size: 0.72rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-light); margin: 1.25rem 0 0.6rem; padding-bottom: 0.4rem; border-bottom: 1px solid var(--border); }
.threshold-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
@@ -540,6 +554,14 @@ canvas.sparkline { display: block; width: 100%; height: 22px; margin-top: 0.4rem
</div>
</details>
<!-- Alert History -->
<details class="settings-panel" id="alertHistoryPanel" style="margin-top:1rem">
<summary>Alert History</summary>
<div class="settings-body" id="alertHistoryBody">
<div class="status">Loading…</div>
</div>
</details>
<!-- Settings -->
<details class="settings-panel" id="settingsPanel">
<summary>Station API Keys</summary>
@@ -1713,6 +1735,74 @@ document.getElementById('saveLocBtn')?.addEventListener('click', async () => {
} catch { msg.textContent = 'Error'; msg.className = 'save-msg err'; }
});
// ── Alert History ─────────────────────────────────────────────────────────────
const TAG_LABELS = {
'nws': 'NWS Alert',
'wind-threshold': 'Wind Gust',
'temp-threshold': 'Low Temp',
'aqi-threshold': 'Air Quality',
'rain-threshold': 'Rain Rate',
'temp-high': 'High Temp',
'temp-low': 'Station Low',
'freeze': 'Freeze',
'wind-station': 'Station Wind',
'station-offline': 'Station Offline',
};
let alertHistoryDays = 30;
async function loadAlertHistory(days) {
alertHistoryDays = days;
const body = document.getElementById('alertHistoryBody');
body.innerHTML = '<div class="status">Loading…</div>';
try {
const r = await fetch(`/api/dashboard/alert-history?days=${days}`);
if (!r.ok) throw new Error();
const items = await r.json();
const dayBtns = body.querySelectorAll?.('.range-btn') || [];
body.innerHTML = `
<div class="alert-history-bar">
<span style="font-size:0.82rem;color:var(--ink-light)">Range:</span>
${[7,30,90].map(d => `<button class="range-btn${d === days ? ' active' : ''}" data-ahdays="${d}">${d}d</button>`).join('')}
<span style="margin-left:auto;font-size:0.78rem;color:var(--ink-light)">${items.length} event${items.length !== 1 ? 's' : ''}</span>
</div>
${items.length === 0
? `<div class="ah-empty">No alerts logged in the last ${days} days.</div>`
: `<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>
<tbody>${items.map(it => {
const isNws = it.type === 'nws';
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 detail = isNws ? (it.headline || '') : (it.body || '');
const time = new Date(it.ts).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `<tr>
<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">${detail}</div></td>
</tr>`;
}).join('')}</tbody>
</table></div>`
}`;
body.querySelectorAll('[data-ahdays]').forEach(btn => {
btn.addEventListener('click', () => loadAlertHistory(parseInt(btn.dataset.ahdays)));
});
} catch {
document.getElementById('alertHistoryBody').innerHTML = '<div class="status error">Failed to load alert history.</div>';
}
}
const alertHistoryPanel = document.getElementById('alertHistoryPanel');
if (alertHistoryPanel) {
alertHistoryPanel.addEventListener('toggle', () => {
if (!alertHistoryPanel.open) return;
const body = document.getElementById('alertHistoryBody');
if (body.querySelector('table, .ah-empty')) return;
loadAlertHistory(alertHistoryDays);
}, { once: false });
}
// ── Scroll handler: add blur shadow to sticky header ─────────────────────────
window.addEventListener('scroll', () => {
document.querySelector('.header').classList.toggle('scrolled', window.scrollY > 5);
+20
View File
@@ -1404,6 +1404,26 @@ app.get('/api/station/trend', requireDashboardAuth, (_req, res) => {
});
});
// Alert history log
app.get('/api/dashboard/alert-history', requireDashboardAuth, (req, res) => {
const days = Math.min(90, Math.max(1, parseInt(req.query.days) || 30));
const results = [];
const now = Date.now();
for (let i = 0; i < days; i++) {
const day = new Date(now - i * 86400000).toISOString().slice(0, 10);
const file = path.join(ALERTS_DIR, `${day}.jsonl`);
if (!fs.existsSync(file)) continue;
try {
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
for (const line of lines) {
try { results.push(JSON.parse(line)); } catch {}
}
} catch {}
}
results.sort((a, b) => b.ts - a.ts);
res.json(results);
});
app.listen(PORT, () => {
console.log(`Weather app running on port ${PORT}`);
if (dashAuth) {