diff --git a/apps/control-station/test/k1SupervisorPresentation.test.mjs b/apps/control-station/test/k1SupervisorPresentation.test.mjs index 1f03653..bd6cec9 100644 --- a/apps/control-station/test/k1SupervisorPresentation.test.mjs +++ b/apps/control-station/test/k1SupervisorPresentation.test.mjs @@ -67,6 +67,7 @@ let reconfigurationAllowsFreshDevice; let readOnlyObservationShowsNetworkUnavailable; let readOnlyFailureShowsNetworkUnavailable; let savedBridgeRequiresNetworkSetup; +let isConnectionControlBootstrapSettling; let provisioningFailureRequiresFreshCandidate; let trustedConnectionBinding; let transportRefEquivalenceKey; @@ -189,6 +190,7 @@ before(async () => { readOnlyObservationShowsNetworkUnavailable, readOnlyFailureShowsNetworkUnavailable, savedBridgeRequiresNetworkSetup, + isConnectionControlBootstrapSettling, provisioningFailureRequiresFreshCandidate, trustedConnectionBinding, transportRefEquivalenceKey, @@ -6228,6 +6230,92 @@ test("Apply presentation stays correlated through ACK, bootstrap, terminal and c ); }); +test("current Bridge bootstrap stays visible over an older unresolved STOP checkpoint", () => { + const presentation = provisioningAttemptPresentation({ + localPhase: "settling", + attemptId: "network-operation-current", + }); + const state = durableTopologyState(); + state.snapshot_runtime_id = presentation.snapshotRuntimeId; + state.connection_attempt = { + schema_version: "missioncore.xgrids-k1-connection-attempt/v1", + attempt_id: presentation.attemptId, + connection_mode: "bridge", + status: "running", + phase: "network_applied", + control_state: "unknown", + stage: "mqtt-device-info", + public_error_code: null, + side_effect_status: "applied", + safe_next_action: "wait-for-current-attempt", + automatic_retry: false, + accepted_at: OBSERVED_AT, + completed_at: null, + timeline: [], + }; + state.operations = [{ + operation_id: presentation.attemptId, + action: "network.provision", + status: "succeeded", + idempotency_key: presentation.idempotencyKey, + }]; + state.physical_command = { + status: "unresolved", + reason_code: "physical-command-reconciliation-required", + requires_reconciliation: true, + resolved_active_recovery_required: false, + automatic_replay_allowed: false, + runtime_bound: true, + reconciliation_ready: true, + observed_session_state: "scan_stopping", + active_operation_id: "older-stop-operation", + record: { + revision: 306, + operation_id: "older-stop-operation", + action: "stop", + stage: "observing", + resolution: null, + connection: { + transport_ref: presentation.deviceId, + connection_mode: "quick-connect", + target_ipv4: "192.168.56.1", + }, + }, + }; + + assert.equal(isConnectionControlBootstrapSettling(state), true); + const markup = renderProvisioningWithAttempt({ + controller: provisioningController(state), + desiredMode: "bridge", + }, presentation); + + assert.match(markup, /Подтверждение подключения…/); + assert.match(markup, /Настройки переданы один раз/); + assert.match(markup, /02<\/span>/); + assert.match(markup, /nodedc-activity-indicator/); + assert.doesNotMatch(markup, /Прежнее подключение не подтверждено/); + assert.doesNotMatch(markup, />Переподключиться<\/button>/); + assert.doesNotMatch(markup, />Подключить новый K1<\/button>/); + + assert.equal(isConnectionControlBootstrapSettling({ + ...state, + connection_attempt: { + ...state.connection_attempt, + status: "succeeded", + safe_next_action: "verify-control-read-only", + }, + }), true); + assert.equal(isConnectionControlBootstrapSettling({ + ...state, + connection_attempt: { + ...state.connection_attempt, + status: "failed", + control_state: "control_not_ready", + safe_next_action: "manual-recovery-required", + }, + }), false); +}); + test("Apply spends secret state before I/O and never stores a password in its presentation latch", () => { const source = readFileSync(provisioningSourceUrl, "utf8"); const presentationType = sourceSlice( diff --git a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx index 8d09b56..6ca3b87 100644 --- a/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx +++ b/plugins/xgrids-k1/frontend/src/XgridsK1Connection.tsx @@ -18,6 +18,7 @@ import { backendConnectionTopology, connectionAttemptForRuntimeError, hasControlAuthority, + isConnectionControlBootstrapSettling, isConfirmedLiveState, isPhysicalStopRecoverySettling, isRecoveredPhysicalScanning, @@ -274,12 +275,16 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) : sourceRuntimeBusy || preparedAcquisition ? "warning" : "neutral"; + const connectionControlBootstrapSettling = + isConnectionControlBootstrapSettling(state); const connectionPhaseLabel = livePreparationPending ? "Подготовка приёма" : sourceRuntimeBusy || preparedAcquisition ? sourceLabel : activeRecoveryPresentation ? activeRecoveryPresentation.title + : connectionControlBootstrapSettling + ? "Подтверждение подключения" : physicalStopRecoverySettling ? "Завершение остановки" : recoveredPhysicalScanning @@ -307,6 +312,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) ? sourceTone : activeRecoveryPresentation ? activeRecoveryPresentation.tone + : connectionControlBootstrapSettling + ? "accent" : physicalRecoveryRequired ? "warning" : projectedPhase === "error" @@ -322,6 +329,8 @@ export function XgridsK1Connection({ model, host }: DevicePluginConnectionProps) ? "Команда остановки уже принята. Завершение выполняется без повторной команды." : activeRecoveryPresentation ? activeRecoveryPresentation.detail + : connectionControlBootstrapSettling + ? "Сеть применена. Сервис проверяет управляющее подключение без повторения BLE-команды." : recoveredPhysicalScanning ? "Локальная запись остановлена, но сканирование ещё продолжается." : physicalRecoveryRequired diff --git a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx index 71654f1..15033bc 100644 --- a/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx +++ b/plugins/xgrids-k1/frontend/src/components/K1ProvisioningPipeline.tsx @@ -2021,16 +2021,18 @@ export function K1ProvisioningPipeline({ const changeNetworkActionApplicable = connectionMode === "bridge" && connectedReconfigurationActionApplicable(state, "prepare-change-network"); const showNetworkStep = Boolean( - !presentedPhysicalRecoveryRequired - && !presentedConnectionRecoveryRequired - && ( - connectionPresentationEstablished - || explicitProvisioningDraftRetained - || explicitProvisioningDraftContextRetained - || unresolvedAppliedAttempt - || connectionAttemptSettling - || connectionAttemptFailed - || (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId)) + connectionAttemptSettling + || ( + !presentedPhysicalRecoveryRequired + && !presentedConnectionRecoveryRequired + && ( + connectionPresentationEstablished + || explicitProvisioningDraftRetained + || explicitProvisioningDraftContextRetained + || unresolvedAppliedAttempt + || connectionAttemptFailed + || (changeNetworkDialogue && Boolean(changeNetworkRequiredDeviceId)) + ) ) ); const requestExplicitProvisioning = useCallback(( @@ -3296,6 +3298,8 @@ export function K1ProvisioningPipeline({ ? "accent" : connectionEstablished ? "success" + : connectionAttemptSettling + ? "accent" : networkRecoveryRequired || presentedPhysicalRecoveryRequired || explicitProvisioningDraftStale @@ -3330,7 +3334,7 @@ export function K1ProvisioningPipeline({ ?? "Сервис не определил точный прежний K1 для безопасной проверки"} - ) : presentedPhysicalRecoveryRequired ? ( + ) : presentedPhysicalRecoveryRequired && !connectionAttemptSettling ? (
) : null}
- ) : presentedConnectionRecoveryRequired ? ( + ) : presentedConnectionRecoveryRequired && !connectionAttemptSettling ? (