fix(k1): keep reconnect feedback in place

This commit is contained in:
DCCONSTRUCTIONS
2026-08-14 19:21:11 +03:00
parent cd8a1045c4
commit 17a07355cf
3 changed files with 328 additions and 169 deletions
@@ -633,7 +633,7 @@ test("top-right device utility is an explicit pending-aware K1 scenario reset",
/const modeResetInFlight = pendingAction === "mode" \|\| modeResetPending !== null/, /const modeResetInFlight = pendingAction === "mode" \|\| modeResetPending !== null/,
); );
assert.match(provisioning, /if \(modeResetInFlight\) return/); assert.match(provisioning, /if \(modeResetInFlight\) return/);
assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 2); assert.equal((provisioning.match(/disabled=\{modeResetInFlight\}/g) ?? []).length, 1);
assert.match(provisioning, /disabled=\{isBusy \|\| modeResetInFlight\}/); assert.match(provisioning, /disabled=\{isBusy \|\| modeResetInFlight\}/);
assert.match(connection, /const hydratedScenarioResetKey = useRef<string \| null>\(null\)/); assert.match(connection, /const hydratedScenarioResetKey = useRef<string \| null>\(null\)/);
@@ -19,6 +19,7 @@ let K1ProvisioningPipeline;
let RuntimeActionFenceTestContext; let RuntimeActionFenceTestContext;
let emptySearchPresentation; let emptySearchPresentation;
let emptyProvisioningAttemptPresentation; let emptyProvisioningAttemptPresentation;
let emptyReadOnlyReconnectPresentation;
let provisioningAttemptViewState; let provisioningAttemptViewState;
let unavailablePhysicalRetirementAuthority; let unavailablePhysicalRetirementAuthority;
let connectionAttemptOwnsAppliedNetworkRecovery; let connectionAttemptOwnsAppliedNetworkRecovery;
@@ -125,6 +126,7 @@ before(async () => {
RuntimeActionFenceTestContext, RuntimeActionFenceTestContext,
emptySearchPresentation, emptySearchPresentation,
emptyProvisioningAttemptPresentation, emptyProvisioningAttemptPresentation,
emptyReadOnlyReconnectPresentation,
provisioningAttemptViewState, provisioningAttemptViewState,
unavailablePhysicalRetirementAuthority, unavailablePhysicalRetirementAuthority,
connectionAttemptOwnsAppliedNetworkRecovery, connectionAttemptOwnsAppliedNetworkRecovery,
@@ -5228,11 +5230,21 @@ test("an unresolved physical command has one explicit server-bound read-only rec
}, },
desiredMode: "bridge", desiredMode: "bridge",
}, { publicSearch: false }); }, { publicSearch: false });
assert.equal( const pendingReconnect = buttonMarkupWithText(
(pendingMarkup.match(/class="connection-action-progress"/g) ?? []).length, pendingMarkup,
1, "Переподключаемся…",
); );
assert.doesNotMatch(pendingMarkup, /Переподключиться|Подключить новый K1|Подтверждаю:/); assert.equal(pendingReconnect.length, 1);
assert.match(pendingReconnect[0], /disabled/);
assert.match(pendingReconnect[0], /aria-busy="true"/);
assert.match(pendingReconnect[0], /nodedc-activity-indicator/);
const pendingConnectNew = buttonMarkupWithText(
pendingMarkup,
"Подключить новый K1",
);
assert.equal(pendingConnectNew.length, 1);
assert.match(pendingConnectNew[0], /disabled/);
assert.doesNotMatch(pendingMarkup, /connection-action-progress|Подтверждаю:/);
const source = readFileSync(provisioningSourceUrl, "utf8"); const source = readFileSync(provisioningSourceUrl, "utf8");
const physicalRecovery = sourceSlice( const physicalRecovery = sourceSlice(
@@ -5443,18 +5455,16 @@ test("unavailable physical replacement stays CAS-protected behind the simple new
}, },
desiredMode: "bridge", desiredMode: "bridge",
}, { publicSearch: false }); }, { publicSearch: false });
assert.match( const bothPendingReconnect = buttonMarkupWithText(
bothPendingMarkup, bothPendingMarkup,
/Проверяем прежний K1 без повторения START, STOP или настроек сети/, "Переподключаемся…",
);
assert.equal(
(bothPendingMarkup.match(/class="connection-action-progress"/g) ?? []).length,
1,
);
assert.doesNotMatch(
bothPendingMarkup,
/Переподключиться|Подключить новый K1|Подтверждаю:/,
); );
assert.equal(bothPendingReconnect.length, 1);
assert.match(bothPendingReconnect[0], /disabled/);
assert.match(bothPendingReconnect[0], /aria-busy="true"/);
assert.match(bothPendingReconnect[0], /nodedc-activity-indicator/);
assert.match(bothPendingMarkup, /Прежнее подключение не подтверждено/);
assert.doesNotMatch(bothPendingMarkup, /connection-action-progress|Подтверждаю:/);
const bothResolvedActive = structuredClone(bothAllowed); const bothResolvedActive = structuredClone(bothAllowed);
bothResolvedActive.physical_command.status = "resolved"; bothResolvedActive.physical_command.status = "resolved";
@@ -6140,6 +6150,92 @@ test("unknown and cold durable recovery offer one reconnect and one new-device p
assert.match(unrelatedMarkup, /Результат применения сети не подтверждён/); assert.match(unrelatedMarkup, /Результат применения сети не подтверждён/);
}); });
test("read-only reconnect keeps its card and loader across a transient projection", async () => {
const initialState = terminalConnectionRecoveryState();
initialState.connection_attempt = null;
let currentState = initialState;
let resolveVerification;
let markVerificationStarted;
const verificationStarted = new Promise((resolve) => {
markVerificationStarted = resolve;
});
const controller = {
...provisioningController(initialState),
verifyConnection: async () => {
markVerificationStarted();
return new Promise((resolve) => {
resolveVerification = resolve;
});
},
getConnectionRecoveryObservationTarget: () =>
recommendedConnectionRecoveryObservationTarget(currentState),
isSnapshotRuntimeCurrent: (runtimeId) =>
runtimeId === currentState.snapshot_runtime_id,
};
const props = () => ({
controller: { ...controller, state: currentState },
desiredMode: "bridge",
});
const harness = createStatefulProvisioningHarness(props());
try {
let tree = harness.render(props());
const reconnect = actionByLabel(tree, "Переподключиться");
assert.ok(reconnect);
reconnect.props.onClick();
await Promise.race([
verificationStarted,
new Promise((_, reject) => setTimeout(
() => reject(new Error("read-only reconnect did not dispatch")),
100,
)),
]);
const transientState = structuredClone(initialState);
transientState.connection_policy.allowed_actions =
transientState.connection_policy.allowed_actions.filter(
(action) => action !== "observe-configured-device-network",
);
delete transientState.connection_policy.actions[
"observe-configured-device-network"
];
currentState = transientState;
tree = harness.render(props());
const pendingMarkup = renderToStaticMarkup(tree);
const pendingReconnect = buttonMarkupWithText(
pendingMarkup,
"Переподключаемся…",
);
assert.equal(pendingReconnect.length, 1);
assert.match(pendingReconnect[0], /disabled/);
assert.match(pendingReconnect[0], /aria-busy="true"/);
assert.match(pendingReconnect[0], /nodedc-activity-indicator/);
assert.match(pendingMarkup, /Сохранённое подключение требует проверки/);
assert.doesNotMatch(pendingMarkup, /connection-action-progress/);
assert.equal(
harness.stateValue(emptyReadOnlyReconnectPresentation)?.kind,
"connection",
);
currentState = initialState;
resolveVerification({
succeeded: false,
reconciliationCompleted: false,
observedState: initialState,
});
await new Promise((resolve) => setTimeout(resolve, 0));
tree = harness.render(props());
const failedMarkup = renderToStaticMarkup(tree);
const retry = buttonMarkupWithText(failedMarkup, "Переподключиться");
assert.equal(retry.length, 1);
assert.doesNotMatch(retry[0], /disabled|aria-busy="true"/);
assert.match(failedMarkup, /Проверьте питание и сеть, затем повторите проверку/);
assert.equal(harness.stateValue(emptyReadOnlyReconnectPresentation), null);
} finally {
harness.dispose();
}
});
test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => { test("cold saved new-device path resets once, performs zero Scan, and survives reload", async () => {
const state = terminalConnectionRecoveryState(); const state = terminalConnectionRecoveryState();
state.connection_attempt = null; state.connection_attempt = null;
@@ -6753,7 +6849,7 @@ test("applied-network recovery spends the old intent and admits only explicit se
assert.match(source, /BLE-команда не повторяется/); assert.match(source, /BLE-команда не повторяется/);
assert.match( assert.match(
source, source,
/status=\{\s*searchActive[\s\S]*?: physicalRecoveryRequired[\s\S]*?: networkRecoveryRequired[\s\S]*?>\s*\{searchActive \? \([\s\S]*?Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с[\s\S]*?: networkRecoveryRequired && !connectionAttemptSettling \? \(/, /status=\{\s*searchActive[\s\S]*?: presentedPhysicalRecoveryRequired[\s\S]*?: networkRecoveryRequired[\s\S]*?>\s*\{searchActive \? \([\s\S]*?Поиск Bluetooth · \{scanSecondsRemaining \?\? 6\} с[\s\S]*?: networkRecoveryRequired && !connectionAttemptSettling \? \(/,
); );
assert.match( assert.match(
@@ -85,6 +85,20 @@ interface DevicePresentationSnapshot {
rssi: number | null; rssi: number | null;
} }
export interface ReadOnlyReconnectPresentation {
kind: "connection" | "physical";
key: string;
actionEpoch: number;
snapshotRuntimeId: string;
deviceId: string;
connectionMode: ConnectionMode;
}
export function emptyReadOnlyReconnectPresentation():
ReadOnlyReconnectPresentation | null {
return null;
}
export interface PhysicalReopenPresentation { export interface PhysicalReopenPresentation {
key: string; key: string;
snapshotRuntimeId: string; snapshotRuntimeId: string;
@@ -1256,10 +1270,10 @@ export function K1ProvisioningPipeline({
useState<string | null>(null); useState<string | null>(null);
const [escapedConnectionRecoveryKey, setEscapedConnectionRecoveryKey] = const [escapedConnectionRecoveryKey, setEscapedConnectionRecoveryKey] =
useState<string | null>(null); useState<string | null>(null);
const [failedConnectionRecoveryKey, setFailedConnectionRecoveryKey] = const [readOnlyReconnectPresentation, setReadOnlyReconnectPresentation] =
useState<string | null>(null); useState<ReadOnlyReconnectPresentation | null>(
const [failedPhysicalRecoveryKey, setFailedPhysicalRecoveryKey] = emptyReadOnlyReconnectPresentation,
useState<string | null>(null); );
// Browser-local generation for work which may settle after an awaited // Browser-local generation for work which may settle after an awaited
// controller call. A scenario reset does not replace snapshot_runtime_id, // controller call. A scenario reset does not replace snapshot_runtime_id,
// so runtime identity alone cannot prevent an old Scan/Verify/Retire result // so runtime identity alone cannot prevent an old Scan/Verify/Retire result
@@ -1607,8 +1621,7 @@ export function K1ProvisioningPipeline({
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
setEscapedAppliedAttemptKey(null); setEscapedAppliedAttemptKey(null);
setEscapedConnectionRecoveryKey(null); setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null); setReadOnlyReconnectPresentation(null);
setFailedPhysicalRecoveryKey(null);
setSearchPresentation(null); setSearchPresentation(null);
setScanSecondsRemaining(null); setScanSecondsRemaining(null);
}, [scenarioResetPresentationKey]); }, [scenarioResetPresentationKey]);
@@ -1638,12 +1651,21 @@ export function K1ProvisioningPipeline({
const connectionAttemptSettling = connectionAttemptView === "submitting" const connectionAttemptSettling = connectionAttemptView === "submitting"
|| connectionAttemptView === "settling"; || connectionAttemptView === "settling";
const connectionAttemptFailed = connectionAttemptView === "failed"; const connectionAttemptFailed = connectionAttemptView === "failed";
const currentReadOnlyReconnectPresentation =
readOnlyReconnectPresentation?.snapshotRuntimeId === snapshotRuntimeId
? readOnlyReconnectPresentation
: null;
const connectionRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "connection";
const physicalRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "physical";
const provisioningMutationBusy = Boolean( const provisioningMutationBusy = Boolean(
presentedPendingAction presentedPendingAction
|| modeResetPending !== null || modeResetPending !== null
|| currentPreparingReconfigurationRequest || currentPreparingReconfigurationRequest
|| searchDisplayActive || searchDisplayActive
|| connectionAttemptSettling || connectionAttemptSettling
|| currentReadOnlyReconnectPresentation
); );
const isBusy = provisioningMutationBusy; const isBusy = provisioningMutationBusy;
const activeSearchSequence = searchDisplayActive const activeSearchSequence = searchDisplayActive
@@ -1783,10 +1805,10 @@ export function K1ProvisioningPipeline({
connectionRecoveryKey, connectionRecoveryKey,
escapedConnectionRecoveryKey, escapedConnectionRecoveryKey,
) && !(searchRequested && !searchDisplayActive); ) && !(searchRequested && !searchDisplayActive);
const connectionRecoveryVerificationFailed = Boolean( const presentedConnectionRecoveryRequired = connectionRecoveryRequired
connectionRecoveryKey || connectionRecoveryVerificationPending;
&& failedConnectionRecoveryKey === connectionRecoveryKey, const presentedPhysicalRecoveryRequired = physicalRecoveryRequired
); || physicalRecoveryVerificationPending;
const appliedControlSettlementPending = Boolean( const appliedControlSettlementPending = Boolean(
unresolvedAppliedAttempt unresolvedAppliedAttempt
&& ["accepted", "running"].includes(unresolvedAppliedAttempt.status) && ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
@@ -1818,26 +1840,22 @@ export function K1ProvisioningPipeline({
const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey( const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey(
state, state,
); );
const physicalRecoveryVerificationFailed = Boolean(
physicalRecoveryAuthorityKey
&& failedPhysicalRecoveryKey === physicalRecoveryAuthorityKey,
);
const physicalReadOnlyVerificationAvailable = Boolean( const physicalReadOnlyVerificationAvailable = Boolean(
physicalRecoveryTarget?.serverBound, physicalRecoveryTarget?.serverBound || physicalRecoveryVerificationPending,
); );
useEffect(() => { const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode
if ( ?? (physicalRecoveryVerificationPending
failedPhysicalRecoveryKey ? currentReadOnlyReconnectPresentation?.connectionMode
&& failedPhysicalRecoveryKey !== physicalRecoveryAuthorityKey : null);
) { const physicalRecoveryModeLabel = presentedPhysicalRecoveryMode
setFailedPhysicalRecoveryKey(null);
}
}, [failedPhysicalRecoveryKey, physicalRecoveryAuthorityKey]);
const physicalRecoveryModeLabel = physicalRecoveryBinding
? connectionModeOptions.find( ? connectionModeOptions.find(
(option) => option.value === physicalRecoveryBinding.connectionMode, (option) => option.value === presentedPhysicalRecoveryMode,
)?.label ?? physicalRecoveryBinding.connectionMode )?.label ?? presentedPhysicalRecoveryMode
: "Прежнее подключение"; : "Прежнее подключение";
const presentedPhysicalRecoveryDeviceId = physicalRecoveryBinding?.deviceId
?? (physicalRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.deviceId
: null);
const reconfigurationTargetAllowed = reconfiguration === null const reconfigurationTargetAllowed = reconfiguration === null
|| ( || (
selectedTarget !== null selectedTarget !== null
@@ -1888,9 +1906,15 @@ export function K1ProvisioningPipeline({
|| connectedEndpoint || connectedEndpoint
|| "Активное подключение" || "Активное подключение"
: null; : null;
const verificationActionPending = presentedPendingAction === "verify"
|| connectionRecoveryVerificationPending
|| physicalRecoveryVerificationPending;
const connectionReconnectPending = presentedConnectionRecoveryRequired
&& verificationActionPending;
const physicalReconnectPending = presentedPhysicalRecoveryRequired
&& verificationActionPending;
const connectionActionPending = presentedPendingAction === "connect" const connectionActionPending = presentedPendingAction === "connect"
|| presentedPendingAction === "verify"; || verificationActionPending;
const verificationActionPending = presentedPendingAction === "verify";
const networkActionPending = presentedPendingAction === "connect" const networkActionPending = presentedPendingAction === "connect"
|| presentedPendingAction === "mode" || presentedPendingAction === "mode"
|| Boolean( || Boolean(
@@ -1924,8 +1948,8 @@ export function K1ProvisioningPipeline({
const changeNetworkActionApplicable = connectionMode === "bridge" const changeNetworkActionApplicable = connectionMode === "bridge"
&& connectedReconfigurationActionApplicable(state, "prepare-change-network"); && connectedReconfigurationActionApplicable(state, "prepare-change-network");
const showNetworkStep = Boolean( const showNetworkStep = Boolean(
!physicalRecoveryRequired !presentedPhysicalRecoveryRequired
&& !connectionRecoveryRequired && !presentedConnectionRecoveryRequired
&& ( && (
selectedModeConnected selectedModeConnected
|| explicitProvisioningDraftRetained || explicitProvisioningDraftRetained
@@ -2141,8 +2165,6 @@ export function K1ProvisioningPipeline({
setPreparingReconfigurationRequest(null); setPreparingReconfigurationRequest(null);
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
setEscapedConnectionRecoveryKey(null); setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
resetSearchPresentation(); resetSearchPresentation();
}, [ }, [
activeBindingKey, activeBindingKey,
@@ -2550,51 +2572,67 @@ export function K1ProvisioningPipeline({
if ( if (
!connectionRecoveryTarget?.serverBound !connectionRecoveryTarget?.serverBound
|| !snapshotRuntimeId || !snapshotRuntimeId
|| !connectionRecoveryKey
|| isBusy || isBusy
|| typeof getConnectionRecoveryObservationTarget !== "function" || typeof getConnectionRecoveryObservationTarget !== "function"
) return; ) return;
const actionEpoch = localScenarioActionEpoch.current; const actionEpoch = localScenarioActionEpoch.current;
const verificationKey = connectionRecoveryKey; const verificationKey = connectionRecoveryKey;
setCandidateUnavailableMessage(null); setReadOnlyReconnectPresentation({
const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime( kind: "connection",
snapshotRuntimeId, key: verificationKey,
connectionRecoveryTarget,
isSnapshotRuntimeCurrent,
getConnectionRecoveryObservationTarget,
async (target) => {
const request = observationRequest(
target,
reconfigurationRevision,
reconfigurationIntentId,
);
if (!request) return null;
return verifyConnection(request, { surfaceErrors: false });
},
);
if (!localScenarioActionEpochIsCurrent(
actionEpoch, actionEpoch,
localScenarioActionEpoch.current, snapshotRuntimeId,
)) return; deviceId: connectionRecoveryTarget.deviceId,
if (!dispatched.dispatched || !dispatched.result) { connectionMode: connectionRecoveryTarget.connectionMode,
setFailedConnectionRecoveryKey(verificationKey); });
setCandidateUnavailableMessage(
"Не удалось начать переподключение: состояние K1 изменилось. Подключите новый K1 через ручной Bluetooth-поиск.",
);
return;
}
if (
typeof isSnapshotRuntimeCurrent === "function"
&& !isSnapshotRuntimeCurrent(snapshotRuntimeId)
) return;
if (!dispatched.result.succeeded) {
setFailedConnectionRecoveryKey(verificationKey);
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Подключите новый K1 через ручной Bluetooth-поиск.",
);
return;
}
setFailedConnectionRecoveryKey(null);
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
try {
const dispatched = await dispatchConnectionRecoveryObservationForCurrentRuntime(
snapshotRuntimeId,
connectionRecoveryTarget,
isSnapshotRuntimeCurrent,
getConnectionRecoveryObservationTarget,
async (target) => {
const request = observationRequest(
target,
reconfigurationRevision,
reconfigurationIntentId,
);
if (!request) return null;
return verifyConnection(request, { surfaceErrors: false });
},
);
if (!localScenarioActionEpochIsCurrent(
actionEpoch,
localScenarioActionEpoch.current,
)) return;
if (!dispatched.dispatched || !dispatched.result) {
setCandidateUnavailableMessage(
"Не удалось начать переподключение: состояние K1 изменилось. Повторите проверку или подключите новый K1 через ручной Bluetooth-поиск.",
);
return;
}
if (
typeof isSnapshotRuntimeCurrent === "function"
&& !isSnapshotRuntimeCurrent(snapshotRuntimeId)
) return;
if (!dispatched.result.succeeded) {
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
);
return;
}
setCandidateUnavailableMessage(null);
} finally {
setReadOnlyReconnectPresentation((current) => (
current?.kind === "connection"
&& current.key === verificationKey
&& current.actionEpoch === actionEpoch
? null
: current
));
}
}; };
const refreshCorrelatedConnectionFailure = async () => { const refreshCorrelatedConnectionFailure = async () => {
@@ -2611,8 +2649,7 @@ export function K1ProvisioningPipeline({
setPasswordVisible(false); setPasswordVisible(false);
setConnectionAttemptPresentation(null); setConnectionAttemptPresentation(null);
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
setFailedConnectionRecoveryKey(null); setReadOnlyReconnectPresentation(null);
setFailedPhysicalRecoveryKey(null);
}; };
const verifyPhysicalRecovery = async () => { const verifyPhysicalRecovery = async () => {
@@ -2621,6 +2658,8 @@ export function K1ProvisioningPipeline({
if ( if (
!physicalRecoveryRequired !physicalRecoveryRequired
|| !physicalRecoveryTarget?.serverBound || !physicalRecoveryTarget?.serverBound
|| !snapshotRuntimeId
|| !verificationKey
|| isBusy || isBusy
) return; ) return;
const request = observationRequest( const request = observationRequest(
@@ -2629,57 +2668,69 @@ export function K1ProvisioningPipeline({
reconfigurationIntentId, reconfigurationIntentId,
); );
if (!request) { if (!request) {
setFailedPhysicalRecoveryKey(verificationKey);
setCandidateUnavailableMessage( setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.", "Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.",
); );
return; return;
} }
setReadOnlyReconnectPresentation({
const modeAuthority = await commitDesiredModeForExplicitAction( kind: "physical",
physicalRecoveryTarget.connectionMode, key: verificationKey,
);
if (!localScenarioActionEpochIsCurrent(
actionEpoch, actionEpoch,
localScenarioActionEpoch.current, snapshotRuntimeId,
)) return; deviceId: physicalRecoveryTarget.deviceId,
if (!modeAuthority) { connectionMode: physicalRecoveryTarget.connectionMode,
setFailedPhysicalRecoveryKey(verificationKey); });
setCandidateUnavailableMessage(
"Не удалось переподключиться: состояние K1 изменилось. Подключите новый K1 вручную.",
);
return;
}
const actionFence = activateRuntimeActionFence(modeAuthority);
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
setCandidateUnavailableMessage(null); setCandidateUnavailableMessage(null);
try { try {
const result = await verifyConnection(request); const modeAuthority = await commitDesiredModeForExplicitAction(
if (!runtimeClickIsCurrent(actionFence)) return; physicalRecoveryTarget.connectionMode,
if (!result.succeeded) { );
setFailedPhysicalRecoveryKey(verificationKey); if (!localScenarioActionEpochIsCurrent(
actionEpoch,
localScenarioActionEpoch.current,
)) return;
if (!modeAuthority) {
setCandidateUnavailableMessage( setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.", "Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.",
); );
return; return;
} }
if ( const actionFence = activateRuntimeActionFence(modeAuthority);
result.observedState if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
&& isRecoveredPhysicalScanning( try {
result.observedState, const result = await verifyConnection(request);
physicalRecoveryTarget.connectionMode, if (!runtimeClickIsCurrent(actionFence)) return;
) if (!result.succeeded) {
) { setCandidateUnavailableMessage(
setCandidateUnavailableMessage( "Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
"K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.", );
); return;
return; }
if (
result.observedState
&& isRecoveredPhysicalScanning(
result.observedState,
physicalRecoveryTarget.connectionMode,
)
) {
setCandidateUnavailableMessage(
"K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.",
);
return;
}
setCandidateUnavailableMessage(null);
} finally {
retireRuntimeClickFence(actionFence);
} }
setFailedPhysicalRecoveryKey(null);
setCandidateUnavailableMessage(null);
} finally { } finally {
retireRuntimeClickFence(actionFence); setReadOnlyReconnectPresentation((current) => (
current?.kind === "physical"
&& current.key === verificationKey
&& current.actionEpoch === actionEpoch
? null
: current
));
} }
}; };
@@ -2779,8 +2830,6 @@ export function K1ProvisioningPipeline({
setReconfigurationDevicePresentation(null); setReconfigurationDevicePresentation(null);
setEscapedAppliedAttemptKey(null); setEscapedAppliedAttemptKey(null);
setEscapedConnectionRecoveryKey(null); setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
resetSearchPresentation(); resetSearchPresentation();
await onDesiredModeChange(value); await onDesiredModeChange(value);
} finally { } finally {
@@ -3023,30 +3072,41 @@ export function K1ProvisioningPipeline({
) : null} ) : null}
</> </>
); );
const connectionRecoveryModeLabel = connectionRecoveryTarget const presentedConnectionRecoveryMode = connectionRecoveryTarget?.connectionMode
?? (connectionRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.connectionMode
: null);
const connectionRecoveryModeLabel = presentedConnectionRecoveryMode
? connectionModeOptions.find( ? connectionModeOptions.find(
(option) => option.value === connectionRecoveryTarget.connectionMode, (option) => option.value === presentedConnectionRecoveryMode,
)?.label ?? connectionRecoveryTarget.connectionMode )?.label ?? presentedConnectionRecoveryMode
: null; : null;
const connectionRecoveryActions = connectionRecoveryRequired ? ( const presentedConnectionRecoveryDeviceId = connectionRecoveryTarget?.deviceId
?? (connectionRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.deviceId
: null);
const connectionRecoveryActions = presentedConnectionRecoveryRequired ? (
<> <>
{connectionRecoveryTarget && !connectionRecoveryVerificationFailed ? ( {connectionRecoveryTarget || connectionRecoveryVerificationPending ? (
<Button <Button
width="full" width="full"
variant="primary" variant="primary"
icon={<Icon name="refresh" />} icon={connectionReconnectPending
disabled={ ? <ActivityIndicator size="compact" />
isBusy : <Icon name="refresh" />}
} disabled={isBusy}
aria-busy={connectionReconnectPending || undefined}
onClick={() => void verifyConnectionRecoveryTarget()} onClick={() => void verifyConnectionRecoveryTarget()}
> >
Переподключиться {connectionReconnectPending
? "Переподключаемся…"
: "Переподключиться"}
</Button> </Button>
) : null} ) : null}
<Button <Button
width="full" width="full"
variant={ variant={
connectionRecoveryTarget && !connectionRecoveryVerificationFailed connectionRecoveryTarget || connectionRecoveryVerificationPending
? "secondary" ? "secondary"
: "primary" : "primary"
} }
@@ -3101,7 +3161,7 @@ export function K1ProvisioningPipeline({
? `Поиск · ${scanSecondsRemaining ?? 6} с` ? `Поиск · ${scanSecondsRemaining ?? 6} с`
: connectionAttemptSettling : connectionAttemptSettling
? "Устройство выбрано" ? "Устройство выбрано"
: physicalRecoveryRequired : presentedPhysicalRecoveryRequired
? verificationActionPending ? verificationActionPending
? "Переподключаемся…" ? "Переподключаемся…"
: "Нужно выбрать действие" : "Нужно выбрать действие"
@@ -3109,10 +3169,12 @@ export function K1ProvisioningPipeline({
? appliedControlSettlementPending ? appliedControlSettlementPending
? "Подтверждаем управление" ? "Подтверждаем управление"
: "Управление не подтверждено · выбор заблокирован" : "Управление не подтверждено · выбор заблокирован"
: connectionRecoveryRequired : presentedConnectionRecoveryRequired
? connectionRecoveryTarget ? connectionReconnectPending
? "Нужна проверка" ? "Переподключаемся…"
: "Нужен новый поиск" : connectionRecoveryTarget
? "Нужна проверка"
: "Нужен новый поиск"
: connectionEstablished : connectionEstablished
? "Подключение установлено" ? "Подключение установлено"
: explicitProvisioningDraftStale : explicitProvisioningDraftStale
@@ -3128,14 +3190,17 @@ export function K1ProvisioningPipeline({
tone={ tone={
searchActive searchActive
? "accent" ? "accent"
: physicalRecoveryRequired : verificationActionPending
&& verificationActionPending && (
presentedPhysicalRecoveryRequired
|| presentedConnectionRecoveryRequired
)
? "accent" ? "accent"
: connectionEstablished : connectionEstablished
? "success" ? "success"
: networkRecoveryRequired : networkRecoveryRequired
|| physicalRecoveryRequired || presentedPhysicalRecoveryRequired
|| connectionRecoveryRequired || presentedConnectionRecoveryRequired
|| explicitProvisioningDraftStale || explicitProvisioningDraftStale
? "warning" ? "warning"
: "neutral" : "neutral"
@@ -3168,52 +3233,47 @@ export function K1ProvisioningPipeline({
?? "Сервис не определил точный прежний K1 для безопасной проверки"} ?? "Сервис не определил точный прежний K1 для безопасной проверки"}
</small> </small>
</div> </div>
) : physicalRecoveryRequired && verificationActionPending ? ( ) : presentedPhysicalRecoveryRequired ? (
<div <div
className="connection-action-progress" className="field-stack"
role="status" aria-busy={physicalReconnectPending || undefined}
aria-live="polite"
aria-busy="true"
> >
<ActivityIndicator />
<strong>Проверяем прежний K1 без повторения START, STOP или настроек сети…</strong>
</div>
) : physicalRecoveryRequired ? (
<div className="field-stack">
<div className="connection-summary" role="status"> <div className="connection-summary" role="status">
<span>Прежнее подключение не подтверждено</span> <span>Прежнее подключение не подтверждено</span>
<strong>{physicalRecoveryModeLabel}</strong> <strong>{physicalRecoveryModeLabel}</strong>
<small> <small>
{physicalRecoveryBinding?.deviceId {presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}
?? "Прежний K1"}
</small> </small>
</div> </div>
<p className="safety-note"> <p className="safety-note">
Переподключитесь к прежнему K1 или начните чистое подключение Переподключитесь к прежнему K1 или начните чистое подключение
нового устройства. нового устройства.
</p> </p>
{physicalReadOnlyVerificationAvailable {physicalReadOnlyVerificationAvailable ? (
&& !physicalRecoveryVerificationFailed ? (
<Button <Button
width="full" width="full"
variant="primary" variant="primary"
icon={<Icon name="refresh" />} icon={physicalReconnectPending
? <ActivityIndicator size="compact" />
: <Icon name="refresh" />}
disabled={isBusy || !physicalRecoveryTarget?.serverBound} disabled={isBusy || !physicalRecoveryTarget?.serverBound}
aria-busy={physicalReconnectPending || undefined}
onClick={() => void verifyPhysicalRecovery()} onClick={() => void verifyPhysicalRecovery()}
> >
Переподключиться {physicalReconnectPending
? "Переподключаемся…"
: "Переподключиться"}
</Button> </Button>
) : null} ) : null}
<Button <Button
width="full" width="full"
variant={ variant={
physicalReadOnlyVerificationAvailable physicalReadOnlyVerificationAvailable
&& !physicalRecoveryVerificationFailed
? "secondary" ? "secondary"
: "primary" : "primary"
} }
icon={<Icon name="search" />} icon={<Icon name="search" />}
disabled={modeResetInFlight} disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)} onClick={() => void changeDesiredConnectionMode(connectionMode)}
> >
Подключить новый K1 Подключить новый K1
@@ -3224,8 +3284,11 @@ export function K1ProvisioningPipeline({
</p> </p>
) : null} ) : null}
</div> </div>
) : connectionRecoveryRequired ? ( ) : presentedConnectionRecoveryRequired ? (
<div className="field-stack"> <div
className="field-stack"
aria-busy={connectionReconnectPending || undefined}
>
{correlatedConnectionAttempt && error ? ( {correlatedConnectionAttempt && error ? (
<K1OperatorError <K1OperatorError
message={error} message={error}
@@ -3251,8 +3314,8 @@ export function K1ProvisioningPipeline({
?? connectionRecoveryAttempt?.connection_mode ?? connectionRecoveryAttempt?.connection_mode
?? requestedModeLabel} ?? requestedModeLabel}
</strong> </strong>
{connectionRecoveryTarget?.deviceId ? ( {presentedConnectionRecoveryDeviceId ? (
<small>{connectionRecoveryTarget.deviceId}</small> <small>{presentedConnectionRecoveryDeviceId}</small>
) : null} ) : null}
</div> </div>
<p className="safety-note"> <p className="safety-note">