fix(k1): keep reconnect feedback in place
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user