"""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()