395 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|