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
@@ -85,6 +85,20 @@ interface DevicePresentationSnapshot {
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 {
key: string;
snapshotRuntimeId: string;
@@ -1256,10 +1270,10 @@ export function K1ProvisioningPipeline({
useState<string | null>(null);
const [escapedConnectionRecoveryKey, setEscapedConnectionRecoveryKey] =
useState<string | null>(null);
const [failedConnectionRecoveryKey, setFailedConnectionRecoveryKey] =
useState<string | null>(null);
const [failedPhysicalRecoveryKey, setFailedPhysicalRecoveryKey] =
useState<string | null>(null);
const [readOnlyReconnectPresentation, setReadOnlyReconnectPresentation] =
useState<ReadOnlyReconnectPresentation | null>(
emptyReadOnlyReconnectPresentation,
);
// Browser-local generation for work which may settle after an awaited
// controller call. A scenario reset does not replace snapshot_runtime_id,
// so runtime identity alone cannot prevent an old Scan/Verify/Retire result
@@ -1607,8 +1621,7 @@ export function K1ProvisioningPipeline({
setCandidateUnavailableMessage(null);
setEscapedAppliedAttemptKey(null);
setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
setReadOnlyReconnectPresentation(null);
setSearchPresentation(null);
setScanSecondsRemaining(null);
}, [scenarioResetPresentationKey]);
@@ -1638,12 +1651,21 @@ export function K1ProvisioningPipeline({
const connectionAttemptSettling = connectionAttemptView === "submitting"
|| connectionAttemptView === "settling";
const connectionAttemptFailed = connectionAttemptView === "failed";
const currentReadOnlyReconnectPresentation =
readOnlyReconnectPresentation?.snapshotRuntimeId === snapshotRuntimeId
? readOnlyReconnectPresentation
: null;
const connectionRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "connection";
const physicalRecoveryVerificationPending =
currentReadOnlyReconnectPresentation?.kind === "physical";
const provisioningMutationBusy = Boolean(
presentedPendingAction
|| modeResetPending !== null
|| currentPreparingReconfigurationRequest
|| searchDisplayActive
|| connectionAttemptSettling
|| currentReadOnlyReconnectPresentation
);
const isBusy = provisioningMutationBusy;
const activeSearchSequence = searchDisplayActive
@@ -1783,10 +1805,10 @@ export function K1ProvisioningPipeline({
connectionRecoveryKey,
escapedConnectionRecoveryKey,
) && !(searchRequested && !searchDisplayActive);
const connectionRecoveryVerificationFailed = Boolean(
connectionRecoveryKey
&& failedConnectionRecoveryKey === connectionRecoveryKey,
);
const presentedConnectionRecoveryRequired = connectionRecoveryRequired
|| connectionRecoveryVerificationPending;
const presentedPhysicalRecoveryRequired = physicalRecoveryRequired
|| physicalRecoveryVerificationPending;
const appliedControlSettlementPending = Boolean(
unresolvedAppliedAttempt
&& ["accepted", "running"].includes(unresolvedAppliedAttempt.status)
@@ -1818,26 +1840,22 @@ export function K1ProvisioningPipeline({
const physicalRecoveryAuthorityKey = physicalRecoveryPresentationAuthorityKey(
state,
);
const physicalRecoveryVerificationFailed = Boolean(
physicalRecoveryAuthorityKey
&& failedPhysicalRecoveryKey === physicalRecoveryAuthorityKey,
);
const physicalReadOnlyVerificationAvailable = Boolean(
physicalRecoveryTarget?.serverBound,
physicalRecoveryTarget?.serverBound || physicalRecoveryVerificationPending,
);
useEffect(() => {
if (
failedPhysicalRecoveryKey
&& failedPhysicalRecoveryKey !== physicalRecoveryAuthorityKey
) {
setFailedPhysicalRecoveryKey(null);
}
}, [failedPhysicalRecoveryKey, physicalRecoveryAuthorityKey]);
const physicalRecoveryModeLabel = physicalRecoveryBinding
const presentedPhysicalRecoveryMode = physicalRecoveryBinding?.connectionMode
?? (physicalRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.connectionMode
: null);
const physicalRecoveryModeLabel = presentedPhysicalRecoveryMode
? connectionModeOptions.find(
(option) => option.value === physicalRecoveryBinding.connectionMode,
)?.label ?? physicalRecoveryBinding.connectionMode
(option) => option.value === presentedPhysicalRecoveryMode,
)?.label ?? presentedPhysicalRecoveryMode
: "Прежнее подключение";
const presentedPhysicalRecoveryDeviceId = physicalRecoveryBinding?.deviceId
?? (physicalRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.deviceId
: null);
const reconfigurationTargetAllowed = reconfiguration === null
|| (
selectedTarget !== null
@@ -1888,9 +1906,15 @@ export function K1ProvisioningPipeline({
|| connectedEndpoint
|| "Активное подключение"
: null;
const verificationActionPending = presentedPendingAction === "verify"
|| connectionRecoveryVerificationPending
|| physicalRecoveryVerificationPending;
const connectionReconnectPending = presentedConnectionRecoveryRequired
&& verificationActionPending;
const physicalReconnectPending = presentedPhysicalRecoveryRequired
&& verificationActionPending;
const connectionActionPending = presentedPendingAction === "connect"
|| presentedPendingAction === "verify";
const verificationActionPending = presentedPendingAction === "verify";
|| verificationActionPending;
const networkActionPending = presentedPendingAction === "connect"
|| presentedPendingAction === "mode"
|| Boolean(
@@ -1924,8 +1948,8 @@ export function K1ProvisioningPipeline({
const changeNetworkActionApplicable = connectionMode === "bridge"
&& connectedReconfigurationActionApplicable(state, "prepare-change-network");
const showNetworkStep = Boolean(
!physicalRecoveryRequired
&& !connectionRecoveryRequired
!presentedPhysicalRecoveryRequired
&& !presentedConnectionRecoveryRequired
&& (
selectedModeConnected
|| explicitProvisioningDraftRetained
@@ -2141,8 +2165,6 @@ export function K1ProvisioningPipeline({
setPreparingReconfigurationRequest(null);
setCandidateUnavailableMessage(null);
setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
resetSearchPresentation();
}, [
activeBindingKey,
@@ -2550,51 +2572,67 @@ export function K1ProvisioningPipeline({
if (
!connectionRecoveryTarget?.serverBound
|| !snapshotRuntimeId
|| !connectionRecoveryKey
|| isBusy
|| typeof getConnectionRecoveryObservationTarget !== "function"
) return;
const actionEpoch = localScenarioActionEpoch.current;
const verificationKey = connectionRecoveryKey;
setCandidateUnavailableMessage(null);
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(
setReadOnlyReconnectPresentation({
kind: "connection",
key: verificationKey,
actionEpoch,
localScenarioActionEpoch.current,
)) return;
if (!dispatched.dispatched || !dispatched.result) {
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);
snapshotRuntimeId,
deviceId: connectionRecoveryTarget.deviceId,
connectionMode: connectionRecoveryTarget.connectionMode,
});
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 () => {
@@ -2611,8 +2649,7 @@ export function K1ProvisioningPipeline({
setPasswordVisible(false);
setConnectionAttemptPresentation(null);
setCandidateUnavailableMessage(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
setReadOnlyReconnectPresentation(null);
};
const verifyPhysicalRecovery = async () => {
@@ -2621,6 +2658,8 @@ export function K1ProvisioningPipeline({
if (
!physicalRecoveryRequired
|| !physicalRecoveryTarget?.serverBound
|| !snapshotRuntimeId
|| !verificationKey
|| isBusy
) return;
const request = observationRequest(
@@ -2629,57 +2668,69 @@ export function K1ProvisioningPipeline({
reconfigurationIntentId,
);
if (!request) {
setFailedPhysicalRecoveryKey(verificationKey);
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.",
);
return;
}
const modeAuthority = await commitDesiredModeForExplicitAction(
physicalRecoveryTarget.connectionMode,
);
if (!localScenarioActionEpochIsCurrent(
setReadOnlyReconnectPresentation({
kind: "physical",
key: verificationKey,
actionEpoch,
localScenarioActionEpoch.current,
)) return;
if (!modeAuthority) {
setFailedPhysicalRecoveryKey(verificationKey);
setCandidateUnavailableMessage(
"Не удалось переподключиться: состояние K1 изменилось. Подключите новый K1 вручную.",
);
return;
}
const actionFence = activateRuntimeActionFence(modeAuthority);
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
snapshotRuntimeId,
deviceId: physicalRecoveryTarget.deviceId,
connectionMode: physicalRecoveryTarget.connectionMode,
});
setCandidateUnavailableMessage(null);
try {
const result = await verifyConnection(request);
if (!runtimeClickIsCurrent(actionFence)) return;
if (!result.succeeded) {
setFailedPhysicalRecoveryKey(verificationKey);
const modeAuthority = await commitDesiredModeForExplicitAction(
physicalRecoveryTarget.connectionMode,
);
if (!localScenarioActionEpochIsCurrent(
actionEpoch,
localScenarioActionEpoch.current,
)) return;
if (!modeAuthority) {
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Подключите новый K1 вручную.",
"Не удалось переподключиться: состояние K1 изменилось. Повторите проверку или подключите новый K1 вручную.",
);
return;
}
if (
result.observedState
&& isRecoveredPhysicalScanning(
result.observedState,
physicalRecoveryTarget.connectionMode,
)
) {
setCandidateUnavailableMessage(
"K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.",
);
return;
const actionFence = activateRuntimeActionFence(modeAuthority);
if (!actionFence || !runtimeActionIsCurrent(actionFence)) return;
try {
const result = await verifyConnection(request);
if (!runtimeClickIsCurrent(actionFence)) return;
if (!result.succeeded) {
setCandidateUnavailableMessage(
"Не удалось переподключиться к прежнему K1. Проверьте питание и сеть, затем повторите проверку.",
);
return;
}
if (
result.observedState
&& isRecoveredPhysicalScanning(
result.observedState,
physicalRecoveryTarget.connectionMode,
)
) {
setCandidateUnavailableMessage(
"K1 подтвердил продолжающееся сканирование. Новая команда не отправлялась; доступна одна явная остановка.",
);
return;
}
setCandidateUnavailableMessage(null);
} finally {
retireRuntimeClickFence(actionFence);
}
setFailedPhysicalRecoveryKey(null);
setCandidateUnavailableMessage(null);
} 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);
setEscapedAppliedAttemptKey(null);
setEscapedConnectionRecoveryKey(null);
setFailedConnectionRecoveryKey(null);
setFailedPhysicalRecoveryKey(null);
resetSearchPresentation();
await onDesiredModeChange(value);
} finally {
@@ -3023,30 +3072,41 @@ export function K1ProvisioningPipeline({
) : null}
</>
);
const connectionRecoveryModeLabel = connectionRecoveryTarget
const presentedConnectionRecoveryMode = connectionRecoveryTarget?.connectionMode
?? (connectionRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.connectionMode
: null);
const connectionRecoveryModeLabel = presentedConnectionRecoveryMode
? connectionModeOptions.find(
(option) => option.value === connectionRecoveryTarget.connectionMode,
)?.label ?? connectionRecoveryTarget.connectionMode
(option) => option.value === presentedConnectionRecoveryMode,
)?.label ?? presentedConnectionRecoveryMode
: null;
const connectionRecoveryActions = connectionRecoveryRequired ? (
const presentedConnectionRecoveryDeviceId = connectionRecoveryTarget?.deviceId
?? (connectionRecoveryVerificationPending
? currentReadOnlyReconnectPresentation?.deviceId
: null);
const connectionRecoveryActions = presentedConnectionRecoveryRequired ? (
<>
{connectionRecoveryTarget && !connectionRecoveryVerificationFailed ? (
{connectionRecoveryTarget || connectionRecoveryVerificationPending ? (
<Button
width="full"
variant="primary"
icon={<Icon name="refresh" />}
disabled={
isBusy
}
icon={connectionReconnectPending
? <ActivityIndicator size="compact" />
: <Icon name="refresh" />}
disabled={isBusy}
aria-busy={connectionReconnectPending || undefined}
onClick={() => void verifyConnectionRecoveryTarget()}
>
Переподключиться
{connectionReconnectPending
? "Переподключаемся…"
: "Переподключиться"}
</Button>
) : null}
<Button
width="full"
variant={
connectionRecoveryTarget && !connectionRecoveryVerificationFailed
connectionRecoveryTarget || connectionRecoveryVerificationPending
? "secondary"
: "primary"
}
@@ -3101,7 +3161,7 @@ export function K1ProvisioningPipeline({
? `Поиск · ${scanSecondsRemaining ?? 6} с`
: connectionAttemptSettling
? "Устройство выбрано"
: physicalRecoveryRequired
: presentedPhysicalRecoveryRequired
? verificationActionPending
? "Переподключаемся…"
: "Нужно выбрать действие"
@@ -3109,10 +3169,12 @@ export function K1ProvisioningPipeline({
? appliedControlSettlementPending
? "Подтверждаем управление"
: "Управление не подтверждено · выбор заблокирован"
: connectionRecoveryRequired
? connectionRecoveryTarget
? "Нужна проверка"
: "Нужен новый поиск"
: presentedConnectionRecoveryRequired
? connectionReconnectPending
? "Переподключаемся…"
: connectionRecoveryTarget
? "Нужна проверка"
: "Нужен новый поиск"
: connectionEstablished
? "Подключение установлено"
: explicitProvisioningDraftStale
@@ -3128,14 +3190,17 @@ export function K1ProvisioningPipeline({
tone={
searchActive
? "accent"
: physicalRecoveryRequired
&& verificationActionPending
: verificationActionPending
&& (
presentedPhysicalRecoveryRequired
|| presentedConnectionRecoveryRequired
)
? "accent"
: connectionEstablished
? "success"
: networkRecoveryRequired
|| physicalRecoveryRequired
|| connectionRecoveryRequired
|| presentedPhysicalRecoveryRequired
|| presentedConnectionRecoveryRequired
|| explicitProvisioningDraftStale
? "warning"
: "neutral"
@@ -3168,52 +3233,47 @@ export function K1ProvisioningPipeline({
?? "Сервис не определил точный прежний K1 для безопасной проверки"}
</small>
</div>
) : physicalRecoveryRequired && verificationActionPending ? (
) : presentedPhysicalRecoveryRequired ? (
<div
className="connection-action-progress"
role="status"
aria-live="polite"
aria-busy="true"
className="field-stack"
aria-busy={physicalReconnectPending || undefined}
>
<ActivityIndicator />
<strong>Проверяем прежний K1 без повторения START, STOP или настроек сети…</strong>
</div>
) : physicalRecoveryRequired ? (
<div className="field-stack">
<div className="connection-summary" role="status">
<span>Прежнее подключение не подтверждено</span>
<strong>{physicalRecoveryModeLabel}</strong>
<small>
{physicalRecoveryBinding?.deviceId
?? "Прежний K1"}
{presentedPhysicalRecoveryDeviceId ?? "Прежний K1"}
</small>
</div>
<p className="safety-note">
Переподключитесь к прежнему K1 или начните чистое подключение
нового устройства.
</p>
{physicalReadOnlyVerificationAvailable
&& !physicalRecoveryVerificationFailed ? (
{physicalReadOnlyVerificationAvailable ? (
<Button
width="full"
variant="primary"
icon={<Icon name="refresh" />}
icon={physicalReconnectPending
? <ActivityIndicator size="compact" />
: <Icon name="refresh" />}
disabled={isBusy || !physicalRecoveryTarget?.serverBound}
aria-busy={physicalReconnectPending || undefined}
onClick={() => void verifyPhysicalRecovery()}
>
Переподключиться
{physicalReconnectPending
? "Переподключаемся…"
: "Переподключиться"}
</Button>
) : null}
<Button
width="full"
variant={
physicalReadOnlyVerificationAvailable
&& !physicalRecoveryVerificationFailed
? "secondary"
: "primary"
}
icon={<Icon name="search" />}
disabled={modeResetInFlight}
disabled={isBusy || modeResetInFlight}
onClick={() => void changeDesiredConnectionMode(connectionMode)}
>
Подключить новый K1
@@ -3224,8 +3284,11 @@ export function K1ProvisioningPipeline({
</p>
) : null}
</div>
) : connectionRecoveryRequired ? (
<div className="field-stack">
) : presentedConnectionRecoveryRequired ? (
<div
className="field-stack"
aria-busy={connectionReconnectPending || undefined}
>
{correlatedConnectionAttempt && error ? (
<K1OperatorError
message={error}
@@ -3251,8 +3314,8 @@ export function K1ProvisioningPipeline({
?? connectionRecoveryAttempt?.connection_mode
?? requestedModeLabel}
</strong>
{connectionRecoveryTarget?.deviceId ? (
<small>{connectionRecoveryTarget.deviceId}</small>
{presentedConnectionRecoveryDeviceId ? (
<small>{presentedConnectionRecoveryDeviceId}</small>
) : null}
</div>
<p className="safety-note">