From 2757a34ed53fafde1709d84f859e7132c5f89773 Mon Sep 17 00:00:00 2001 From: kaifarikman Date: Sun, 27 Sep 2026 17:22:10 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BD=D0=B0=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D1=82=D0=B0=D1=80=D1=82,=20=D1=81=D0=B0?= =?UTF-8?q?=D0=BC=D1=8B=D0=B9=20=D1=81=D1=82=D1=80=D0=BE=D0=B3=D0=B8=D0=B9?= =?UTF-8?q?=20=D1=81=D0=B8=D0=B3=D0=BD=D0=B0=D0=BB=20=D0=BA=D1=80=D0=B0?= =?UTF-8?q?=D1=81=D0=B8=D1=82=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/http/sessions.py | 54 ++++++++++++-------- backend/app/session/state.py | 8 +-- backend/tests/test_checkpoint_model.py | 1 + frontend/src/pages/instructor/Instructor.tsx | 9 +++- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/backend/app/api/http/sessions.py b/backend/app/api/http/sessions.py index d5f3b4d..a414536 100644 --- a/backend/app/api/http/sessions.py +++ b/backend/app/api/http/sessions.py @@ -196,13 +196,21 @@ async def dds_history( return result -def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> list[Signal]: +def _signals( + state: SessionState, queue: list[DdsQueueCard], now: datetime, *, live: bool +) -> list[Signal]: """Сигналы реестра: очередь, повторные отказы, курсант не на связи. Не завязаны на паузу занятия (`session.pause`, если появится) — очередь, отказы и присутствие сокета не читают таймеры. Присутствие — по наличию - подписчика в хабе; окно нужно, чтобы короткий разрыв соединения не сразу - считался потерей связи курсанта. + подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв + соединения не сразу считался потерей связи курсанта. + + `live=False` — снимок собран из checkpoint (сессию не держит хаб этого + процесса: рестарт до переподключения сокета или чужой узел в кластере). + Такой объект живёт один запрос и выбрасывается: писать в него + `socket_last_seen_at` бессмысленно — на следующем опросе присутствие + неизвестно снова. Честнее не утверждать «на связи», чем соврать. """ settings = get_settings() signals: list[Signal] = [] @@ -216,22 +224,23 @@ def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> l kind="refusals", severity="warn", text=f"Подряд отказов: {state.consecutive_refusals}", )) - connected = ( - hub.station_connected(state.session_id) if state.exercise is Exercise.DDS - else hub.trainee_connected(state.session_id) - ) - if connected: - state.socket_last_seen_at = now - offline_seconds = ( - (now - state.socket_last_seen_at).total_seconds() - if state.socket_last_seen_at is not None else 0 - ) - if offline_seconds >= settings.signal_offline_window_seconds: - signals.append(Signal( - kind="offline", severity="violated", - text=f"Курсант не на связи {int(offline_seconds)} с", - )) - return signals + if live: + connected = ( + hub.station_connected(state.session_id) if state.exercise is Exercise.DDS + else hub.trainee_connected(state.session_id) + ) + if connected: + state.socket_last_seen_at = now + offline_seconds = ( + (now - state.socket_last_seen_at).total_seconds() + if state.socket_last_seen_at is not None else 0 + ) + if offline_seconds >= settings.signal_offline_window_seconds: + signals.append(Signal( + kind="offline", severity="violated", + text=f"Курсант не на связи {int(offline_seconds)} с", + )) + return sorted(signals, key=lambda signal: signal.severity != "violated") @router.get("/active", response_model=list[ActiveSessionOut]) @@ -247,6 +256,10 @@ async def active( state.session_id: state for state in hub.active_sessions(who.login) } + #: Снимки без живой записи в хабе этого узла — рестарт до переподключения + #: сокета либо чужой узел в кластере. Присутствие сокета для них здесь + #: не проверяется: объект живёт один запрос, а не хаб (см. `_signals`). + checkpoint_only: set[UUID] = set() if db is not None: rows = ( await db.scalars( @@ -275,6 +288,7 @@ async def active( state.owner_login = row.owner_login if not state.ended: states[state.session_id] = state + checkpoint_only.add(state.session_id) for state in states.values(): elapsed = (max(0, int((now - state.started_at).total_seconds())) @@ -312,7 +326,7 @@ async def active( ), dds_statuses=latest_statuses, dds_snapshot=station, - signals=_signals(state, queue, now), + signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only), )) return result diff --git a/backend/app/session/state.py b/backend/app/session/state.py index 38a1491..0e51d4d 100644 --- a/backend/app/session/state.py +++ b/backend/app/session/state.py @@ -127,6 +127,11 @@ class PersistedSession(BaseModel): #: Подряд идущих первичных отказов без принятой карточки между ними — #: сигнал реестра преподавателя, не влияет на балл. consecutive_refusals: int = 0 + #: Когда реестр преподавателя последний раз видел подключённый сокет + #: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи". + #: Персистится: без этого рестарт/failover обнуляет отсчёт окна + #: присутствия и сигнал никогда не срабатывает для восстановленных сессий. + socket_last_seen_at: datetime | None = None @field_validator("processed_station_commands") @classmethod @@ -173,9 +178,6 @@ class SessionState(PersistedSession): voice: object | None = Field(default=None, exclude=True) recorder: object | None = Field(default=None, exclude=True) recording_path: str | None = Field(default=None, exclude=True) - #: Когда реестр преподавателя последний раз видел подключённый сокет - #: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи". - socket_last_seen_at: datetime | None = Field(default=None, exclude=True) def persisted(self) -> PersistedSession: """Сохраняемая часть без копирования — то, что уходит в снимок.""" diff --git a/backend/tests/test_checkpoint_model.py b/backend/tests/test_checkpoint_model.py index 1402aad..cd79f6a 100644 --- a/backend/tests/test_checkpoint_model.py +++ b/backend/tests/test_checkpoint_model.py @@ -162,6 +162,7 @@ def full_state() -> SessionState: processed_station_commands=[str(uuid4())], text_revealed_facts={"f_address": "улица Ленина, 14"}, consecutive_refusals=2, + socket_last_seen_at=AT, ) diff --git a/frontend/src/pages/instructor/Instructor.tsx b/frontend/src/pages/instructor/Instructor.tsx index d7dd1de..8a9f9f8 100644 --- a/frontend/src/pages/instructor/Instructor.tsx +++ b/frontend/src/pages/instructor/Instructor.tsx @@ -22,6 +22,7 @@ import { useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions, useScenarios, useSessions, useTrainees, } from "@/shared/api/http"; +import type { Signal } from "@/shared/api/http"; import { sessionIdFromUrl } from "@/shared/api/session"; import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; @@ -44,6 +45,12 @@ function modeLabel(mode: SessionMode): string { return MODES.find((item) => item.value === mode)?.label ?? mode; } +function worstSignalSeverity(signals: Signal[]): "warn" | "violated" | null { + return signals.some((signal) => signal.severity === "violated") + ? "violated" + : signals.length ? "warn" : null; +} + function sessionDateLabel(value: string | null): string | null { if (!value) return null; const date = new Date(value); @@ -383,7 +390,7 @@ export function Instructor() { ? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}` : "Нормативы не нарушены"} - + {item.signals.length ? item.signals.map((signal) => {signal.text}
) : "Сигналов нет"}