Files
NODEDC_MISSION_CORE/plugins/xgrids-k1/frontend/src/activeStreamRecovery.ts
T

279 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
isXgridsActiveStreamRecovery,
type XgridsActiveStreamRecovery,
type XgridsK1State,
} from "./api";
export interface ActiveStreamRecoveryLineage {
snapshotRuntimeId: string;
acquisitionId: string;
acquisitionStateRevision: number;
recoveryGeneration: number;
runtimeProducerGeneration: number;
recovery: XgridsActiveStreamRecovery;
}
export type ActiveStreamForceFinishAuthority = ActiveStreamRecoveryLineage;
export type ActiveStreamRecoveryPresentationAuthority = ActiveStreamRecoveryLineage;
export type ActiveStreamRecoveryVisibleState =
| "reconnecting"
| "blocked"
| "standby"
| "fault";
export interface ActiveStreamRecoveryPresentation {
state: ActiveStreamRecoveryVisibleState;
eyebrow: string;
title: string;
statusLabel: string;
tone: "neutral" | "warning" | "danger";
detail: string;
progressLabel: string | null;
showSpinner: boolean;
forceFinishAvailable: boolean;
}
function positiveInteger(value: unknown): value is number {
return Number.isInteger(value) && (value as number) > 0;
}
/**
* Resolve one exact active-stream lineage from the public runtime snapshot.
*
* A recovery-shaped object alone is not authority. The browser also requires
* the current runtime id, the same acquisition id and the exact producer
* generation on both sides of the projection. This keeps a late recovery
* update from an older producer out of both presentation and mutation gates.
*/
export function exactActiveStreamRecoveryLineage(
state: XgridsK1State | null | undefined,
): ActiveStreamRecoveryLineage | null {
const recovery = state?.connection_recovery;
const acquisition = state?.acquisition;
const snapshotRuntimeId = state?.snapshot_runtime_id?.trim() || null;
const producerGeneration = state?.producer_generation;
const acquisitionId = acquisition?.acquisition_id?.trim() || null;
const recoveryAcquisitionId = recovery?.acquisition_id?.trim() || null;
if (
!snapshotRuntimeId
|| !isXgridsActiveStreamRecovery(recovery)
|| !acquisition
|| !acquisitionId
|| recoveryAcquisitionId !== acquisitionId
|| !positiveInteger(acquisition.state_revision)
|| !positiveInteger(recovery.generation)
|| !positiveInteger(producerGeneration)
|| recovery.runtime_producer_generation !== producerGeneration
|| recovery.automatic_read_only_rebind !== true
) return null;
return {
snapshotRuntimeId,
acquisitionId,
acquisitionStateRevision: acquisition.state_revision,
recoveryGeneration: recovery.generation,
runtimeProducerGeneration: producerGeneration,
recovery,
};
}
/** Exact, current and backend-policy-admitted authority for local-only finish. */
export function activeStreamForceFinishAuthority(
state: XgridsK1State | null | undefined,
): ActiveStreamForceFinishAuthority | null {
const lineage = exactActiveStreamRecoveryLineage(state);
if (
!lineage
|| !["reconnecting", "blocked"].includes(lineage.recovery.state)
|| lineage.recovery.force_finish_allowed !== true
|| state?.phase !== "reconnecting"
|| state.source_mode !== "live"
|| ![
"starting",
"awaiting_external_start",
"acquiring",
].includes(state.acquisition?.state ?? "")
) return null;
return lineage;
}
/**
* Exact authority for retaining browser presentation while the backend owns a
* read-only reconnect. This is deliberately narrower than the recovery card:
* terminal/blocked recovery states and an inactive acquisition cannot retain
* a prior spatial or camera transport.
*/
export function activeStreamRecoveryPresentationAuthority(
state: XgridsK1State | null | undefined,
): ActiveStreamRecoveryPresentationAuthority | null {
const lineage = exactActiveStreamRecoveryLineage(state);
if (
!lineage
|| lineage.recovery.state !== "reconnecting"
|| state?.phase !== "reconnecting"
|| state.source_mode !== "live"
|| ![
"starting",
"awaiting_external_start",
"acquiring",
].includes(state.acquisition?.state ?? "")
) return null;
return lineage;
}
/**
* Keep the exact recovered lineage available to disposable browser receivers
* after the recovery card has disappeared. Spatial admission can complete on
* the first authoritative PCL before the acquisition-owned camera produces
* its first playable frame. This authority carries only the no-write
* presentation lease: it grants neither force-finish nor START/STOP policy.
*/
export function activeStreamRecoveredBrowserAuthority(
state: XgridsK1State | null | undefined,
): ActiveStreamRecoveryPresentationAuthority | null {
const lineage = exactActiveStreamRecoveryLineage(state);
if (
!lineage
|| lineage.recovery.state !== "recovered"
|| lineage.recovery.camera_recovery !== "owned"
|| state?.phase !== "live"
|| state.source_mode !== "live"
|| state.acquisition?.state !== "acquiring"
) return null;
return lineage;
}
/**
* While a validated recovery contract is active it owns the presentation
* decision. Ordinary supervisor data flags may be stale across the network
* gap, so only an exact reconnect lease can retain browser transports.
*/
export function activeStreamRecoveryOwnsPresentationDecision(
state: XgridsK1State | null | undefined,
): boolean {
const recovery = state?.connection_recovery;
return Boolean(
isXgridsActiveStreamRecovery(recovery)
&& !["inactive", "recovered"].includes(recovery.state),
);
}
export function activeStreamForceFinishAuthorityMatches(
expected: ActiveStreamForceFinishAuthority,
state: XgridsK1State | null | undefined,
): boolean {
const current = activeStreamForceFinishAuthority(state);
return Boolean(
current
&& current.snapshotRuntimeId === expected.snapshotRuntimeId
&& current.acquisitionId === expected.acquisitionId
&& current.acquisitionStateRevision === expected.acquisitionStateRevision
&& current.recoveryGeneration === expected.recoveryGeneration
&& current.runtimeProducerGeneration === expected.runtimeProducerGeneration,
);
}
export function formatActiveStreamRecoveryElapsed(
elapsedMs: number | null,
): string | null {
if (!Number.isFinite(elapsedMs) || elapsedMs === null || elapsedMs < 0) return null;
const elapsedSeconds = Math.floor(elapsedMs / 1_000);
if (elapsedSeconds < 60) return `${elapsedSeconds} с`;
const minutes = Math.floor(elapsedSeconds / 60);
const seconds = elapsedSeconds % 60;
return seconds > 0 ? `${minutes} мин ${seconds} с` : `${minutes} мин`;
}
function recoveryProgressLabel(
recovery: XgridsActiveStreamRecovery,
): string | null {
const elapsed = formatActiveStreamRecoveryElapsed(recovery.elapsed_ms);
const attempt = recovery.attempt > 0
? `Попытка ${recovery.attempt}`
: "Подготовка проверки";
return elapsed ? `${attempt} · ${elapsed}` : attempt;
}
/**
* Present only an exact current lineage. `recovered` deliberately returns
* null so the ordinary confirmed live UI resumes without a transitional card.
*/
export function activeStreamRecoveryPresentation(
state: XgridsK1State | null | undefined,
): ActiveStreamRecoveryPresentation | null {
const lineage = exactActiveStreamRecoveryLineage(state);
if (!lineage) return null;
const recovery = lineage.recovery;
if (recovery.state === "reconnecting") {
return {
state: "reconnecting",
eyebrow: "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
title: "Восстанавливаем соединение",
statusLabel: "Восстановление связи",
tone: "neutral",
detail: "Проверка прежнего активного контура только для чтения. START, STOP и настройки сети не отправляются.",
progressLabel: recoveryProgressLabel(recovery),
showSpinner: true,
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
};
}
if (recovery.state === "blocked") {
return {
state: "blocked",
eyebrow: "СВЯЗЬ · ТРЕБУЕТСЯ ДЕЙСТВИЕ",
title: recovery.camera_recovery === "blocked"
? "Видеопоток не восстановлен"
: "Связь не восстановлена",
statusLabel: "Восстановление остановлено",
tone: "warning",
detail: recovery.camera_recovery === "blocked"
? "Связь с K1 проверена, но камера не возобновила передачу. Можно завершить только локальный приём."
: "Автоматическая проверка остановлена. Можно завершить только локальный приём; команда устройству не отправится.",
progressLabel: recoveryProgressLabel(recovery),
showSpinner: false,
forceFinishAvailable: activeStreamForceFinishAuthority(state) !== null,
};
}
if (recovery.state === "standby") {
return {
state: "standby",
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
title: "Устройство перешло в ожидание",
statusLabel: "Приём завершён",
tone: "neutral",
detail: "K1 сообщил, что активное сканирование уже завершено. Локальный приём закрывается без команды STOP.",
progressLabel: recoveryProgressLabel(recovery),
showSpinner: false,
forceFinishAvailable: false,
};
}
if (recovery.state === "fault") {
return {
state: "fault",
eyebrow: "СВЯЗЬ · СОСТОЯНИЕ ПРОВЕРЕНО",
title: "K1 сообщил об ошибке",
statusLabel: "Восстановление невозможно",
tone: "danger",
detail: "Безопасная проверка обнаружила ошибку устройства. Автоматических команд и повторов нет.",
progressLabel: recoveryProgressLabel(recovery),
showSpinner: false,
forceFinishAvailable: false,
};
}
return null;
}
/**
* Only an exact, still-active background reconnect may hide the generic red
* error banner. A failed explicit local finish is operator-facing evidence and
* must remain visible even while the last accepted snapshot says reconnecting.
*/
export function suppressGenericErrorDuringActiveStreamRecovery(
state: XgridsK1State | null | undefined,
errorAction?: string | null,
): boolean {
if (errorAction === "force-finish") return false;
return activeStreamRecoveryPresentationAuthority(state) !== null;
}