wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -1,31 +1,264 @@
|
||||
import { Button, StatusBadge, type StatusTone } from "@nodedc/ui-react";
|
||||
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 { K1ProvisioningPipeline } from "./components/K1ProvisioningPipeline";
|
||||
import { K1OperatorError } from "./components/K1OperatorError";
|
||||
import {
|
||||
K1ProvisioningPipeline,
|
||||
unavailablePhysicalRetirementAuthority,
|
||||
} from "./components/K1ProvisioningPipeline";
|
||||
import {
|
||||
backendConnectionTopology,
|
||||
connectionAttemptForRuntimeError,
|
||||
hasControlAuthority,
|
||||
isConfirmedLiveState,
|
||||
isPhysicalStopRecoverySettling,
|
||||
isRecoveredPhysicalScanning,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
isSourceRuntimeBusy,
|
||||
readOnlyConnectionObservationTarget,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
requiresReadOnlyPhysicalRecovery,
|
||||
sourceStatusLabel,
|
||||
} from "./lifecycle";
|
||||
import { localizeRuntimeMessage } from "./messages";
|
||||
import { phaseLabel, phaseTone } from "./presentation";
|
||||
import { useXgridsK1Controller } from "./runtimeContext";
|
||||
import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "./runtimeContext";
|
||||
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;
|
||||
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,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
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, refresh, clearError } = controller;
|
||||
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 preparedAcquisition = recoverableAcquisition(state)?.state === "prepared";
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const sourceLabel = activeRecoveryPresentation?.title ?? sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const physicalRecoveryRequired = requiresReadOnlyPhysicalRecovery(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 =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: projectedPhase === "error" || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: confirmedLive || state?.source_mode === "replay"
|
||||
? "success"
|
||||
@@ -34,56 +267,88 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps)
|
||||
: "neutral";
|
||||
const connectionPhaseLabel = sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceLabel
|
||||
: phaseLabel(state?.phase);
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.title
|
||||
: physicalStopRecoverySettling
|
||||
? "Завершение остановки"
|
||||
: recoveredPhysicalScanning
|
||||
? "Сканирование продолжается"
|
||||
: physicalRecoveryRequired
|
||||
? "Требуется действие"
|
||||
: 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 = sourceRuntimeBusy || preparedAcquisition
|
||||
? sourceTone
|
||||
: phaseTone(state?.phase);
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.tone
|
||||
: physicalRecoveryRequired
|
||||
? "warning"
|
||||
: projectedPhase === "error"
|
||||
? phaseTone(projectedPhase)
|
||||
: connectionTopology?.status === "active"
|
||||
? "success"
|
||||
: connectionTopology?.status === "configured-unverified"
|
||||
? "neutral"
|
||||
: "neutral";
|
||||
const connectionPhaseDetail = physicalStopRecoverySettling
|
||||
? "Команда остановки уже принята. Завершение выполняется без повторной команды."
|
||||
: activeRecoveryPresentation
|
||||
? activeRecoveryPresentation.detail
|
||||
: recoveredPhysicalScanning
|
||||
? "Локальная запись остановлена, но сканирование ещё продолжается."
|
||||
: physicalRecoveryRequired
|
||||
? physicalRecoveryDetail
|
||||
?? "Безопасное восстановление прежнего K1 сейчас недоступно."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "configured-unverified"
|
||||
? "Начните новое подключение."
|
||||
: !sourceRuntimeBusy && connectionTopology?.status === "active"
|
||||
? "Готово к новой сессии."
|
||||
: "Ожидается состояние локального контура.";
|
||||
const operationalPanelsVisible = shouldRenderK1OperationalPanels(state);
|
||||
|
||||
return (
|
||||
<div className="device-workspace xgrids-k1-plugin">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>Обновить состояние</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>Закрыть</Button>
|
||||
</div>
|
||||
</aside>
|
||||
{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">XGRIDS K1 · PLUGIN UI</span>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНОЕ ПОДКЛЮЧЕНИЕ</span>
|
||||
<h2>Подключение {model.displayName}</h2>
|
||||
<p>BLE/Wi‑Fi provisioning и acquisition pipeline принадлежат этому device plugin; Control Station предоставляет только host slot и переход в пространственную сцену.</p>
|
||||
<p>Выберите способ связи и последовательно установите подключение.</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={connectionPhaseTone}>{connectionPhaseLabel}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
<span>{connectionPhaseDetail}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<K1Metrics controller={controller} />
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<K1ProvisioningPipeline
|
||||
controller={controller}
|
||||
phaseLabel={connectionPhaseLabel}
|
||||
phaseTone={connectionPhaseTone}
|
||||
/>
|
||||
<div className="device-workspace__side">
|
||||
<K1AcquisitionPipeline
|
||||
controller={controller}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
/>
|
||||
<K1Diagnostics controller={controller} sourceLabel={sourceLabel} />
|
||||
</div>
|
||||
</div>
|
||||
<K1ConnectionPipelines
|
||||
controller={controller}
|
||||
desiredConnectionMode={effectiveDesiredConnectionMode}
|
||||
onDesiredConnectionModeChange={updateDesiredConnectionMode}
|
||||
operationalPanelsVisible={operationalPanelsVisible}
|
||||
openSpatialScene={host.openSpatialScene}
|
||||
activateAutomaticSpatialSource={host.activateAutomaticSpatialSource}
|
||||
sourceLabel={sourceLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user