fe7cf91488
- PORTAL_OPEN_LINKS now defaults OFF — /portal/open/* is an unauthenticated, proxy-reachable session-minting path (and a linked project's open link grants the whole client's scope), so it must be explicitly enabled in dev. - Session cookie: enforce server-side expiry (check iat vs COOKIE_MAX_AGE — was browser-only) and guard a non-dict signed body (was an uncaught AttributeError → 500, reachable if SECRET_KEY is the insecure default). - Escape operator-set strings (location/rule/event names) before innerHTML + Leaflet tooltips — they're client-facing, so a name with markup was stored XSS in the client's browser. Global esc() helper applied at every injection point. - WS _scrub_frame drops a non-JSON frame instead of forwarding it raw; /history rows now whitelisted like the other scoped endpoints. - Preview-client slug uses the full project id (an 8-char prefix could collide two projects onto one client). Verified: cookie reader (fresh/expired/non-dict/missing-iat) + open-links default off; templates parse; scoped scrubbing intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
17 KiB
HTML
336 lines
17 KiB
HTML
{% extends "portal/base.html" %}
|
||
{% block title %}{{ location.name }}{% endblock %}
|
||
{% block head %}
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||
{% endblock %}
|
||
{% block content %}
|
||
<a href="/portal" class="reveal inline-flex items-center gap-1.5 text-sm text-[var(--text-dim)] hover:text-[var(--text)] transition-colors">
|
||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
|
||
All locations
|
||
</a>
|
||
<div class="reveal mt-3 flex flex-wrap items-end justify-between gap-3">
|
||
<h1 class="text-3xl font-bold tracking-tight">{{ location.name }}</h1>
|
||
<div class="flex items-center gap-2.5">
|
||
<span id="p-badge" class="hidden"></span>
|
||
<span id="p-fresh" class="text-[var(--text-dim)] font-mono text-xs"></span>
|
||
</div>
|
||
</div>
|
||
|
||
{% if not has_device %}
|
||
<div class="panel reveal p-12 text-center text-[var(--text-dim)] mt-6">No device is currently assigned to this location.</div>
|
||
{% else %}
|
||
<div id="p-alarm-banner" class="hidden reveal mt-5 px-4 py-3 rounded-xl bg-[rgba(220,38,38,0.10)] border border-[rgba(220,38,38,0.32)] text-[var(--lvl-bad)] text-sm flex items-center gap-2.5">
|
||
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||
d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/>
|
||
</svg>
|
||
<span id="p-alarm-text" class="font-medium">Currently above threshold.</span>
|
||
</div>
|
||
|
||
<!-- Hero console: Leq primary + instrument strip -->
|
||
<div class="panel reveal mt-5 p-6 sm:p-7" style="animation-delay:60ms">
|
||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-1.5">Leq · average</div>
|
||
<div class="flex items-baseline gap-2.5">
|
||
<span id="p-leq" class="reading text-6xl sm:text-7xl leading-none font-semibold">--</span>
|
||
<span class="text-sm text-[var(--text-dim)] font-mono">dB</span>
|
||
</div>
|
||
|
||
<div class="hairline mt-6 pt-5 grid grid-cols-2 sm:grid-cols-4 gap-5">
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lp · instant</div>
|
||
<div class="mt-1 flex items-baseline gap-1"><span id="p-lp" class="reading text-2xl font-semibold c-lp">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">Lmax · peak</div>
|
||
<div class="mt-1 flex items-baseline gap-1"><span id="p-lmax" class="reading text-2xl font-semibold c-lmax">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L1</div>
|
||
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln1" class="reading text-2xl font-semibold c-l1">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||
</div>
|
||
<div>
|
||
<div class="text-[10px] uppercase tracking-[0.15em] text-[var(--text-dim)] font-mono">L10</div>
|
||
<div class="mt-1 flex items-baseline gap-1"><span id="p-ln2" class="reading text-2xl font-semibold c-l10">--</span><span class="text-[10px] text-[var(--text-dim)] font-mono">dB</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live trace -->
|
||
<div class="panel reveal mt-5 overflow-hidden" style="animation-delay:120ms">
|
||
<div class="px-5 pt-4 text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono">Live trace · last 2h</div>
|
||
<div class="relative px-3 pb-3 pt-2" style="min-height: 340px;">
|
||
<canvas id="p-chart"></canvas>
|
||
<div id="p-paused" class="hidden absolute inset-0 flex items-center justify-center bg-[rgba(8,11,20,0.78)] rounded-xl backdrop-blur-sm">
|
||
<button onclick="resumeStream()"
|
||
class="px-4 py-2 rounded-lg bg-seismo-orange/15 text-seismo-orange border border-seismo-orange/40 hover:bg-seismo-orange/25 text-sm font-medium transition-colors">
|
||
⏸ Live paused — tap to resume
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Alert limits (what this location is alerted on) -->
|
||
<div id="p-limits-section" class="reveal mt-7 hidden" style="animation-delay:180ms">
|
||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert limits</div>
|
||
<div id="p-thresholds" class="space-y-2"></div>
|
||
</div>
|
||
|
||
<!-- Alert history -->
|
||
<div class="reveal mt-7" style="animation-delay:220ms">
|
||
<div class="text-[10px] uppercase tracking-[0.22em] text-[var(--text-dim)] font-mono mb-3">Alert history</div>
|
||
<div id="p-events" class="space-y-2"></div>
|
||
</div>
|
||
{% endif %}
|
||
{% endblock %}
|
||
|
||
{% block scripts %}
|
||
{% if has_device %}
|
||
<script>
|
||
const LOC_ID = "{{ location.id }}";
|
||
const cd = { t: [], lp: [], leq: [], ln1: [], ln2: [] };
|
||
let chart;
|
||
const numOrNull = v => { const f = parseFloat(v); return isNaN(f) ? null : f; };
|
||
|
||
// Level color for the Leq hero (matches the overview bands).
|
||
const LEVEL_AMBER = 55, LEVEL_RED = 70;
|
||
function leqColor(measuring, v) {
|
||
// CSS var refs so the hero color auto-flips with the theme.
|
||
if (!measuring || v == null || isNaN(v)) return 'var(--text)';
|
||
if (v >= LEVEL_RED) return 'var(--lvl-bad)';
|
||
if (v >= LEVEL_AMBER) return 'var(--lvl-warn)';
|
||
return 'var(--lvl-ok)';
|
||
}
|
||
function paintLeq(measuring, leqVal) {
|
||
const el = document.getElementById('p-leq');
|
||
if (el) el.style.color = leqColor(measuring, parseFloat(leqVal));
|
||
}
|
||
|
||
function ds(label) { return { label, data: [], borderWidth: 1.5, pointRadius: 0, tension: 0.35, spanGaps: true }; }
|
||
function skinChart() {
|
||
if (!chart) return;
|
||
const dim = cssVar('--text-dim');
|
||
const cols = [cssVar('--m-lp'), cssVar('--lvl-ok'), cssVar('--m-l1'), cssVar('--m-l10')];
|
||
chart.data.datasets.forEach((d, i) => { d.borderColor = cols[i]; d.backgroundColor = cols[i]; });
|
||
const grid = 'rgba(124,146,188,0.10)', gridX = 'rgba(124,146,188,0.05)', border = 'rgba(124,146,188,0.18)';
|
||
const y = chart.options.scales.y, x = chart.options.scales.x;
|
||
y.ticks.color = dim; y.title.color = dim; y.grid.color = grid; y.border.color = border;
|
||
x.ticks.color = dim; x.grid.color = gridX; x.border.color = border;
|
||
chart.options.plugins.legend.labels.color = cssVar('--text');
|
||
chart.update('none');
|
||
}
|
||
function initChart() {
|
||
const ctx = document.getElementById('p-chart').getContext('2d');
|
||
const mono = { family: 'IBM Plex Mono', size: 10 };
|
||
chart = new Chart(ctx, {
|
||
type: 'line',
|
||
data: { labels: [], datasets: [ds('Lp'), ds('Leq'), ds('L1'), ds('L10')] },
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false, animation: false,
|
||
interaction: { intersect: false, mode: 'index' },
|
||
scales: {
|
||
y: { min: 30, max: 130, title: { display: true, text: 'dB', font: { family: 'IBM Plex Mono' } },
|
||
ticks: { font: mono }, grid: {}, border: {} },
|
||
x: { ticks: { font: mono, maxTicksLimit: 8 }, grid: {}, border: {} }
|
||
},
|
||
plugins: { legend: { labels: { font: { family: 'Hanken Grotesk' }, usePointStyle: true, pointStyleWidth: 10, boxHeight: 7 } } }
|
||
}
|
||
});
|
||
skinChart();
|
||
}
|
||
document.addEventListener('portal-theme', skinChart);
|
||
|
||
function setCard(id, v) { document.getElementById(id).textContent = (v == null || v === '') ? '--' : v; }
|
||
function setBadge(measuring, lastSeen) {
|
||
const b = document.getElementById('p-badge'), f = document.getElementById('p-fresh');
|
||
const base = 'inline-flex items-center gap-1.5 px-2.5 py-1 text-[11px] rounded-full border ';
|
||
if (measuring === null) { b.className = 'hidden'; b.textContent = ''; }
|
||
else if (measuring) { b.className = base + 'border-[rgba(244,139,28,0.45)] text-seismo-orange'; b.innerHTML = '<span class="live-dot"></span> Live'; }
|
||
else { b.className = base + 'border-[var(--border)] text-[var(--text-dim)]'; b.textContent = 'Stopped'; }
|
||
f.innerHTML = fmtFreshness(lastSeen);
|
||
}
|
||
function fmtFreshness(iso) {
|
||
if (!iso) return '<span class="text-[var(--text-dim)]">no recent reading</span>';
|
||
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||
let ago, stale = false;
|
||
if (s < 10) ago = 'just now';
|
||
else if (s < 60) ago = s + 's ago';
|
||
else if (s < 3600) { ago = Math.round(s / 60) + 'm ago'; stale = s >= 300; }
|
||
else { ago = Math.round(s / 3600) + 'h ago'; stale = true; }
|
||
const cls = stale ? 'text-amber-400' : 'text-[var(--text-dim)]';
|
||
return `as of ${t.toLocaleTimeString()} <span class="${cls}">(${ago}${stale ? ' · cached' : ''})</span>`;
|
||
}
|
||
|
||
async function prefill() {
|
||
try {
|
||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/live`)).json();
|
||
const d = j.data;
|
||
if (!d) {
|
||
setBadge(null, null);
|
||
document.getElementById('p-fresh').textContent =
|
||
j.reason === 'no_device' ? 'No device assigned' : 'Currently unreachable';
|
||
return;
|
||
}
|
||
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
||
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||
setBadge(measuring, d.last_seen);
|
||
paintLeq(measuring, d.leq);
|
||
} catch (e) { /* keep last values */ }
|
||
}
|
||
async function backfill() {
|
||
try {
|
||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/history?hours=2`)).json();
|
||
for (const row of (j.readings || [])) {
|
||
cd.t.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
|
||
cd.lp.push(numOrNull(row.lp)); cd.leq.push(numOrNull(row.leq));
|
||
cd.ln1.push(numOrNull(row.ln1)); cd.ln2.push(numOrNull(row.ln2));
|
||
}
|
||
chart.data.labels = cd.t;
|
||
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
||
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
||
chart.update('none');
|
||
} catch (e) { /* leave chart empty */ }
|
||
}
|
||
|
||
// ---- live stream (upgrades the cache prefill to a real ~1Hz feed) --------
|
||
let ws = null, hardCap = null, paused = false;
|
||
const IDLE_CAP_MS = 15 * 60 * 1000; // auto-close after 15 min so an abandoned
|
||
// tab doesn't pin the device at 1Hz polling
|
||
|
||
function pushPoint(d) {
|
||
cd.t.push(new Date().toLocaleTimeString());
|
||
cd.lp.push(numOrNull(d.lp)); cd.leq.push(numOrNull(d.leq));
|
||
cd.ln1.push(numOrNull(d.ln1)); cd.ln2.push(numOrNull(d.ln2));
|
||
if (cd.t.length > 600) { cd.t.shift(); cd.lp.shift(); cd.leq.shift(); cd.ln1.shift(); cd.ln2.shift(); }
|
||
chart.data.labels = cd.t;
|
||
chart.data.datasets[0].data = cd.lp; chart.data.datasets[1].data = cd.leq;
|
||
chart.data.datasets[2].data = cd.ln1; chart.data.datasets[3].data = cd.ln2;
|
||
chart.update('none');
|
||
}
|
||
|
||
function openStream() {
|
||
if (paused || ws) return;
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
ws = new WebSocket(`${proto}//${location.host}/portal/api/location/${encodeURIComponent(LOC_ID)}/stream`);
|
||
ws.onmessage = (e) => {
|
||
let d; try { d = JSON.parse(e.data); } catch (_) { return; }
|
||
if (d.feed_status === 'no_device') {
|
||
setBadge(null, null);
|
||
document.getElementById('p-fresh').textContent = 'No device assigned';
|
||
return;
|
||
}
|
||
if (d.heartbeat) return;
|
||
if (d.feed_status === 'unreachable') {
|
||
document.getElementById('p-fresh').innerHTML = '<span class="text-amber-400">device unreachable</span>';
|
||
return;
|
||
}
|
||
setCard('p-lp', d.lp); setCard('p-leq', d.leq); setCard('p-lmax', d.lmax);
|
||
setCard('p-ln1', d.ln1); setCard('p-ln2', d.ln2);
|
||
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||
setBadge(measuring, d.timestamp || new Date().toISOString());
|
||
paintLeq(measuring, d.leq);
|
||
pushPoint(d);
|
||
};
|
||
ws.onclose = () => { ws = null; };
|
||
ws.onerror = () => {};
|
||
clearTimeout(hardCap);
|
||
hardCap = setTimeout(() => { paused = true; closeStream(); showPaused(true); }, IDLE_CAP_MS);
|
||
}
|
||
|
||
function closeStream() {
|
||
clearTimeout(hardCap);
|
||
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
|
||
}
|
||
|
||
function showPaused(on) {
|
||
const el = document.getElementById('p-paused');
|
||
if (el) el.classList.toggle('hidden', !on);
|
||
}
|
||
function resumeStream() {
|
||
paused = false; showPaused(false);
|
||
prefill(); // refresh cards instantly on resume
|
||
openStream();
|
||
}
|
||
|
||
// Stop streaming when the tab is hidden (client switched away / locked phone) and
|
||
// resume when it's visible again — the main cost guard, so the device relaxes back
|
||
// to its idle poll rate the moment nobody is actually looking.
|
||
document.addEventListener('visibilitychange', () => {
|
||
if (document.hidden) closeStream();
|
||
else if (!paused) openStream();
|
||
});
|
||
window.addEventListener('beforeunload', closeStream);
|
||
|
||
// ---- alert history + current-alarm banner (read-only) --------------------
|
||
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
||
function fmtAlertTime(iso) { return iso ? new Date(iso.endsWith('Z') ? iso : iso + 'Z').toLocaleString() : ''; }
|
||
|
||
// ---- alert limits (the active thresholds, read-only) ---------------------
|
||
function fmtThreshold(r) {
|
||
const m = EV_METRIC[r.metric] || esc(r.metric);
|
||
const cmp = r.comparison === 'below' ? 'below' : 'above';
|
||
let s = `${m} ${cmp} ${r.threshold_db} dB`;
|
||
if (r.duration_s) s += ` for ${r.duration_s}s`;
|
||
if (r.schedule_start && r.schedule_end) s += ` · ${r.schedule_start}–${r.schedule_end}`;
|
||
return s;
|
||
}
|
||
async function loadThresholds() {
|
||
const sec = document.getElementById('p-limits-section');
|
||
try {
|
||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/thresholds`)).json();
|
||
const rules = j.rules || [];
|
||
if (!rules.length) { sec.classList.add('hidden'); return; }
|
||
const list = document.getElementById('p-thresholds');
|
||
list.innerHTML = '';
|
||
for (const r of rules) {
|
||
const row = document.createElement('div');
|
||
row.className = 'panel px-3.5 py-2.5 text-sm flex items-center gap-2.5';
|
||
row.innerHTML = `<span class="w-1.5 h-1.5 rounded-full bg-seismo-orange shrink-0"></span>
|
||
<span class="text-[var(--text)]">${esc(r.name || 'Alert')}</span>
|
||
<span class="text-[var(--text-dim)] font-mono text-xs">${fmtThreshold(r)}</span>`;
|
||
list.appendChild(row);
|
||
}
|
||
sec.classList.remove('hidden');
|
||
} catch (e) { sec.classList.add('hidden'); }
|
||
}
|
||
|
||
async function loadEvents() {
|
||
try {
|
||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
||
const events = j.events || [];
|
||
const banner = document.getElementById('p-alarm-banner');
|
||
if (j.active) {
|
||
banner.classList.remove('hidden');
|
||
document.getElementById('p-alarm-text').textContent =
|
||
j.active > 1 ? `${j.active} alerts currently active` : 'Currently above threshold.';
|
||
} else banner.classList.add('hidden');
|
||
const list = document.getElementById('p-events');
|
||
if (!events.length) { list.innerHTML = '<div class="text-sm text-[var(--text-dim)]">No alerts have fired.</div>'; return; }
|
||
list.innerHTML = '';
|
||
for (const e of events) {
|
||
const m = EV_METRIC[e.metric] || esc(e.metric);
|
||
const active = e.status === 'active';
|
||
const when = active ? `since ${fmtAlertTime(e.onset_at)}`
|
||
: `${fmtAlertTime(e.onset_at)} → ${fmtAlertTime(e.clear_at)}`;
|
||
const peak = (e.peak_value != null) ? ` · peak ${e.peak_value} dB` : '';
|
||
const row = document.createElement('div');
|
||
row.className = 'panel px-3.5 py-2.5 text-sm ' + (active ? 'border-[rgba(220,38,38,0.4)]' : '');
|
||
row.innerHTML = `<div class="${active ? 'text-[var(--lvl-bad)] font-medium' : 'text-[var(--text)]'}">${esc(e.rule_name || 'Alert')} <span class="text-xs text-[var(--text-dim)] font-mono">· ${m} ${e.threshold_db} dB</span></div>
|
||
<div class="text-xs text-[var(--text-dim)] font-mono mt-0.5">${when}${peak}</div>`;
|
||
list.appendChild(row);
|
||
}
|
||
} catch (e) { /* leave history as-is */ }
|
||
}
|
||
|
||
initChart();
|
||
prefill(); // instant first paint from cache
|
||
backfill(); // seed the chart trail
|
||
openStream(); // then upgrade to the live feed
|
||
loadEvents();
|
||
loadThresholds();
|
||
setInterval(loadEvents, 20000);
|
||
</script>
|
||
{% endif %}
|
||
{% endblock %}
|