Add alert history dashboard endpoint and UI panel
Publish Container / publish (push) Successful in 2m33s
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:
1 parent
926f280a28
commit
97dfae86ab
2 files changed
+110
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user