fix: сигнал не на связи переживает рестарт, самый строгий сигнал красит строку
This commit is contained in:
parent
69b483f570
commit
2757a34ed5
4 changed files with 48 additions and 24 deletions
|
|
@ -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,6 +224,7 @@ def _signals(state: SessionState, queue: list[DdsQueueCard], now: datetime) -> l
|
|||
kind="refusals", severity="warn",
|
||||
text=f"Подряд отказов: {state.consecutive_refusals}",
|
||||
))
|
||||
if live:
|
||||
connected = (
|
||||
hub.station_connected(state.session_id) if state.exercise is Exercise.DDS
|
||||
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",
|
||||
text=f"Курсант не на связи {int(offline_seconds)} с",
|
||||
))
|
||||
return signals
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
: "Нормативы не нарушены"}
|
||||
</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.map((signal) => <span key={signal.kind}>{signal.text}<br /></span>)
|
||||
: "Сигналов нет"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue