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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
"""Сохраняемая часть без копирования — то, что уходит в снимок."""
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
: "Сигналов нет"}
|
: "Сигналов нет"}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue