feat(portal): M1 pages — locations overview + read-only live location view
/portal overview: client's active sound locations as live tiles (current Lp +
Live/Stopped badge + "updated Xm ago", polled from the scoped cache every 15s)
plus a Leaflet map of locations with coordinates. /portal/location/{id}: 404-gated
read-only live panel — Lp/Leq/Lmax/L1/L10 cards + a 4-line Chart.js trace
(backfilled from /history) + measuring/freshness badge. Cache-only, 15s poll, no
device controls, no refresh-from-device. _client_locations() feeds the overview.
Verified: portal.py compiles; both inline scripts balance; all four portal
templates parse in Jinja2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,26 @@ def active_unit_for_location(location_id: str, db: Session):
|
|||||||
return asg.unit_id if asg else None
|
return asg.unit_id if asg else None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_locations(client: Client, db: Session) -> list:
|
||||||
|
"""The client's active sound locations (for the overview tiles + map)."""
|
||||||
|
pids = _client_project_ids(client, db)
|
||||||
|
if not pids:
|
||||||
|
return []
|
||||||
|
projs = {p.id: p.name for p in
|
||||||
|
db.query(Project.id, Project.name).filter(Project.id.in_(pids)).all()}
|
||||||
|
locs = (db.query(MonitoringLocation)
|
||||||
|
.filter(MonitoringLocation.project_id.in_(pids),
|
||||||
|
MonitoringLocation.location_type == "sound",
|
||||||
|
MonitoringLocation.removed_at.is_(None))
|
||||||
|
.order_by(MonitoringLocation.sort_order, MonitoringLocation.name).all())
|
||||||
|
return [{
|
||||||
|
"id": loc.id, "name": loc.name,
|
||||||
|
"address": loc.address, "coordinates": loc.coordinates,
|
||||||
|
"project_name": projs.get(loc.project_id),
|
||||||
|
"has_device": active_unit_for_location(loc.id, db) is not None,
|
||||||
|
} for loc in locs]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/enter/{token}")
|
@router.get("/enter/{token}")
|
||||||
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
|
def portal_enter(token: str, request: Request, db: Session = Depends(get_db)):
|
||||||
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
|
"""Magic-URL entry: validate the token, mint a session cookie, land on /portal."""
|
||||||
@@ -101,14 +121,28 @@ def portal_access(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def portal_home(request: Request, client: Client = Depends(get_current_client)):
|
def portal_home(request: Request, client: Client = Depends(get_current_client),
|
||||||
"""Client overview. (M1 task 4 fills in the scoped location list + map.)"""
|
db: Session = Depends(get_db)):
|
||||||
|
"""Client overview — their active sound locations with live tiles + a map."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"portal/overview.html",
|
"portal/overview.html",
|
||||||
{"request": request, "client": client, "locations": []},
|
{"request": request, "client": client,
|
||||||
|
"locations": _client_locations(client, db)},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/location/{location_id}")
|
||||||
|
def portal_location(location_id: str, request: Request,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""Read-only live view for one of the client's locations (404 if not owned)."""
|
||||||
|
loc = resolve_client_location(client, location_id, db)
|
||||||
|
return templates.TemplateResponse("portal/location.html", {
|
||||||
|
"request": request, "client": client, "location": loc,
|
||||||
|
"has_device": active_unit_for_location(location_id, db) is not None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
# -- scoped data (cache reads only — never hits the device) ------------------
|
# -- scoped data (cache reads only — never hits the device) ------------------
|
||||||
|
|
||||||
@router.get("/api/location/{location_id}/live")
|
@router.get("/api/location/{location_id}/live")
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
{% 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="text-sm text-gray-400 hover:text-gray-200">← All locations</a>
|
||||||
|
<h1 class="text-2xl font-semibold mt-1">{{ location.name }}</h1>
|
||||||
|
<div class="flex items-center gap-2 text-sm mt-1 mb-6">
|
||||||
|
<span id="p-badge" class="hidden px-2 py-0.5 text-xs font-medium rounded-full"></span>
|
||||||
|
<span id="p-fresh" class="text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not has_device %}
|
||||||
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
|
||||||
|
No device is currently assigned to this location.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3 mb-6">
|
||||||
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
|
<div class="text-xs text-gray-400">Lp (Instant)</div>
|
||||||
|
<div id="p-lp" class="text-2xl font-bold text-blue-400">--</div><div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
|
<div class="text-xs text-gray-400">Leq (Average)</div>
|
||||||
|
<div id="p-leq" class="text-2xl font-bold text-green-400">--</div><div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
|
<div class="text-xs text-gray-400">Lmax (Max)</div>
|
||||||
|
<div id="p-lmax" class="text-2xl font-bold text-red-400">--</div><div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
|
<div class="text-xs text-gray-400">L1</div>
|
||||||
|
<div id="p-ln1" class="text-2xl font-bold text-purple-400">--</div><div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg bg-slate-800/60 border border-slate-700 p-3">
|
||||||
|
<div class="text-xs text-gray-400">L10</div>
|
||||||
|
<div id="p-ln2" class="text-2xl font-bold text-orange-400">--</div><div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-4" style="min-height: 360px;">
|
||||||
|
<canvas id="p-chart"></canvas>
|
||||||
|
</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; };
|
||||||
|
|
||||||
|
function ds(label, color) {
|
||||||
|
return { label, data: [], borderColor: color, backgroundColor: color,
|
||||||
|
borderWidth: 2, pointRadius: 0, tension: 0.3, spanGaps: true };
|
||||||
|
}
|
||||||
|
function initChart() {
|
||||||
|
const ctx = document.getElementById('p-chart').getContext('2d');
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: { labels: [], datasets: [
|
||||||
|
ds('Lp', 'rgb(96,165,250)'), ds('Leq', 'rgb(74,222,128)'),
|
||||||
|
ds('L1', 'rgb(192,132,252)'), ds('L10', 'rgb(251,146,60)') ] },
|
||||||
|
options: {
|
||||||
|
responsive: true, maintainAspectRatio: false, animation: false,
|
||||||
|
interaction: { intersect: false, mode: 'index' },
|
||||||
|
scales: {
|
||||||
|
y: { min: 30, max: 130, title: { display: true, text: 'dB', color: '#94a3b8' },
|
||||||
|
ticks: { color: '#94a3b8' }, grid: { color: 'rgba(148,163,184,0.12)' } },
|
||||||
|
x: { ticks: { color: '#94a3b8', maxTicksLimit: 8 }, grid: { color: 'rgba(148,163,184,0.12)' } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { color: '#cbd5e1' } } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (measuring === null) { b.className = 'hidden px-2 py-0.5 text-xs font-medium rounded-full'; b.textContent = ''; }
|
||||||
|
else if (measuring) { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-green-900/40 text-green-300'; b.textContent = '● Live'; }
|
||||||
|
else { b.className = 'px-2 py-0.5 text-xs font-medium rounded-full bg-slate-700 text-gray-300'; b.textContent = '■ Stopped'; }
|
||||||
|
f.innerHTML = fmtFreshness(lastSeen);
|
||||||
|
}
|
||||||
|
function fmtFreshness(iso) {
|
||||||
|
if (!iso) return '<span class="text-gray-500">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-gray-400';
|
||||||
|
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);
|
||||||
|
} 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 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
initChart();
|
||||||
|
prefill();
|
||||||
|
backfill();
|
||||||
|
setInterval(prefill, 15000); // cache poll — read-only, no device contention
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
{% extends "portal/base.html" %}
|
{% extends "portal/base.html" %}
|
||||||
{% block title %}Your locations{% endblock %}
|
{% block title %}Your locations{% endblock %}
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="text-2xl font-semibold mb-1">Your monitoring locations</h1>
|
<h1 class="text-2xl font-semibold mb-1">Your monitoring locations</h1>
|
||||||
<p class="text-gray-400 text-sm mb-6">Live sound levels for your active locations.</p>
|
<p class="text-gray-400 text-sm mb-6">Live sound levels for your active locations. Read-only.</p>
|
||||||
|
|
||||||
{# M1 task 4 fleshes this out into location tiles + a map. #}
|
|
||||||
{% if locations %}
|
{% if locations %}
|
||||||
|
<div id="loc-map" class="h-64 rounded-xl overflow-hidden mb-6 hidden border border-slate-700"></div>
|
||||||
|
|
||||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{% for loc in locations %}
|
{% for loc in locations %}
|
||||||
<a href="/portal/location/{{ loc.id }}" class="block rounded-xl border border-slate-700 bg-slate-800/50 p-4 hover:border-seismo-orange transition-colors">
|
<a href="/portal/location/{{ loc.id }}" data-loc="{{ loc.id }}"
|
||||||
|
class="loc-tile block rounded-xl border border-slate-700 bg-slate-800/50 p-4 hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="font-semibold">{{ loc.name }}</div>
|
<div class="font-semibold">{{ loc.name }}</div>
|
||||||
<div class="text-xs text-gray-400 mt-1">{{ loc.address or loc.project_name }}</div>
|
<span class="loc-badge hidden shrink-0 px-2 py-0.5 text-xs rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-0.5 truncate">{{ loc.address or loc.project_name or '' }}</div>
|
||||||
|
<div class="mt-3 flex items-baseline gap-1">
|
||||||
|
<span class="loc-lp text-3xl font-bold text-seismo-orange">--</span>
|
||||||
|
<span class="text-sm text-gray-400">dB Lp</span>
|
||||||
|
</div>
|
||||||
|
<div class="loc-fresh text-xs text-gray-500 mt-1"> </div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -20,3 +33,67 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
const LOCATIONS = {{ locations|tojson }};
|
||||||
|
|
||||||
|
function fmtAgo(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const t = new Date(iso.endsWith('Z') ? iso : iso + 'Z');
|
||||||
|
const s = Math.max(0, Math.round((Date.now() - t.getTime()) / 1000));
|
||||||
|
if (s < 60) return 'updated just now';
|
||||||
|
if (s < 3600) return 'updated ' + Math.round(s / 60) + 'm ago';
|
||||||
|
return 'updated ' + Math.round(s / 3600) + 'h ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTile(loc) {
|
||||||
|
const el = document.querySelector(`.loc-tile[data-loc="${loc.id}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
const lp = el.querySelector('.loc-lp'), badge = el.querySelector('.loc-badge'),
|
||||||
|
fresh = el.querySelector('.loc-fresh');
|
||||||
|
try {
|
||||||
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(loc.id)}/live`)).json();
|
||||||
|
const d = j.data;
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
if (!d) {
|
||||||
|
badge.textContent = j.reason === 'no_device' ? 'No device' : 'Offline';
|
||||||
|
badge.className = 'loc-badge shrink-0 px-2 py-0.5 text-xs rounded-full bg-slate-700 text-gray-300';
|
||||||
|
lp.textContent = '--'; fresh.innerHTML = ' ';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lp.textContent = (d.lp == null || d.lp === '') ? '--' : d.lp;
|
||||||
|
const measuring = d.measurement_state === 'Start' || d.measurement_state === 'Measure';
|
||||||
|
badge.textContent = measuring ? '● Live' : 'Stopped';
|
||||||
|
badge.className = 'loc-badge shrink-0 px-2 py-0.5 text-xs rounded-full ' +
|
||||||
|
(measuring ? 'bg-green-900/40 text-green-300' : 'bg-slate-700 text-gray-300');
|
||||||
|
fresh.textContent = fmtAgo(d.last_seen);
|
||||||
|
} catch (e) { /* leave placeholders */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTiles() { LOCATIONS.forEach(loadTile); }
|
||||||
|
refreshTiles();
|
||||||
|
setInterval(refreshTiles, 15000);
|
||||||
|
|
||||||
|
// Map of locations that have coordinates
|
||||||
|
const withCoords = LOCATIONS.filter(l => l.coordinates);
|
||||||
|
if (withCoords.length) {
|
||||||
|
const mapEl = document.getElementById('loc-map');
|
||||||
|
mapEl.classList.remove('hidden');
|
||||||
|
const map = L.map('loc-map');
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
{ maxZoom: 19, attribution: '© OpenStreetMap' }).addTo(map);
|
||||||
|
const pts = [];
|
||||||
|
withCoords.forEach(l => {
|
||||||
|
const [la, lo] = (l.coordinates || '').split(',').map(Number);
|
||||||
|
if (!isNaN(la) && !isNaN(lo)) {
|
||||||
|
L.marker([la, lo]).addTo(map).bindPopup(l.name);
|
||||||
|
pts.push([la, lo]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (pts.length) map.fitBounds(pts, { padding: [30, 30], maxZoom: 15 });
|
||||||
|
else mapEl.classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user