"""Live alerts across all pages: polls the API every few seconds, toasts new alerts, pins open acute ones on top.
Uses the built-in `st.fragment(run_every=...)`: only this fragment re-runs on the timer, the page is untouched
unless a new alert arrives on a doctor page (then the whole app reruns so the list and the card pick it up).
"""
from __future__ import annotations
from html import escape
import streamlit as st
from app.api.schemas import AlertOut
from dashboard import theme
from dashboard.api_client import ApiError
from dashboard.components import helpers as h
from dashboard.components import state, ui
POLL_SECONDS = 2.5
MAX_BANNER = 3
def _toast(a: AlertOut) -> None:
icon = theme.TONES[h.level_tone(a.level)].icon
st.toast(f"**{a.patient_name}** — {a.reason}", icon=icon, duration="long" if a.level == "acute" else "short")
def flush_pending_toasts() -> None:
"""Toasts queued right before a full-app rerun are shown on the next run (a rerun drops pending deltas)."""
for a in st.session_state.pop("pending_toasts", []):
_toast(a)
def _banner(acute: list[AlertOut]) -> None:
for a in acute[:MAX_BANNER]:
left, right = st.columns([8, 1.6], vertical_alignment="center")
with left:
st.html(
ui.banner(
f"Кризисный сигнал · {escape(a.patient_name)} — {escape(a.reason)}"
f'
{escape(h.when_label(a.ts))} · '
"по протоколу — связаться с пациентом сегодня
",
tone="red",
pulse=True,
)
)
with right:
if st.button(
"Карточка", key=f"acute-open-{a.id}", type="primary", icon=":material/arrow_forward:", width="stretch"
):
st.switch_page(state.PATIENTS_PAGE, query_params={"patient": a.token})
if len(acute) > MAX_BANNER:
st.caption(f"Ещё кризисных сигналов: {len(acute) - MAX_BANNER}")
def render(*, rerun_app_on_new: bool = True) -> None:
"""Place once per run (app.py does it above every page)."""
@st.fragment(run_every=POLL_SECONDS)
def _poll() -> None:
ss = st.session_state
try:
alerts = state.client().alerts(open_only=True)
except ApiError:
return # pages show their own "API недоступен" banner; the poller just retries next tick
acked = state.local_acks()
alerts = [a for a in alerts if a.acked_at is None and a.id not in acked]
seen: set[int] | None = ss.get("seen_alert_ids")
if seen is None:
# First poll of the session: what is already open is not "new" — no toast storm on page load.
ss["seen_alert_ids"] = {a.id for a in alerts}
new: list[AlertOut] = []
else:
new = h.split_new(alerts, seen)
seen.update(a.id for a in new)
if new and rerun_app_on_new:
ss["pending_toasts"] = ss.get("pending_toasts", []) + new
st.rerun(scope="app")
for a in new:
_toast(a)
_banner(h.sort_alerts(a for a in alerts if a.level == "acute"))
_poll()