fix: сигнал не на связи переживает рестарт, самый строгий сигнал красит строку

This commit is contained in:
kaifarikman 2026-09-27 17:22:10 +03:00
commit 2757a34ed5
4 changed files with 48 additions and 24 deletions

View file

@ -196,13 +196,21 @@ async def dds_history(
return result 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`, если появится) — очередь, Не завязаны на паузу занятия (`session.pause`, если появится) — очередь,
отказы и присутствие сокета не читают таймеры. Присутствие — по наличию отказы и присутствие сокета не читают таймеры. Присутствие — по наличию
подписчика в хабе; окно нужно, чтобы короткий разрыв соединения не сразу подписчика в хабе этого узла; окно нужно, чтобы короткий разрыв
считался потерей связи курсанта. соединения не сразу считался потерей связи курсанта.
`live=False` — снимок собран из checkpoint (сессию не держит хаб этого
процесса: рестарт до переподключения сокета или чужой узел в кластере).
Такой объект живёт один запрос и выбрасывается: писать в него
`socket_last_seen_at` бессмысленно — на следующем опросе присутствие
неизвестно снова. Честнее не утверждать «на связи», чем соврать.
""" """
settings = get_settings() settings = get_settings()
signals: list[Signal] = [] signals: list[Signal] = []
@ -216,6 +224,7 @@ def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> l
kind="refusals", severity="warn", kind="refusals", severity="warn",
text=f"Подряд отказов: {state.consecutive_refusals}", text=f"Подряд отказов: {state.consecutive_refusals}",
)) ))
if live:
connected = ( connected = (
hub.station_connected(state.session_id) if state.exercise is Exercise.DDS hub.station_connected(state.session_id) if state.exercise is Exercise.DDS
else hub.trainee_connected(state.session_id) else hub.trainee_connected(state.session_id)
@ -231,7 +240,7 @@ def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> l
kind="offline", severity="violated", kind="offline", severity="violated",
text=f"Курсант не на связи {int(offline_seconds)} с", text=f"Курсант не на связи {int(offline_seconds)} с",
)) ))
return signals return sorted(signals, key=lambda signal: signal.severity != "violated")
@router.get("/active", response_model=list[ActiveSessionOut]) @router.get("/active", response_model=list[ActiveSessionOut])
@ -247,6 +256,10 @@ async def active(
state.session_id: state state.session_id: state
for state in hub.active_sessions(who.login) for state in hub.active_sessions(who.login)
} }
#: Снимки без живой записи в хабе этого узла — рестарт до переподключения
#: сокета либо чужой узел в кластере. Присутствие сокета для них здесь
#: не проверяется: объект живёт один запрос, а не хаб (см. `_signals`).
checkpoint_only: set[UUID] = set()
if db is not None: if db is not None:
rows = ( rows = (
await db.scalars( await db.scalars(
@ -275,6 +288,7 @@ async def active(
state.owner_login = row.owner_login state.owner_login = row.owner_login
if not state.ended: if not state.ended:
states[state.session_id] = state states[state.session_id] = state
checkpoint_only.add(state.session_id)
for state in states.values(): for state in states.values():
elapsed = (max(0, int((now - state.started_at).total_seconds())) elapsed = (max(0, int((now - state.started_at).total_seconds()))
@ -312,7 +326,7 @@ async def active(
), ),
dds_statuses=latest_statuses, dds_statuses=latest_statuses,
dds_snapshot=station, dds_snapshot=station,
signals=_signals(state, queue, now), signals=_signals(state, queue, now, live=state.session_id not in checkpoint_only),
)) ))
return result return result

View file

@ -127,6 +127,11 @@ class PersistedSession(BaseModel):
#: Подряд идущих первичных отказов без принятой карточки между ними — #: Подряд идущих первичных отказов без принятой карточки между ними —
#: сигнал реестра преподавателя, не влияет на балл. #: сигнал реестра преподавателя, не влияет на балл.
consecutive_refusals: int = 0 consecutive_refusals: int = 0
#: Когда реестр преподавателя последний раз видел подключённый сокет
#: курсанта (станция ДДС или звонок 112) — для сигнала "не на связи".
#: Персистится: без этого рестарт/failover обнуляет отсчёт окна
#: присутствия и сигнал никогда не срабатывает для восстановленных сессий.
socket_last_seen_at: datetime | None = None
@field_validator("processed_station_commands") @field_validator("processed_station_commands")
@classmethod @classmethod
@ -173,9 +178,6 @@ class SessionState(PersistedSession):
voice: object | None = Field(default=None, exclude=True) voice: object | None = Field(default=None, exclude=True)
recorder: object | None = Field(default=None, exclude=True) recorder: object | None = Field(default=None, exclude=True)
recording_path: str | 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: def persisted(self) -> PersistedSession:
"""Сохраняемая часть без копирования — то, что уходит в снимок.""" """Сохраняемая часть без копирования — то, что уходит в снимок."""

View file

@ -162,6 +162,7 @@ def full_state() -> SessionState:
processed_station_commands=[str(uuid4())], processed_station_commands=[str(uuid4())],
text_revealed_facts={"f_address": "улица Ленина, 14"}, text_revealed_facts={"f_address": "улица Ленина, 14"},
consecutive_refusals=2, consecutive_refusals=2,
socket_last_seen_at=AT,
) )

View file

@ -22,6 +22,7 @@ import {
useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions, useActiveSessions, useIncidentGroups, useReviewScenarioSubmission, useScenarioSubmissions,
useScenarios, useSessions, useTrainees, useScenarios, useSessions, useTrainees,
} from "@/shared/api/http"; } from "@/shared/api/http";
import type { Signal } from "@/shared/api/http";
import { sessionIdFromUrl } from "@/shared/api/session"; import { sessionIdFromUrl } from "@/shared/api/session";
import type { Level, SessionMode, SessionReport } from "@/shared/types/generated"; 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; 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 { function sessionDateLabel(value: string | null): string | null {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);
@ -383,7 +390,7 @@ export function Instructor() {
? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}` ? `Просрочено: первичная реакция ${item.dds_overdue_cards}; отработка карточки ${item.dds_work_overdue_cards}`
: "Нормативы не нарушены"} : "Нормативы не нарушены"}
</td> </td>
<td className={item.signals.length ? `state-${item.signals[0].severity}` : "state-ok"}> <td className={`state-${worstSignalSeverity(item.signals) ?? "ok"}`}>
{item.signals.length {item.signals.length
? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>) ? item.signals.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
: "Сигналов нет"} : "Сигналов нет"}