Files
NODEDC_MISSION_CORE/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx
T

395 lines
17 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { StatusBadge, type StatusTone } from "@nodedc/ui-react";
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
import {
activeStreamRecoveryPresentation,
suppressGenericErrorDuringActiveStreamRecovery,
} from "./activeStreamRecovery";
import { K1AcquisitionPipeline } from "./components/K1AcquisitionPipeline";
import { K1Diagnostics } from "./components/K1Diagnostics";
import { K1Metrics } from "./components/K1Metrics";
import { K1OperatorError } from "./components/K1OperatorError";
import {
K1ProvisioningPipeline,
unavailablePhysicalRetirementAuthority,
} from "./components/K1ProvisioningPipeline";
import {
backendConnectionTopology,
connectionAttemptForRuntimeError,
hasControlAuthority,
isConnectionControlBootstrapSettling,
isConfirmedLiveState,
isPhysicalStopRecoverySettling,
isRecoveredPhysicalScanning,
isReleasedTerminalAcquisitionFailure,
isSourceRuntimeBusy,
readOnlyConnectionObservationTarget,
recoverableAcquisition,
requiresCanonicalStopAfterTerminalLocalFailure,
requiresReadOnlyPhysicalRecovery,
savedBridgeRequiresNetworkSetup,
sourceStatusLabel,
} from "./lifecycle";
import { phaseLabel, phaseTone } from "./presentation";
import {
useXgridsK1Controller,
type XgridsK1Controller,
} from "./runtimeContext";
import type { PendingAction } from "./useXgridsK1Runtime";
import type { XgridsK1State } from "./api";
import {
DEFAULT_CONNECTION_MODE,
type ConnectionMode,
} from "./configuration";
export { K1OperatorError };
export function shouldRenderK1GenericRuntimeError(
error: string | null | undefined,
hasCorrelatedConnectionAttempt: boolean,
state: XgridsK1State | null | undefined,
errorAction?: string | null,
): boolean {
return Boolean(
error
&& !hasCorrelatedConnectionAttempt
&& !suppressGenericErrorDuringActiveStreamRecovery(state, errorAction),
);
}
export function physicalRecoveryConnectionDetail(
state: XgridsK1State | null | undefined,
): string | null {
if (!requiresReadOnlyPhysicalRecovery(state)) return null;
if (savedBridgeRequiresNetworkSetup(state)) {
return "K1 ответил по Bluetooth, но не подключён к сохранённой общей сети. Настройки устройства не изменялись; подключите K1 к общей сети заново.";
}
const retirementAvailable = Boolean(
unavailablePhysicalRetirementAuthority(state),
);
const readOnlyVerificationAvailable = Boolean(
readOnlyConnectionObservationTarget(state)?.serverBound,
);
if (retirementAvailable && readOnlyVerificationAvailable) {
return "Если прежний K1 снова доступен, проверьте его состояние без изменений: проверка читает состояние и не отправляет START, STOP или настройки сети. Если K1 недоступен постоянно или заменён, его можно локально исключить без связи с устройством.";
}
if (readOnlyVerificationAvailable) {
return "Проверьте состояние прежнего K1 без изменений устройства. Проверка использует сохранённое системой подключение и не отправляет START, STOP или настройки сети.";
}
if (retirementAvailable) {
return "Прежний K1 можно локально исключить без связи с устройством: действие не отправляет START, STOP или настройки сети. После этого можно отдельно выбрать другой K1.";
}
return "Безопасная сверка прежнего K1 сейчас недоступна. Обновите состояние; новые команды устройству заблокированы.";
}
function connectionPhaseFallbackLabel(phase: string | null | undefined): string {
if (phase === "device_selected") return "Выбор выполнен";
if (phase === "connected") return "Сетевой адрес получен";
return phaseLabel(phase);
}
/**
* Keep the disconnected connection job focused on its progressive pipeline.
* Persisted topology is evidence, not live control authority. Operational
* panels return only when they are actionable or required to finish an
* already-started lifecycle, especially STOP and recovery.
*/
export function shouldRenderK1OperationalPanels(
state: XgridsK1State | null | undefined,
pendingAction: PendingAction | null = null,
): boolean {
return Boolean(
pendingAction === "live"
|| hasControlAuthority(state)
|| state?.source_mode === "live"
|| state?.source_mode === "replay"
|| recoverableAcquisition(state)
|| state?.acquisition?.cleanup_pending === true
|| requiresCanonicalStopAfterTerminalLocalFailure(state)
|| isRecoveredPhysicalScanning(state)
|| isPhysicalStopRecoverySettling(state)
|| activeStreamRecoveryPresentation(state) !== null
);
}
export function K1ConnectionPipelines({
controller,
desiredConnectionMode,
onDesiredConnectionModeChange,
operationalPanelsVisible,
openSpatialScene,
activateAutomaticSpatialSource,
sourceLabel,
}: {
controller: XgridsK1Controller;
desiredConnectionMode: ConnectionMode;
onDesiredConnectionModeChange: (mode: ConnectionMode) => void | Promise<void>;
operationalPanelsVisible: boolean;
openSpatialScene: () => void;
activateAutomaticSpatialSource: () => void;
sourceLabel: string;
}) {
return (
<>
{operationalPanelsVisible ? <K1Metrics controller={controller} /> : null}
<div className="device-workspace__grid">
<K1ProvisioningPipeline
controller={controller}
desiredMode={desiredConnectionMode}
onDesiredModeChange={onDesiredConnectionModeChange}
/>
{operationalPanelsVisible ? (
<div className="device-workspace__side">
<K1AcquisitionPipeline
controller={controller}
desiredConnectionMode={desiredConnectionMode}
openSpatialScene={openSpatialScene}
activateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
</div>
) : null}
</div>
</>
);
}
export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) {
const controller = useXgridsK1Controller();
const {
state,
error,
errorDiagnostic,
errorCorrelation,
refresh,
clearError,
} = controller;
const [desiredConnectionMode, setDesiredConnectionMode] = useState<ConnectionMode>(
DEFAULT_CONNECTION_MODE,
);
const desiredModeInitialized = useRef(false);
const desiredModeLocallyDirty = useRef(false);
const hydratedScenarioResetKey = useRef<string | null>(null);
const confirmedLive = isConfirmedLiveState(state);
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
const livePreparationPending = controller.pendingAction === "live";
const preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(state);
const savedBridgeNetworkSetupRequired = savedBridgeRequiresNetworkSetup(state);
const physicalStopRecoverySettling = isPhysicalStopRecoverySettling(state);
const recoveredPhysicalScanning = physicalRecoveryRequired
&& state?.application_control_session?.state === "scanning"
&& state.application_control_session.can_stop === true;
const physicalRecoveryDetail = physicalRecoveryConnectionDetail(state);
const correlatedConnectionAttempt = connectionAttemptForRuntimeError(
errorCorrelation,
state,
);
const showGenericRuntimeError = shouldRenderK1GenericRuntimeError(
error,
Boolean(correlatedConnectionAttempt),
state,
errorCorrelation?.action,
);
const relevantAcquisitionFailed = state?.source_mode !== "replay"
&& state?.acquisition?.state === "failed"
&& !releasedAcquisitionFailure;
const projectedPhase = releasedAcquisitionFailure && state?.phase === "error"
? "idle"
: state?.phase;
const connectionTopology = backendConnectionTopology(state);
const effectiveDesiredConnectionMode = desiredModeInitialized.current
? desiredConnectionMode
: state?.desired_connection_mode
?? (connectionTopology?.status === "active"
? connectionTopology.connectionMode
: DEFAULT_CONNECTION_MODE);
useEffect(() => {
if (!state || desiredModeInitialized.current) return;
desiredModeInitialized.current = true;
setDesiredConnectionMode(
state.desired_connection_mode
?? (connectionTopology?.status === "active"
? connectionTopology.connectionMode
: DEFAULT_CONNECTION_MODE),
);
}, [connectionTopology, state]);
useEffect(() => {
if (!desiredModeInitialized.current) return;
const backendDesiredMode = state?.desired_connection_mode;
if (!backendDesiredMode) return;
const scenarioReset = state?.connection_scenario_reset;
const scenarioResetKey = scenarioReset
&& scenarioReset.revision === state?.desired_connection_mode_revision
&& scenarioReset.desired_mode === backendDesiredMode
&& state?.snapshot_runtime_id?.trim()
? `${state.snapshot_runtime_id}:${scenarioReset.revision}`
: null;
if (scenarioResetKey && hydratedScenarioResetKey.current !== scenarioResetKey) {
// The shell emergency reset is an authoritative new backend revision.
// It must retire a locally dirty selector too; an older dirty browser
// draft cannot keep showing Quick/Direct after canonical Bridge won.
hydratedScenarioResetKey.current = scenarioResetKey;
desiredModeLocallyDirty.current = false;
setDesiredConnectionMode(backendDesiredMode);
return;
}
if (backendDesiredMode === desiredConnectionMode) {
desiredModeLocallyDirty.current = false;
return;
}
// Every dropdown gesture is now an explicit backend scenario-reset CAS.
// The callback may publish its accepted mode one render before the hook's
// authoritative snapshot arrives, so passive polling must not overwrite
// that in-flight acknowledgement. Once the backend echoes the exact mode
// above, the dirty fence clears and later authoritative changes hydrate it.
if (desiredModeLocallyDirty.current) return;
setDesiredConnectionMode(backendDesiredMode);
}, [
desiredConnectionMode,
state?.connection_scenario_reset,
state?.desired_connection_mode,
state?.desired_connection_mode_revision,
state?.snapshot_runtime_id,
]);
const updateDesiredConnectionMode = (mode: ConnectionMode) => {
desiredModeLocallyDirty.current = mode !== state?.desired_connection_mode;
setDesiredConnectionMode(mode);
};
const sourceTone: StatusTone =
activeRecoveryPresentation
? activeRecoveryPresentation.tone
: projectedPhase === "error" || relevantAcquisitionFailed
? "danger"
: confirmedLive || state?.source_mode === "replay"
? "success"
: sourceRuntimeBusy || preparedAcquisition
? "warning"
: "neutral";
const connectionAttemptFailed = !sourceRuntimeBusy && !preparedAcquisition
&& connectionTopology?.status !== "active"
&& state?.connection_attempt?.connection_mode === effectiveDesiredConnectionMode
&& ["failed", "cancelled", "timed_out", "interrupted", "operator_action_required"]
.includes(state.connection_attempt.status);
const connectionControlBootstrapSettling =
isConnectionControlBootstrapSettling(state);
const connectionPhaseLabel = livePreparationPending
? "Подготовка приёма"
: sourceRuntimeBusy || preparedAcquisition
? sourceLabel
: activeRecoveryPresentation
? activeRecoveryPresentation.title
: connectionControlBootstrapSettling
? "Подтверждение подключения"
: physicalStopRecoverySettling
? "Завершение остановки"
: recoveredPhysicalScanning
? "Сканирование продолжается"
: physicalRecoveryRequired
? savedBridgeNetworkSetupRequired
? "Нужна настройка сети"
: "Требуется действие"
: connectionAttemptFailed
? "Подключение не завершено"
: projectedPhase === "error"
? connectionPhaseFallbackLabel(projectedPhase)
: connectionTopology?.status === "active"
? "Подключение установлено"
: connectionTopology?.status === "configured-unverified"
? "Подключение отсутствует"
: connectionTopology?.source === "durable"
? "Подключение отсутствует"
: connectionTopology?.source === "applied"
? "Подключение отсутствует"
: connectionTopology?.source === "last-known"
? "Подключение отсутствует"
: connectionPhaseFallbackLabel(projectedPhase);
const connectionPhaseTone = livePreparationPending
? "warning"
: sourceRuntimeBusy || preparedAcquisition
? sourceTone
: activeRecoveryPresentation
? activeRecoveryPresentation.tone
: connectionControlBootstrapSettling
? "accent"
: physicalRecoveryRequired
? "warning"
: connectionAttemptFailed
? "warning"
: projectedPhase === "error"
? phaseTone(projectedPhase)
: connectionTopology?.status === "active"
? "success"
: connectionTopology?.status === "configured-unverified"
? "neutral"
: "neutral";
const connectionPhaseDetail = livePreparationPending
? "Подготовка продолжается."
: physicalStopRecoverySettling
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
: activeRecoveryPresentation
? activeRecoveryPresentation.detail
: connectionControlBootstrapSettling
? "Сеть применена. Сервис проверяет управляющее подключение без повторения BLE-команды."
: recoveredPhysicalScanning
? "Локальная запись остановлена, но сканирование ещё продолжается."
: physicalRecoveryRequired
? physicalRecoveryDetail
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
: connectionAttemptFailed
? "Проверьте состояние K1 или начните новое подключение."
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
? "Начните новое подключение."
: !sourceRuntimeBusy && connectionTopology?.status === "active"
? "Готово к новой сессии."
: "Ожидается состояние локального контура.";
const operationalPanelsVisible = shouldRenderK1OperationalPanels(
state,
controller.pendingAction,
);
return (
<div className="device-workspace xgrids-k1-plugin">
{showGenericRuntimeError && error ? (
<K1OperatorError
message={error}
diagnostic={errorDiagnostic}
onRefresh={() => void refresh()}
onClear={clearError}
/>
) : null}
<section className="workspace-lead workspace-lead--compact">
<div>
<span className="section-eyebrow">ЛОКАЛЬНОЕ ПОДКЛЮЧЕНИЕ</span>
<h2>Подключение {model.displayName}</h2>
<p>Выберите способ связи и последовательно установите подключение.</p>
</div>
<div className="workspace-lead__status">
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
<span>{connectionPhaseDetail}</span>
</div>
</section>
<K1ConnectionPipelines
controller={controller}
desiredConnectionMode={effectiveDesiredConnectionMode}
onDesiredConnectionModeChange={updateDesiredConnectionMode}
operationalPanelsVisible={operationalPanelsVisible}
openSpatialScene={host.openSpatialScene}
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
sourceLabel={sourceLabel}
/>
</div>
);
}