docs: client portal design + milestone plan (M1 live view → M4 full auth) #61
@@ -242,6 +242,34 @@ async def portal_location_events(location_id: str, limit: int = 20,
|
|||||||
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
|
return {"status": "ok", "events": events, "active": sum(1 for e in events if e.get("status") == "active")}
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist of alert-rule fields shown to a client (the active limits, no cooldown/
|
||||||
|
# hysteresis internals).
|
||||||
|
_PORTAL_RULE_FIELDS = ("name", "metric", "comparison", "threshold_db", "duration_s",
|
||||||
|
"schedule_start", "schedule_end", "schedule_days")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/location/{location_id}/thresholds")
|
||||||
|
async def portal_location_thresholds(location_id: str,
|
||||||
|
client: Client = Depends(get_current_client),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
|
"""The active alert limits for a location the client owns (enabled rules only),
|
||||||
|
so the client can see what they're being alerted on. Read-only, scrubbed."""
|
||||||
|
resolve_client_location(client, location_id, db)
|
||||||
|
unit_id = active_unit_for_location(location_id, db)
|
||||||
|
if not unit_id:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as hc:
|
||||||
|
r = await hc.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/alerts/rules")
|
||||||
|
except Exception:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"status": "ok", "rules": []}
|
||||||
|
raw = (r.json() or {}).get("rules", [])
|
||||||
|
rules = [{k: x.get(k) for k in _PORTAL_RULE_FIELDS} for x in raw if x.get("enabled")]
|
||||||
|
return {"status": "ok", "rules": rules}
|
||||||
|
|
||||||
|
|
||||||
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
# -- live stream (fan-out feed, scoped + scrubbed) ---------------------------
|
||||||
|
|
||||||
def _scrub_frame(raw: str) -> str:
|
def _scrub_frame(raw: str) -> str:
|
||||||
|
|||||||
@@ -69,8 +69,14 @@
|
|||||||
</div>
|
</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 -->
|
<!-- Alert history -->
|
||||||
<div class="reveal mt-7" style="animation-delay:180ms">
|
<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 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 id="p-events" class="space-y-2"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +266,35 @@ window.addEventListener('beforeunload', closeStream);
|
|||||||
const EV_METRIC = { leq: 'Leq', lp: 'Lp', lmax: 'Lmax', lpeak: 'Lpeak', ln1: 'L1', ln2: 'L10' };
|
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() : ''; }
|
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] || 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)]">${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() {
|
async function loadEvents() {
|
||||||
try {
|
try {
|
||||||
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
const j = await (await fetch(`/portal/api/location/${encodeURIComponent(LOC_ID)}/events?limit=20`)).json();
|
||||||
@@ -293,6 +328,7 @@ prefill(); // instant first paint from cache
|
|||||||
backfill(); // seed the chart trail
|
backfill(); // seed the chart trail
|
||||||
openStream(); // then upgrade to the live feed
|
openStream(); // then upgrade to the live feed
|
||||||
loadEvents();
|
loadEvents();
|
||||||
|
loadThresholds();
|
||||||
setInterval(loadEvents, 20000);
|
setInterval(loadEvents, 20000);
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
Reference in New Issue
Block a user