wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ActiveStreamRecoveryPresentation } from "../activeStreamRecovery";
|
||||
|
||||
export type ActiveStreamRecoverySurfaceVariant = "panel" | "compact";
|
||||
|
||||
export interface ActiveStreamRecoverySurfaceProps {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
forceFinishing: boolean;
|
||||
actionBusy: boolean;
|
||||
onForceFinish: () => void;
|
||||
variant?: ActiveStreamRecoverySurfaceVariant;
|
||||
}
|
||||
|
||||
interface ActiveStreamRecoverySurfaceCopy {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
statusLabel: string;
|
||||
detail: string;
|
||||
showSpinner: boolean;
|
||||
forceFinishAvailable: boolean;
|
||||
}
|
||||
|
||||
function surfaceCopy({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
}: Pick<
|
||||
ActiveStreamRecoverySurfaceProps,
|
||||
"presentation" | "forceFinishing"
|
||||
>): ActiveStreamRecoverySurfaceCopy {
|
||||
return {
|
||||
eyebrow: forceFinishing
|
||||
? "СВЯЗЬ · ЛОКАЛЬНОЕ ЗАВЕРШЕНИЕ"
|
||||
: presentation?.eyebrow ?? "СВЯЗЬ · АКТИВНЫЙ ПРИЁМ",
|
||||
title: forceFinishing
|
||||
? "Завершаем локальный приём"
|
||||
: presentation?.title ?? "Восстанавливаем соединение",
|
||||
statusLabel: forceFinishing
|
||||
? "Локальное завершение"
|
||||
: presentation?.statusLabel ?? "Восстановление связи",
|
||||
detail: forceFinishing
|
||||
? "Закрываем только локальный приём и сохранение. Команда STOP устройству не отправляется."
|
||||
: presentation?.detail ?? "Проверяем состояние активного приёма.",
|
||||
showSpinner: forceFinishing || presentation?.showSpinner === true,
|
||||
forceFinishAvailable:
|
||||
!forceFinishing && presentation?.forceFinishAvailable === true,
|
||||
};
|
||||
}
|
||||
|
||||
function RecoveryState({
|
||||
presentation,
|
||||
copy,
|
||||
}: {
|
||||
presentation: ActiveStreamRecoveryPresentation | null;
|
||||
copy: ActiveStreamRecoverySurfaceCopy;
|
||||
}) {
|
||||
const stateClassName = copy.showSpinner
|
||||
? "active-stream-recovery__state"
|
||||
: "active-stream-recovery__state active-stream-recovery__state--static";
|
||||
return (
|
||||
<div className={stateClassName}>
|
||||
{copy.showSpinner ? <ActivityIndicator size="compact" /> : null}
|
||||
<div className="active-stream-recovery__copy">
|
||||
<strong>{copy.title}</strong>
|
||||
<span>{copy.detail}</span>
|
||||
{presentation?.progressLabel ? (
|
||||
<small>{presentation.progressLabel}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryAction({
|
||||
visible,
|
||||
actionBusy,
|
||||
compact,
|
||||
onForceFinish,
|
||||
}: {
|
||||
visible: boolean;
|
||||
actionBusy: boolean;
|
||||
compact: boolean;
|
||||
onForceFinish: () => void;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className="active-stream-recovery__actions">
|
||||
<Button
|
||||
size={compact ? "compact" : undefined}
|
||||
variant="secondary"
|
||||
disabled={actionBusy}
|
||||
onClick={onForceFinish}
|
||||
>
|
||||
Прервать соединение
|
||||
</Button>
|
||||
<p>
|
||||
Завершит только локальный front/back-приём и сохранение. START, STOP,
|
||||
Bluetooth и настройки устройства не отправляются.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared recovery owner for the connection and spatial workspaces.
|
||||
*
|
||||
* The surface never chooses a mutation by itself: its sole callback is the
|
||||
* explicitly fenced local force-finish action supplied by the K1 controller.
|
||||
*/
|
||||
export function ActiveStreamRecoverySurface({
|
||||
presentation,
|
||||
forceFinishing,
|
||||
actionBusy,
|
||||
onForceFinish,
|
||||
variant = "panel",
|
||||
}: ActiveStreamRecoverySurfaceProps) {
|
||||
const copy = surfaceCopy({ presentation, forceFinishing });
|
||||
const tone = forceFinishing ? "neutral" : presentation?.tone ?? "neutral";
|
||||
const content = (
|
||||
<>
|
||||
<RecoveryState presentation={presentation} copy={copy} />
|
||||
<RecoveryAction
|
||||
visible={copy.forceFinishAvailable}
|
||||
actionBusy={actionBusy}
|
||||
compact={variant === "compact"}
|
||||
onForceFinish={onForceFinish}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls xgrids-k1-spatial-controls--recovery"
|
||||
aria-label="Восстановление активной сессии XGRIDS K1"
|
||||
aria-live="polite"
|
||||
aria-busy={copy.showSpinner}
|
||||
data-recovery-state={
|
||||
forceFinishing ? "force-finishing" : presentation?.state ?? "reconnecting"
|
||||
}
|
||||
>
|
||||
<div className="active-stream-recovery__compact-heading">
|
||||
<span>{copy.eyebrow}</span>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</div>
|
||||
<div className="active-stream-recovery active-stream-recovery--compact">
|
||||
{content}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{copy.eyebrow}</span>
|
||||
<h2>{copy.title}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={tone}>{copy.statusLabel}</StatusBadge>
|
||||
</header>
|
||||
<div className="active-stream-recovery" aria-live="polite">
|
||||
{content}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import { profileSelectionForConnectionMode } from "../compatibility";
|
||||
import {
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
exactActiveStreamRecoveryLineage,
|
||||
} from "../activeStreamRecovery";
|
||||
import {
|
||||
SUPPORTED_GNSS_MODE,
|
||||
SUPPORTED_MOUNT_TYPE,
|
||||
@@ -22,48 +28,63 @@ import {
|
||||
} from "../configuration";
|
||||
import { runAutomaticSpatialSourceStart } from "../automaticSourceStart";
|
||||
import {
|
||||
canIssueCanonicalStop,
|
||||
connectionPolicyAllows,
|
||||
isConfirmedLiveState,
|
||||
isSoftwareCommandedAcquisition,
|
||||
isReleasedTerminalAcquisitionFailure,
|
||||
currentAppliedConnectionTopology,
|
||||
isSourceRuntimeBusy,
|
||||
isVendorWriteCapable,
|
||||
isTerminalAcquisitionState,
|
||||
recoverableAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
sourceStatusLabel,
|
||||
} from "../lifecycle";
|
||||
import { normalizeProjectName, validateProjectName } from "../projectName";
|
||||
import { connectionPolicyOperatorGuidance } from "../presentation";
|
||||
import {
|
||||
normalizeProjectName,
|
||||
projectNameAfterConnectionModeSelection,
|
||||
shouldHydratePreparedProject,
|
||||
validateProjectName,
|
||||
} from "../projectName";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import type { OperatorPresenceConfirmation } from "../api";
|
||||
import {
|
||||
activeStopTarget,
|
||||
operatorActionPhysicalAcceptance,
|
||||
preparationTarget,
|
||||
preparedStartTarget,
|
||||
} from "../physicalCommandConfirmation";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "live", label: "Прямой приём" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
|
||||
export function K1AcquisitionPipeline({
|
||||
controller,
|
||||
desiredConnectionMode = "bridge",
|
||||
openSpatialScene,
|
||||
activateAutomaticSpatialSource,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
desiredConnectionMode?: "bridge" | "quick-connect" | "direct-connect";
|
||||
openSpatialScene: () => void;
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
physicalStopIntentSpent,
|
||||
physicalStopInFlight,
|
||||
closeApplicationControlSession,
|
||||
startCanonicalAcquisition,
|
||||
prepareCanonicalAcquisition,
|
||||
startPreparedAcquisition,
|
||||
startReplay,
|
||||
stop,
|
||||
stopLocalReceiver,
|
||||
forceFinishActiveStreamLocally,
|
||||
abort,
|
||||
} = controller;
|
||||
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
|
||||
@@ -75,13 +96,59 @@ export function K1AcquisitionPipeline({
|
||||
const [mountType, setMountType] = useState<MountType>(SUPPORTED_MOUNT_TYPE);
|
||||
const [gnssMode, setGnssMode] = useState<GnssMode>(SUPPORTED_GNSS_MODE);
|
||||
const hydratedAcquisitionId = useRef<string | null>(null);
|
||||
const previousDesiredConnectionMode = useRef(desiredConnectionMode);
|
||||
|
||||
const activeAcquisition = recoverableAcquisition(state);
|
||||
const preparedAcquisition = activeAcquisition?.state === "prepared" ? activeAcquisition : null;
|
||||
const projectNameValidation = validateProjectName(projectName);
|
||||
const vendorWriteCapable = isVendorWriteCapable(state);
|
||||
const control = state?.application_control_session;
|
||||
const controlPhase = control?.state ?? "idle";
|
||||
const appliedTopology = currentAppliedConnectionTopology(state);
|
||||
const connectionMode = desiredConnectionMode;
|
||||
const backendDesiredConnectionMode = state?.desired_connection_mode
|
||||
?? desiredConnectionMode;
|
||||
const configuredConnectionMode = state?.configured_connection_mode
|
||||
?? state?.connection_mode
|
||||
?? null;
|
||||
const activeConnectionMode = state?.active_connection_mode
|
||||
?? (appliedTopology?.status === "active" ? appliedTopology.connectionMode : null);
|
||||
const desiredSelectionCommitted = backendDesiredConnectionMode
|
||||
=== desiredConnectionMode;
|
||||
const desiredModeMatchesActive = desiredSelectionCommitted
|
||||
&& activeConnectionMode === desiredConnectionMode;
|
||||
const modeSwitchRequired = Boolean(
|
||||
!desiredSelectionCommitted
|
||||
|| (activeConnectionMode && !desiredModeMatchesActive)
|
||||
|| (configuredConnectionMode && configuredConnectionMode !== desiredConnectionMode),
|
||||
);
|
||||
const connectionConfigured = Boolean(
|
||||
appliedTopology?.status === "active"
|
||||
&& desiredModeMatchesActive
|
||||
&& state?.connection_lifecycle?.ready_to_start === true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousDesiredConnectionMode.current === desiredConnectionMode) return;
|
||||
previousDesiredConnectionMode.current = desiredConnectionMode;
|
||||
const projectNameAfterSelection = projectNameAfterConnectionModeSelection(
|
||||
preparedAcquisition?.project_name,
|
||||
);
|
||||
// A prepared acquisition is immutable backend state, not a draft owned by
|
||||
// this selector. Preserve its project while the operator previews another
|
||||
// mode so selecting the active mode again can resume START immediately.
|
||||
if (preparedAcquisition) {
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
return;
|
||||
}
|
||||
// Draft project fields belong to the previously selected transport. The
|
||||
// dropdown sends no physical command; Connect performs the later bounded
|
||||
// mode transaction, while START remains fenced in the meantime.
|
||||
setProjectName(projectNameAfterSelection);
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [desiredConnectionMode, preparedAcquisition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.source_mode === "live" || state?.source_mode === "replay") {
|
||||
@@ -93,11 +160,35 @@ export function K1AcquisitionPipeline({
|
||||
|
||||
useEffect(() => {
|
||||
const acquisitionId = preparedAcquisition?.acquisition_id ?? null;
|
||||
if (!acquisitionId || hydratedAcquisitionId.current === acquisitionId) return;
|
||||
if (!shouldHydratePreparedProject({
|
||||
acquisitionId,
|
||||
hydratedAcquisitionId: hydratedAcquisitionId.current,
|
||||
modeSwitchRequired,
|
||||
})) return;
|
||||
hydratedAcquisitionId.current = acquisitionId;
|
||||
setProjectName(preparedAcquisition?.project_name ?? "");
|
||||
setProjectNameTouched(false);
|
||||
}, [preparedAcquisition?.acquisition_id, preparedAcquisition?.project_name]);
|
||||
}, [
|
||||
modeSwitchRequired,
|
||||
preparedAcquisition?.acquisition_id,
|
||||
preparedAcquisition?.project_name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const acquisition = state?.acquisition;
|
||||
if (
|
||||
hydratedAcquisitionId.current === null
|
||||
|| !acquisition
|
||||
|| acquisition.acquisition_id !== hydratedAcquisitionId.current
|
||||
|| !isTerminalAcquisitionState(acquisition.state)
|
||||
|| state?.source_mode !== "idle"
|
||||
) return;
|
||||
hydratedAcquisitionId.current = null;
|
||||
setProjectName("");
|
||||
setProjectNameTouched(false);
|
||||
setMountType(SUPPORTED_MOUNT_TYPE);
|
||||
setGnssMode(SUPPORTED_GNSS_MODE);
|
||||
}, [state?.acquisition, state?.source_mode]);
|
||||
|
||||
const isBusy = pendingAction !== null;
|
||||
const sourceRuntimeBusy = isSourceRuntimeBusy(state);
|
||||
@@ -108,10 +199,50 @@ export function K1AcquisitionPipeline({
|
||||
: activeAcquisition
|
||||
? "live"
|
||||
: sessionIntent;
|
||||
const sourceLabel = sourceStatusLabel(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay" && state?.acquisition?.state === "failed";
|
||||
const gracefulStopTarget = activeStopTarget(state);
|
||||
const terminalPhysicalStopObserved =
|
||||
requiresCanonicalStopAfterTerminalLocalFailure(state);
|
||||
const physicalStopExecutable = Boolean(
|
||||
gracefulStopTarget
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const localReceiverStopExecutable = Boolean(
|
||||
connectionPolicyAllows(state, "stop-local-receiver")
|
||||
&& preparedAcquisition === null,
|
||||
);
|
||||
const terminalPhysicalStopPending = terminalPhysicalStopObserved
|
||||
&& physicalStopInFlight;
|
||||
const recoveredPhysicalStop = terminalPhysicalStopObserved
|
||||
&& physicalStopExecutable
|
||||
&& !physicalStopInFlight;
|
||||
const terminalLocalRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& localReceiverStopExecutable;
|
||||
const terminalReadOnlyRecovery = terminalPhysicalStopObserved
|
||||
&& !physicalStopPresented
|
||||
&& !localReceiverStopExecutable;
|
||||
const terminalLocalCapturePending = Boolean(
|
||||
state?.acquisition?.cleanup_pending === true
|
||||
|| state?.source_mode === "live",
|
||||
);
|
||||
const sourceLabel = terminalPhysicalStopPending
|
||||
? "Команда отправлена"
|
||||
: recoveredPhysicalStop
|
||||
? "Требуется остановка"
|
||||
: terminalLocalRecovery
|
||||
? "Локальное завершение доступно"
|
||||
: terminalReadOnlyRecovery
|
||||
? "Действия заблокированы"
|
||||
: sourceStatusLabel(state);
|
||||
const releasedAcquisitionFailure = isReleasedTerminalAcquisitionFailure(state);
|
||||
const relevantAcquisitionFailed = state?.source_mode !== "replay"
|
||||
&& state?.acquisition?.state === "failed"
|
||||
&& !releasedAcquisitionFailure;
|
||||
const sourceTone: StatusTone =
|
||||
state?.phase === "error" || relevantAcquisitionFailed
|
||||
terminalPhysicalStopObserved
|
||||
? "warning"
|
||||
: (state?.phase === "error" && !releasedAcquisitionFailure) || relevantAcquisitionFailed
|
||||
? "danger"
|
||||
: isConfirmedLiveState(state) || state?.source_mode === "replay"
|
||||
? "success"
|
||||
@@ -122,42 +253,105 @@ export function K1AcquisitionPipeline({
|
||||
() => sessionItems.map((item) => ({ ...item, disabled: sessionLocked })),
|
||||
[sessionLocked],
|
||||
);
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const activeRecoveryForceFinishAuthority = activeStreamForceFinishAuthority(state);
|
||||
const activeRecoveryLineage = exactActiveStreamRecoveryLineage(state);
|
||||
const recoveredActiveSession = Boolean(
|
||||
activeRecoveryLineage?.recovery.state === "recovered"
|
||||
&& state?.phase === "live"
|
||||
&& state.source_mode === "live"
|
||||
&& activeAcquisition?.state === "acquiring"
|
||||
&& activeRecoveryLineage.acquisitionId === activeAcquisition.acquisition_id,
|
||||
);
|
||||
const recoveredActiveSessionLabel = activeAcquisition?.project_name?.trim()
|
||||
|| activeAcquisition?.acquisition_id
|
||||
|| "текущая сессия";
|
||||
const localForceFinishPending = pendingAction === "force-finish";
|
||||
|
||||
if (activeRecoveryPresentation || localForceFinishPending) {
|
||||
return (
|
||||
<ActiveStreamRecoverySurface
|
||||
presentation={activeRecoveryPresentation}
|
||||
forceFinishing={localForceFinishPending}
|
||||
actionBusy={pendingAction !== null}
|
||||
onForceFinish={() => {
|
||||
if (!activeRecoveryForceFinishAuthority) return;
|
||||
void forceFinishActiveStreamLocally();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const preparedCanonicalLaunch =
|
||||
preparedAcquisition?.control_mode === "plugin-commanded";
|
||||
const launchBlockedByAcquisition =
|
||||
activeAcquisition !== null && !preparedCanonicalLaunch;
|
||||
const controlRetryBlocked =
|
||||
controlPhase === "failed" && control?.can_open !== true;
|
||||
const finalStartTarget = preparedStartTarget(state);
|
||||
const draftPreparationTarget = preparationTarget(
|
||||
state,
|
||||
projectNameValidation.value,
|
||||
);
|
||||
const physicalStartAllowed = connectionPolicyAllows(state, "start-acquisition");
|
||||
const physicalStartGuidance = finalStartTarget && !physicalStartAllowed
|
||||
? connectionPolicyOperatorGuidance(state, "start-acquisition")
|
||||
: null;
|
||||
const physicalStopGuidance = gracefulStopTarget
|
||||
&& !physicalStopPresented
|
||||
&& !terminalPhysicalStopObserved
|
||||
? connectionPolicyOperatorGuidance(state, "stop-acquisition")
|
||||
: null;
|
||||
const physicalStopGuidanceCopy = terminalPhysicalStopPending
|
||||
? "Команда остановки устройства уже отправлена. Ждём новое подтверждённое состояние; повторная команда не отправляется."
|
||||
: terminalLocalRecovery
|
||||
? physicalStopIntentSpent
|
||||
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
|
||||
: "Управляющая команда устройству сейчас недоступна. Завершите только разрешённый сервером локальный приём или выполните read-only восстановление."
|
||||
: terminalReadOnlyRecovery
|
||||
? "Управляющие действия сейчас не разрешены. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: physicalStopGuidance
|
||||
? `${physicalStopGuidance.reason} ${physicalStopGuidance.nextAction}`
|
||||
: gracefulStopTarget && !physicalStopPresented && physicalStopIntentSpent
|
||||
? "Команда завершилась без нового подтверждённого результата. Повторная команда устройству не отправляется; завершите только локальный приём."
|
||||
: gracefulStopTarget && !physicalStopPresented
|
||||
? "Команда устройству недоступна в текущем подтверждённом состоянии. Завершите только локальный приём или выполните read-only восстановление."
|
||||
: null;
|
||||
|
||||
const startLive = async () => {
|
||||
const submitFinalStart = async () => {
|
||||
const physicalAcceptance = operatorActionPhysicalAcceptance();
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startPreparedAcquisition(physicalAcceptance),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
};
|
||||
|
||||
const requestLivePreparation = async () => {
|
||||
setProjectNameTouched(true);
|
||||
if (
|
||||
!state?.k1_ip ||
|
||||
!connectionConfigured ||
|
||||
!connectionMode ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked ||
|
||||
projectNameValidation.error
|
||||
) return;
|
||||
const timezoneName = Intl.DateTimeFormat().resolvedOptions().timeZone || "Etc/UTC";
|
||||
await runAutomaticSpatialSourceStart(
|
||||
() => startCanonicalAcquisition({
|
||||
control: {
|
||||
...PHYSICAL_ACCEPTANCE,
|
||||
timezone_name: timezoneName,
|
||||
},
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(
|
||||
state.connection_mode ?? "bridge",
|
||||
),
|
||||
},
|
||||
physicalAcceptance: PHYSICAL_ACCEPTANCE,
|
||||
}),
|
||||
activateAutomaticSpatialSource,
|
||||
openSpatialScene,
|
||||
);
|
||||
if (finalStartTarget) {
|
||||
if (!physicalStartAllowed) return;
|
||||
await submitFinalStart();
|
||||
return;
|
||||
}
|
||||
if (!draftPreparationTarget) return;
|
||||
const prepared = await prepareCanonicalAcquisition({
|
||||
acquisition: {
|
||||
project_name: projectNameValidation.value,
|
||||
mount_type: SUPPORTED_MOUNT_TYPE,
|
||||
gnss_mode: SUPPORTED_GNSS_MODE,
|
||||
compatibility_attestation: profileSelectionForConnectionMode(connectionMode),
|
||||
},
|
||||
});
|
||||
if (!prepared) return;
|
||||
await submitFinalStart();
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
@@ -177,8 +371,8 @@ export function K1AcquisitionPipeline({
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">{effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
|
||||
<span className="section-eyebrow">{terminalPhysicalStopPending ? "ВОССТАНОВЛЕНИЕ · КОМАНДА ОТПРАВЛЕНА" : recoveredPhysicalStop ? "ВОССТАНОВЛЕНИЕ · ОСТАНОВКА" : terminalLocalRecovery ? "ВОССТАНОВЛЕНИЕ · ЛОКАЛЬНЫЙ КОНТУР" : terminalReadOnlyRecovery ? "ВОССТАНОВЛЕНИЕ · ТОЛЬКО ЧТЕНИЕ" : recoveredActiveSession ? "СВЯЗЬ ВОССТАНОВЛЕНА · АКТИВНЫЙ ПРИЁМ" : effectiveSessionIntent === "live" ? "ШАГИ 04–05 · ПРОЕКТ И ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}</span>
|
||||
<h2>{terminalPhysicalStopPending ? "Ожидаем подтверждение устройства" : recoveredPhysicalStop ? "Сканирование продолжается" : terminalLocalRecovery ? "Завершите локальный приём" : terminalReadOnlyRecovery ? "Ожидайте подтверждённое состояние" : recoveredActiveSession ? "Связь восстановлена · приём продолжается" : effectiveSessionIntent === "live" ? "Назовите проект и запустите приём" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={sourceTone}>{sourceLabel}</StatusBadge>
|
||||
</header>
|
||||
@@ -188,7 +382,55 @@ export function K1AcquisitionPipeline({
|
||||
items={selectableSessionItems}
|
||||
onChange={(intent) => { if (!sessionLocked) setSessionIntent(intent); }}
|
||||
/>
|
||||
{effectiveSessionIntent === "live" ? (
|
||||
{terminalPhysicalStopObserved ? (
|
||||
<div className="session-form">
|
||||
<div className="connection-summary">
|
||||
{terminalPhysicalStopPending ? (
|
||||
<>
|
||||
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
|
||||
<strong>Команда остановки устройства уже отправлена</strong>
|
||||
<small>
|
||||
Ждём новое подтверждённое состояние K1. Повторная команда устройству не отправляется.
|
||||
</small>
|
||||
</>
|
||||
) : recoveredPhysicalStop ? (
|
||||
<>
|
||||
<span>{terminalLocalCapturePending ? "Локальный приём ещё требует завершения" : "Локальная запись завершена"}</span>
|
||||
<strong>Сканирование подтверждено; требуется явный STOP</strong>
|
||||
<small>
|
||||
Нажмите «Остановить сканирование» ниже или в пространственной сцене. Новый проект, START и настройка сети останутся заблокированы до подтверждённого READY.
|
||||
</small>
|
||||
</>
|
||||
) : terminalLocalRecovery ? (
|
||||
<>
|
||||
<span>Команды устройству заблокированы</span>
|
||||
<strong>Доступно локальное завершение приёма</strong>
|
||||
<small>
|
||||
Повторная команда K1 не отправляется. Завершите локальный приём или выполните read-only восстановление.
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Управляющие действия заблокированы</span>
|
||||
<strong>Доступно только read-only восстановление</strong>
|
||||
<small>
|
||||
Дождитесь нового подтверждённого состояния; локальные и управляющие команды сейчас не разрешены.
|
||||
</small>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : recoveredActiveSession ? (
|
||||
<div className="session-form">
|
||||
<div className="connection-summary">
|
||||
<span>Исходная сессия · {recoveredActiveSessionLabel}</span>
|
||||
<strong>Продолжаем тот же приём без нового START</strong>
|
||||
<small>
|
||||
Автоматическое восстановление не отправляло START, STOP, Bluetooth или настройки сети. Явная остановка ниже доступна только при текущем подтверждённом праве на STOP.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : effectiveSessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<div className="scan-configuration-grid">
|
||||
<div className="configuration-field">
|
||||
@@ -228,40 +470,53 @@ export function K1AcquisitionPipeline({
|
||||
aria-invalid={projectNameTouched && projectNameValidation.error ? true : undefined}
|
||||
description={projectNameTouched && projectNameValidation.error
|
||||
? projectNameValidation.error
|
||||
: "Отдельной команды сохранения имени на K1 нет: оно отправляется только при START."}
|
||||
: "Имя отправляется только при START; отдельной команды сохранения нет."}
|
||||
placeholder="Например, TEST001"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
aria-busy={pendingAction === "live"}
|
||||
icon={pendingAction === "live"
|
||||
? <ActivityIndicator size="compact" />
|
||||
: <Icon name="activity" />}
|
||||
disabled={
|
||||
isBusy ||
|
||||
!state?.k1_ip ||
|
||||
!connectionConfigured ||
|
||||
projectNameValidation.error !== null ||
|
||||
sourceRuntimeBusy ||
|
||||
launchBlockedByAcquisition ||
|
||||
controlRetryBlocked
|
||||
controlRetryBlocked ||
|
||||
modeSwitchRequired ||
|
||||
Boolean(finalStartTarget && !physicalStartAllowed)
|
||||
}
|
||||
onClick={() => void startLive()}
|
||||
onClick={() => void requestLivePreparation()}
|
||||
>
|
||||
{pendingAction === "live"
|
||||
? controlPhase === "connecting"
|
||||
? "Синхронизация с K1…"
|
||||
? "Синхронизация…"
|
||||
: controlPhase === "workspace-requested"
|
||||
? "Входим в рабочее пространство…"
|
||||
: controlPhase === "project-requested"
|
||||
? "Готовим проект и локальный приём…"
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования…"
|
||||
: "Запускаем K1 и локальный приём…"
|
||||
: preparedCanonicalLaunch
|
||||
? "Продолжить запуск сканирования и приёма"
|
||||
: "Запустить сканирование и локальный приём"}
|
||||
? "Запускаем приём…"
|
||||
: "Подготавливаем проект и локальный приём…"
|
||||
: finalStartTarget
|
||||
? "Запустить приём"
|
||||
: preparedCanonicalLaunch
|
||||
? "Продолжить запуск"
|
||||
: "Запустить приём"}
|
||||
</Button>
|
||||
<p className="start-confirmation-note">
|
||||
Нажатие запуска — явное операторское действие для выбранного K1. Автоматических повторов START нет.
|
||||
{modeSwitchRequired
|
||||
? `Выбран другой способ связи. Сначала установите подключение через ${desiredConnectionMode === "bridge" ? "Bridge" : desiredConnectionMode === "quick-connect" ? "Quick Connect" : "Direct Connect"}.`
|
||||
: !connectionConfigured
|
||||
? "Сначала завершите подключение в выбранном режиме. START не используется для установки связи."
|
||||
: physicalStartGuidance
|
||||
? `${physicalStartGuidance.reason} ${physicalStartGuidance.nextAction}`
|
||||
: "Одно нажатие выполняет каноническую подготовку и один START после подтверждённого READY. Автоматических повторов команд нет."}
|
||||
</p>
|
||||
{control?.control_socket_open && !activeAcquisition && !isBusy ? (
|
||||
{control?.control_socket_open && !activeAcquisition && !recoveredPhysicalStop && !isBusy ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={isBusy}
|
||||
@@ -272,23 +527,23 @@ export function K1AcquisitionPipeline({
|
||||
) : null}
|
||||
<p className="live-instruction">
|
||||
{controlPhase === "failed"
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручная проверка K1"}`
|
||||
? `Диалог остановлен без автоповтора: ${control?.failure?.message || "требуется ручное действие"}`
|
||||
: controlPhase === "connecting"
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ K1."
|
||||
? "Выполняются операции 1–6 записанного диалога; следующий этап ждёт подтверждённый ответ."
|
||||
: controlPhase === "workspace-requested"
|
||||
? "После подтверждённых операций 1–6 выполняется вход в рабочее пространство."
|
||||
: controlPhase === "project-requested"
|
||||
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется на K1."
|
||||
? "Выполняются операции 8–10 и готовится локальный приём; имя ещё не отправляется."
|
||||
: controlPhase === "start-requested" || controlPhase === "initializing"
|
||||
? "Калибровка оборудования. Не перемещайте K1; временных переходов и повторных команд нет."
|
||||
? "Калибровка оборудования. Не перемещайте сканер; временных переходов и повторных команд нет."
|
||||
: controlPhase === "scanning"
|
||||
? "K1 подтвердил SCANNING и инициализацию. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка выражает намерение запустить сканирование. Совместимость подтверждается живым DeviceInfo; этапы идут строго по записанному порядку и только после ответов K1."}
|
||||
? "Режим сканирования и инициализация подтверждены. Остановка доступна в пространственной сцене."
|
||||
: "Одна кнопка запускает весь процесс. Совместимость подключения подтверждается автоматически; каждый следующий этап начинается только после подтверждения результата."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField label="Путь к записи" hint="Локальный файл исходных данных" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
|
||||
<TextField label="Путь к записи" hint="Локальный файл записи" value={replayPath} onChange={(event) => setReplayPath(event.target.value)} spellCheck={false} placeholder="sessions/.../capture.tsv" />
|
||||
<TextField label="Скорость повтора" hint="Множитель" type="number" min="0.1" step="0.1" value={replaySpeed} onChange={(event) => setReplaySpeed(event.target.value)} />
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
|
||||
@@ -301,25 +556,47 @@ export function K1AcquisitionPipeline({
|
||||
)}
|
||||
<div className="session-footer">
|
||||
<p>
|
||||
{state?.source_mode === "replay"
|
||||
{physicalStopGuidanceCopy
|
||||
? physicalStopGuidanceCopy
|
||||
: recoveredPhysicalStop && physicalStopExecutable
|
||||
? terminalLocalCapturePending
|
||||
? "Локальный приём ещё требует завершения. Эта кнопка отправит ровно один явный STOP и дождётся подтверждённого результата."
|
||||
: "Локальная запись уже остановлена. Эта кнопка отправит ровно один явный STOP и дождётся READY."
|
||||
: state?.source_mode === "replay"
|
||||
? "Остановка завершит фактически запущенный повтор записи."
|
||||
: activeAcquisition || state?.source_mode === "live"
|
||||
? vendorWriteCapable && activeAcquisition?.control_mode === "plugin-commanded"
|
||||
? "Остановка отправит профилированную команду K1 и дождётся завершения локального сохранения."
|
||||
: "Остановка завершает только локальный приём и сохранение. Физическое состояние сканера остаётся неизвестным."
|
||||
? physicalStopPresented
|
||||
? "Остановка отправит профилированную команду и дождётся завершения локального сохранения."
|
||||
: localReceiverStopExecutable
|
||||
? "Остановка завершает только локальный приём и сохранение. Состояние сканирования остаётся неизвестным."
|
||||
: "Действие остановки сейчас не разрешено. Дождитесь нового подтверждённого состояния или выполните read-only восстановление."
|
||||
: "Активного источника сейчас нет."}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || (!sourceRuntimeBusy && preparedAcquisition !== null) || (!sourceRuntimeBusy && activeAcquisition === null)}
|
||||
onClick={() => void stop(
|
||||
isSoftwareCommandedAcquisition(state) ? PHYSICAL_ACCEPTANCE : undefined,
|
||||
)}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? state?.source_mode === "replay" ? "Останавливаем повтор…" : "Останавливаем локальный приём…"
|
||||
: state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
{physicalStopPresented || localReceiverStopExecutable ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={
|
||||
isBusy
|
||||
|| (physicalStopPresented && !physicalStopExecutable)
|
||||
|| (!sourceRuntimeBusy && preparedAcquisition !== null)
|
||||
}
|
||||
onClick={() => {
|
||||
if (physicalStopExecutable) {
|
||||
void stop(operatorActionPhysicalAcceptance());
|
||||
return;
|
||||
}
|
||||
if (localReceiverStopExecutable) {
|
||||
void stopLocalReceiver();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{physicalStopInFlight
|
||||
? "Останавливаем устройство…"
|
||||
: pendingAction === "stop"
|
||||
? physicalStopPresented ? "Останавливаем устройство…" : state?.source_mode === "replay" ? "Останавливаем повтор…" : "Завершаем локальный приём…"
|
||||
: physicalStopPresented ? recoveredPhysicalStop ? "Остановить сканирование" : "Остановить устройство и запись" : state?.source_mode === "replay" ? "Остановить повтор" : preparedAcquisition ? "Завершить подготовленный приём" : "Завершить локальный приём"}
|
||||
</Button>
|
||||
) : null}
|
||||
{activeAcquisition ? (
|
||||
<Button variant="ghost" disabled={isBusy} onClick={() => void abort()}>
|
||||
{pendingAction === "abort" ? "Прерываем локальную операцию…" : preparedAcquisition ? "Отменить подготовку" : "Аварийно завершить локальный приём"}
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
formatNumber,
|
||||
pipelineLatency,
|
||||
} from "../presentation";
|
||||
import { isConfirmedLiveState } from "../lifecycle";
|
||||
import {
|
||||
activeConnectionEndpointLabel,
|
||||
backendConnectionTopology,
|
||||
isConfirmedLiveState,
|
||||
} from "../lifecycle";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
|
||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -33,6 +37,18 @@ export function K1Diagnostics({ controller, sourceLabel }: {
|
||||
const { state, backendStatus, eventStatus, latencyHistory } = controller;
|
||||
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
|
||||
const latency = pipelineLatency(streamActive ? state?.metrics : undefined);
|
||||
const activeEndpoint = activeConnectionEndpointLabel(state);
|
||||
const topology = backendConnectionTopology(state);
|
||||
const unverifiedEndpoint = topology?.status !== "active" ? topology?.endpoint : null;
|
||||
const endpointLabel = activeEndpoint
|
||||
? "Адрес подключения"
|
||||
: topology?.source === "durable"
|
||||
? "Адрес конфигурации"
|
||||
: topology?.source === "last-known"
|
||||
? "Адрес конфигурации"
|
||||
: topology?.source === "applied"
|
||||
? "Адрес конфигурации"
|
||||
: "Адрес подключения";
|
||||
return (
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
@@ -43,7 +59,20 @@ export function K1Diagnostics({ controller, sourceLabel }: {
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий"><span className="inline-state" data-state={eventStatus}>{eventStatusLabel(eventStatus)}</span></DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства"><code>{state?.k1_ip || "Не получен"}</code></DetailRow>
|
||||
<DetailRow label={endpointLabel}>
|
||||
{activeEndpoint
|
||||
? <code>{activeEndpoint}</code>
|
||||
: unverifiedEndpoint
|
||||
? (
|
||||
<span>
|
||||
<code>{unverifiedEndpoint}</code>
|
||||
{topology?.status === "configured-unverified"
|
||||
? " · подключение ещё не подтверждено"
|
||||
: " · связь не подтверждена"}
|
||||
</span>
|
||||
)
|
||||
: <span>Не получен</span>}
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { isConfirmedLiveState } from "../lifecycle";
|
||||
import { hasAuthoritativeData, isConfirmedLiveState } from "../lifecycle";
|
||||
import { finiteMetric, formatNumber, pipelineLatency } from "../presentation";
|
||||
import type { XgridsK1Controller } from "../runtimeContext";
|
||||
import { MetricCard } from "./MetricCard";
|
||||
|
||||
export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
|
||||
const { state } = controller;
|
||||
const streamActive = isConfirmedLiveState(state) || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const streamAuthoritative = state?.source_mode === "replay" || Boolean(
|
||||
isConfirmedLiveState(state) && hasAuthoritativeData(state),
|
||||
);
|
||||
const metrics = streamAuthoritative ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
@@ -35,7 +37,7 @@ export function K1Metrics({ controller }: { controller: XgridsK1Controller }) {
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
detail="Данные потока при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { XgridsConnectionAttempt } from "../api";
|
||||
import { hostFailureDiagnosticPresentation } from "../hostDiagnosticPresentation";
|
||||
|
||||
const connectionAttemptStageLabels: Record<string, string> = {
|
||||
accepted: "Запрос принят",
|
||||
"scan-selection-admitted": "Результат выбран",
|
||||
"host-wifi-profile-preflight": "Подготовка профиля Wi‑Fi",
|
||||
"device-ap-activation": "Подготовка локальной сети",
|
||||
"ble-provisioning-write": "Передаются настройки сети",
|
||||
"ble-write-dispatched": "Настройки переданы",
|
||||
"status-observing": "Ожидание ответа",
|
||||
"device-topology-applied": "Целевая сеть подтверждена",
|
||||
"host-wifi-association": "Настройка связи с сетью",
|
||||
"control-endpoint-admission": "Подготовка управляющего канала",
|
||||
connected: "Связь подтверждена",
|
||||
"network-configured": "Сеть настроена",
|
||||
};
|
||||
|
||||
function attemptStageLabel(attempt: XgridsConnectionAttempt): string {
|
||||
const normalized = attempt.stage.replace(/-failed$/, "");
|
||||
return connectionAttemptStageLabels[normalized] ?? "Подключение остановлено";
|
||||
}
|
||||
|
||||
function attemptSideEffectLabel(value: string): string {
|
||||
if (value === "none") return "Команда не отправлялась";
|
||||
if (value === "applied") return "Целевая сеть подтверждена";
|
||||
if (value === "confirmed") return "Передача команды подтверждена";
|
||||
return "Результат команды не подтверждён";
|
||||
}
|
||||
|
||||
export function attemptNetworkPhaseLabel(
|
||||
value: XgridsConnectionAttempt["phase"],
|
||||
): string {
|
||||
const phase = String(value);
|
||||
if (phase === "network_applied") return "Настройки сети применены";
|
||||
if (phase === "network_outcome_unknown") {
|
||||
return "Результат применения настроек сети не подтверждён";
|
||||
}
|
||||
return "Настройки сети не применены";
|
||||
}
|
||||
|
||||
function attemptControlStateLabel(
|
||||
value: XgridsConnectionAttempt["control_state"],
|
||||
): string {
|
||||
if (value === "ready") return "Управляющее подключение подтверждено";
|
||||
if (value === "control_not_ready") return "Управляющее подключение не подтверждено";
|
||||
return "Состояние управляющего подключения неизвестно";
|
||||
}
|
||||
|
||||
export function attemptNextActionLabel(
|
||||
value: XgridsConnectionAttempt["safe_next_action"],
|
||||
): string {
|
||||
switch (value) {
|
||||
case "wait-for-current-attempt":
|
||||
return "Дождаться завершения текущей попытки";
|
||||
case "continue-with-control-verification":
|
||||
return "Продолжить текущее подключение";
|
||||
case "verify-control-read-only":
|
||||
return "Проверить управление без изменения сети";
|
||||
case "start-acquisition":
|
||||
return "Готово к запуску приёма";
|
||||
case "stop-local-receiver":
|
||||
return "Завершить только локальный приём";
|
||||
case "retire-unavailable-physical-target":
|
||||
return "Исключить недоступный прежний K1 и выбрать другой";
|
||||
case "scan-select-connect":
|
||||
return "Выполнить новый поиск и выбрать результат";
|
||||
case "manual-recovery-required":
|
||||
return "Требуется ручное восстановление";
|
||||
}
|
||||
}
|
||||
|
||||
const publicConnectionErrorLabels: Readonly<Record<string, string>> = {
|
||||
"network-provision-discovery-generation-conflict":
|
||||
"Результат Bluetooth-поиска устарел до отправки. Настройки устройства не изменялись; выполните новый поиск.",
|
||||
"connection-mode-draft-revision-conflict":
|
||||
"Способ подключения изменился до запуска операции. Настройки устройства не изменялись; повторите явное действие.",
|
||||
"connection-mode-draft-mismatch":
|
||||
"Выбранный способ подключения ещё не подтверждён локальным контуром. Настройки устройства не изменялись.",
|
||||
"physical-command-reconciliation-required":
|
||||
"Сначала завершите отдельную проверку физического состояния K1 без изменений устройства. Новая команда не отправлялась.",
|
||||
"physical-device-already-active":
|
||||
"K1 всё ещё подтверждён в активном сканировании. Сначала выполните явную остановку; новая сетевая команда не отправлялась.",
|
||||
};
|
||||
|
||||
function publicConnectionErrorLabel(
|
||||
attempt: XgridsConnectionAttempt | null | undefined,
|
||||
structured: ReturnType<typeof hostFailureDiagnosticPresentation>,
|
||||
): string {
|
||||
const publicCode = attempt?.public_error_code?.trim();
|
||||
if (publicCode && publicConnectionErrorLabels[publicCode]) {
|
||||
return publicConnectionErrorLabels[publicCode];
|
||||
}
|
||||
return structured
|
||||
? "Системный контур безопасно остановил операцию. Автоматического повтора не было."
|
||||
: "Подключение не завершено. Автоматического повтора не было.";
|
||||
}
|
||||
|
||||
export function K1OperatorError({
|
||||
diagnostic,
|
||||
attempt,
|
||||
title = "Локальная операция завершилась ошибкой",
|
||||
recoveryActions,
|
||||
compact = false,
|
||||
showDefaultActions = true,
|
||||
onRefresh,
|
||||
onClear,
|
||||
}: {
|
||||
/** Kept for call-site compatibility; unreviewed exception text is never rendered. */
|
||||
message?: string;
|
||||
diagnostic?: unknown;
|
||||
attempt?: XgridsConnectionAttempt | null;
|
||||
title?: string;
|
||||
recoveryActions?: ReactNode;
|
||||
compact?: boolean;
|
||||
showDefaultActions?: boolean;
|
||||
onRefresh: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const structured = hostFailureDiagnosticPresentation(diagnostic);
|
||||
const [diagnosticCopied, setDiagnosticCopied] = useState(false);
|
||||
const copyDiagnosticBundle = async () => {
|
||||
if (!attempt?.diagnostic_bundle || !navigator.clipboard) return;
|
||||
await navigator.clipboard.writeText(
|
||||
JSON.stringify(attempt.diagnostic_bundle, null, 2),
|
||||
);
|
||||
setDiagnosticCopied(true);
|
||||
};
|
||||
const hasDetails = Boolean(structured || attempt);
|
||||
return (
|
||||
<aside
|
||||
className={`error-banner${compact ? " error-banner--compact" : ""}`}
|
||||
role="alert"
|
||||
>
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div className="error-banner__copy">
|
||||
<strong>{title}</strong>
|
||||
<p>{publicConnectionErrorLabel(attempt, structured)}</p>
|
||||
{recoveryActions ? (
|
||||
<div className="error-banner__recovery-actions">
|
||||
{recoveryActions}
|
||||
</div>
|
||||
) : null}
|
||||
{hasDetails ? (
|
||||
<details className="error-banner__details">
|
||||
<summary>Подробности и диагностика</summary>
|
||||
{structured ? (
|
||||
<dl className="error-banner__diagnostic">
|
||||
<div>
|
||||
<dt>Причина</dt>
|
||||
<dd>{structured.codeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Системный контур</dt>
|
||||
<dd>{structured.domainLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Влияние</dt>
|
||||
<dd>{structured.impactLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Что сделать</dt>
|
||||
<dd>{structured.operatorActionLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
{attempt ? (
|
||||
<dl
|
||||
className="error-banner__diagnostic"
|
||||
aria-label="Диагностика подключения"
|
||||
>
|
||||
<div>
|
||||
<dt>Попытка</dt>
|
||||
<dd><code>{attempt.attempt_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Остановлено на шаге</dt>
|
||||
<dd>{attemptStageLabel(attempt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Что изменилось</dt>
|
||||
<dd>{attemptSideEffectLabel(attempt.side_effect_status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сеть</dt>
|
||||
<dd>{attemptNetworkPhaseLabel(attempt.phase)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Управление</dt>
|
||||
<dd>{attemptControlStateLabel(attempt.control_state)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Безопасное действие</dt>
|
||||
<dd>{attemptNextActionLabel(attempt.safe_next_action)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
{attempt?.diagnostic_bundle || showDefaultActions ? (
|
||||
<div className="error-banner__actions">
|
||||
{attempt?.diagnostic_bundle ? (
|
||||
<Button size="compact" variant="secondary" onClick={() => void copyDiagnosticBundle()}>
|
||||
{diagnosticCopied ? "Диагностика скопирована" : "Скопировать диагностику"}
|
||||
</Button>
|
||||
) : null}
|
||||
{showDefaultActions ? (
|
||||
<>
|
||||
<Button size="compact" variant="secondary" onClick={onRefresh}>
|
||||
Проверить состояние
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={onClear}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,22 @@
|
||||
import { Button } from "@nodedc/ui-react";
|
||||
import { ActivityIndicator, Button } from "@nodedc/ui-react";
|
||||
|
||||
import type { DevicePluginConnectionProps } from "@mission-core/plugin-sdk";
|
||||
import type {
|
||||
AcquisitionState,
|
||||
OperatorPresenceConfirmation,
|
||||
XgridsAcquisition,
|
||||
XgridsK1State,
|
||||
} from "../api";
|
||||
import {
|
||||
activeStreamForceFinishAuthority,
|
||||
activeStreamRecoveryPresentation,
|
||||
} from "../activeStreamRecovery";
|
||||
import {
|
||||
canIssueCanonicalStop,
|
||||
connectionPolicyAllows,
|
||||
hasAuthoritativeData,
|
||||
hasControlAuthority,
|
||||
isSoftwareCommandedAcquisition,
|
||||
requiresCanonicalStopAfterTerminalLocalFailure,
|
||||
shouldRenderSpatialControls,
|
||||
} from "../lifecycle";
|
||||
import {
|
||||
@@ -15,7 +24,15 @@ import {
|
||||
formatNumber,
|
||||
spatialActionFailure,
|
||||
} from "../presentation";
|
||||
import { useXgridsK1Controller } from "../runtimeContext";
|
||||
import {
|
||||
activeStopTarget,
|
||||
operatorActionPhysicalAcceptance,
|
||||
} from "../physicalCommandConfirmation";
|
||||
import {
|
||||
useXgridsK1Controller,
|
||||
type XgridsK1Controller,
|
||||
} from "../runtimeContext";
|
||||
import { ActiveStreamRecoverySurface } from "./ActiveStreamRecoverySurface";
|
||||
|
||||
interface PhasePresentation {
|
||||
label: string;
|
||||
@@ -23,15 +40,31 @@ interface PhasePresentation {
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
const PHYSICAL_ACCEPTANCE = {
|
||||
operator_present: true,
|
||||
owner_controlled_device: true,
|
||||
lixelgo_closed: true,
|
||||
battery_storage_confirmed: true,
|
||||
expected_physical_state_confirmed: true,
|
||||
} satisfies OperatorPresenceConfirmation;
|
||||
export interface K1SpatialAuthorityState {
|
||||
controlAuthoritative: boolean;
|
||||
dataAuthoritative: boolean;
|
||||
softwareCommanded: boolean;
|
||||
authorityFailure: string | null;
|
||||
}
|
||||
|
||||
function phasePresentation(
|
||||
export function k1SpatialAuthorityState(
|
||||
state: XgridsK1State | null | undefined,
|
||||
): K1SpatialAuthorityState {
|
||||
const controlAuthoritative = hasControlAuthority(state);
|
||||
const dataAuthoritative = hasAuthoritativeData(state);
|
||||
return {
|
||||
controlAuthoritative,
|
||||
dataAuthoritative,
|
||||
softwareCommanded: controlAuthoritative && isSoftwareCommandedAcquisition(state),
|
||||
authorityFailure: state?.acquisition?.state === "acquiring" && !dataAuthoritative
|
||||
? controlAuthoritative
|
||||
? "Поток данных K1 не подтверждён supervisor-ом. Телеметрия скрыта до восстановления data authority."
|
||||
: "Управляющая сессия K1 потеряна. Локальное завершение доступно, но команды устройству запрещены."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function k1SpatialPhasePresentation(
|
||||
acquisition: XgridsAcquisition,
|
||||
softwareCommanded: boolean,
|
||||
): PhasePresentation {
|
||||
@@ -49,16 +82,20 @@ function phasePresentation(
|
||||
busy: false,
|
||||
},
|
||||
awaiting_external_start: {
|
||||
label: "Ожидание запуска на устройстве",
|
||||
detail: "Запустите сканирование физической кнопкой K1.",
|
||||
label: softwareCommanded
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Ожидание запуска на устройстве",
|
||||
detail: softwareCommanded
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Запустите сканирование физической кнопкой K1.",
|
||||
busy: true,
|
||||
},
|
||||
starting: {
|
||||
label: softwareCommanded
|
||||
? "Калибровка оборудования"
|
||||
? "K1 калибруется и готовит облако точек"
|
||||
: "Подготовка локального приёмника",
|
||||
detail: softwareCommanded
|
||||
? "Статическая инициализация после запуска — не перемещайте устройство."
|
||||
? "Первые данные могут появиться через десятки секунд. Не перемещайте устройство."
|
||||
: "Mission Core запускает запись до физического старта K1.",
|
||||
busy: true,
|
||||
},
|
||||
@@ -106,43 +143,146 @@ function formatDuration(seconds: number): string {
|
||||
: `${String(minutes).padStart(2, "0")}:${String(remainder).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
const { state, pendingAction, stop } = controller;
|
||||
export function runSpatialActiveStreamForceFinish(
|
||||
controller: Pick<
|
||||
XgridsK1Controller,
|
||||
"state" | "forceFinishActiveStreamLocally"
|
||||
>,
|
||||
): Promise<boolean> {
|
||||
if (!activeStreamForceFinishAuthority(controller.state)) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return controller.forceFinishActiveStreamLocally();
|
||||
}
|
||||
|
||||
export function K1SpatialControlsView({
|
||||
controller,
|
||||
}: {
|
||||
controller: XgridsK1Controller;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
pendingAction,
|
||||
physicalStopIntentSpent,
|
||||
physicalStopInFlight,
|
||||
stop,
|
||||
stopLocalReceiver,
|
||||
forceFinishActiveStreamLocally,
|
||||
} = controller;
|
||||
const acquisition = state?.acquisition;
|
||||
const activeRecoveryPresentation = activeStreamRecoveryPresentation(state);
|
||||
const localForceFinishPending = pendingAction === "force-finish";
|
||||
|
||||
if (activeRecoveryPresentation || localForceFinishPending) {
|
||||
return (
|
||||
<ActiveStreamRecoverySurface
|
||||
presentation={activeRecoveryPresentation}
|
||||
forceFinishing={localForceFinishPending}
|
||||
actionBusy={pendingAction !== null}
|
||||
variant="compact"
|
||||
onForceFinish={() => {
|
||||
void runSpatialActiveStreamForceFinish({
|
||||
state,
|
||||
forceFinishActiveStreamLocally,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const physicalStopTarget = activeStopTarget(state);
|
||||
const localReceiverStopAllowed = connectionPolicyAllows(state, "stop-local-receiver");
|
||||
const physicalStopExecutable = Boolean(
|
||||
physicalStopTarget
|
||||
&& canIssueCanonicalStop(state, physicalStopIntentSpent),
|
||||
);
|
||||
const physicalStopPresented = physicalStopInFlight || physicalStopExecutable;
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition?.state ?? "",
|
||||
);
|
||||
const cleanupPending = acquisition?.cleanup_pending === true;
|
||||
|
||||
if (!acquisition || !shouldRenderSpatialControls(state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
controlAuthoritative,
|
||||
dataAuthoritative,
|
||||
authorityFailure,
|
||||
} = k1SpatialAuthorityState(state);
|
||||
const softwareCommanded = isSoftwareCommandedAcquisition(state);
|
||||
const phase = phasePresentation(acquisition, softwareCommanded);
|
||||
const telemetry = deviceTelemetry(state.metrics);
|
||||
const stopping = ["awaiting_external_stop", "stopping", "finalizing"].includes(
|
||||
acquisition.state,
|
||||
);
|
||||
const stopDisabled = pendingAction !== null || stopping;
|
||||
const dataPlaneState = state?.connection_supervisor?.observed.data_plane.state;
|
||||
const terminalPhysicalStopRequired = requiresCanonicalStopAfterTerminalLocalFailure(state);
|
||||
const phase = physicalStopInFlight
|
||||
? {
|
||||
label: "Команда остановки устройства отправлена",
|
||||
detail: "Ждём подтверждённое состояние K1; повторная команда не отправляется.",
|
||||
busy: true,
|
||||
}
|
||||
: terminalPhysicalStopRequired && physicalStopExecutable
|
||||
? {
|
||||
label: "Локальный приём остановился · K1 продолжает работу",
|
||||
detail: "Остановите устройство явной командой; новый START заблокирован.",
|
||||
busy: false,
|
||||
}
|
||||
: terminalPhysicalStopRequired && localReceiverStopAllowed
|
||||
? {
|
||||
label: "Состояние K1 требует безопасного восстановления",
|
||||
detail: "Команда устройству не отправляется. Доступно разрешённое сервером локальное завершение или read-only восстановление.",
|
||||
busy: false,
|
||||
}
|
||||
: terminalPhysicalStopRequired
|
||||
? {
|
||||
label: "Управляющие действия заблокированы",
|
||||
detail: "Дождитесь подтверждённого состояния или выполните read-only восстановление.",
|
||||
busy: false,
|
||||
}
|
||||
: acquisition.state === "acquiring"
|
||||
&& !dataAuthoritative
|
||||
? {
|
||||
label: !controlAuthoritative
|
||||
? "Управляющая сессия K1 потеряна"
|
||||
: dataPlaneState === "lost"
|
||||
? "Связь с потоком K1 потеряна"
|
||||
: dataPlaneState === "stalled"
|
||||
? "Поток K1 нестабилен"
|
||||
: "Ожидаем подтверждённый поток K1",
|
||||
detail: !controlAuthoritative
|
||||
? "Состояние acquisition сохранено как последнее известное; команды устройству не отправляются."
|
||||
: "Управляющая сессия подтверждена, но живые данные пока не получили авторитетный статус.",
|
||||
busy: false,
|
||||
}
|
||||
: k1SpatialPhasePresentation(acquisition, softwareCommanded);
|
||||
const telemetry = deviceTelemetry(dataAuthoritative ? state.metrics : undefined);
|
||||
const stopDisabled = pendingAction !== null
|
||||
|| stopping;
|
||||
const controlFailure =
|
||||
state.application_control_session?.state === "failed"
|
||||
? state.application_control_session.failure?.message ||
|
||||
"Канонический диалог остановлен; автоматический повтор запрещён."
|
||||
: null;
|
||||
const actionFailure = spatialActionFailure(
|
||||
const runtimeActionFailure = spatialActionFailure(
|
||||
controller.error ??
|
||||
controlFailure ??
|
||||
(cleanupPending
|
||||
? "Локальный поток или архив ещё не завершён. Повторите остановку."
|
||||
? physicalStopInFlight
|
||||
? "Локальный поток или архив ещё не завершён. Команда устройству уже отправлена; дождитесь подтверждённого состояния."
|
||||
: localReceiverStopAllowed
|
||||
? "Локальный поток или архив ещё не завершён. Завершите только разрешённый сервером локальный приём."
|
||||
: "Локальный поток или архив ещё не завершён. Дождитесь подтверждённого состояния или выполните read-only восстановление."
|
||||
: null),
|
||||
);
|
||||
|
||||
const actionFailure = runtimeActionFailure ?? spatialActionFailure(authorityFailure);
|
||||
return (
|
||||
<section
|
||||
className="xgrids-k1-spatial-controls"
|
||||
aria-label="Управление сессией XGRIDS K1"
|
||||
aria-busy={phase.busy}
|
||||
data-busy={phase.busy ? "true" : undefined}
|
||||
>
|
||||
<div className="xgrids-k1-spatial-controls__phase">
|
||||
{phase.busy ? <span className="xgrids-k1-spatial-controls__spinner" aria-hidden="true" /> : null}
|
||||
{phase.busy ? <ActivityIndicator size="compact" /> : null}
|
||||
<span>
|
||||
<strong>{phase.label}</strong>
|
||||
<small>{phase.detail}</small>
|
||||
@@ -165,20 +305,47 @@ export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
<small>{actionFailure.detail}</small>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={stopDisabled}
|
||||
onClick={() => void stop(softwareCommanded ? PHYSICAL_ACCEPTANCE : undefined)}
|
||||
>
|
||||
{pendingAction === "stop"
|
||||
? softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
|
||||
: stopping
|
||||
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
|
||||
: actionFailure
|
||||
? "Повторить остановку"
|
||||
: softwareCommanded ? "Остановить устройство и запись" : "Остановить локальный приём"}
|
||||
</Button>
|
||||
{physicalStopPresented ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={stopDisabled || !physicalStopExecutable}
|
||||
onClick={() => {
|
||||
if (physicalStopExecutable) {
|
||||
void stop(operatorActionPhysicalAcceptance());
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xgrids-k1-spatial-controls__action-label">
|
||||
{physicalStopInFlight
|
||||
? "Останавливаем устройство…"
|
||||
: pendingAction === "stop"
|
||||
? terminalPhysicalStopRequired ? "Останавливаем K1…" : softwareCommanded ? "Останавливаем устройство…" : "Останавливаем приём…"
|
||||
: stopping
|
||||
? acquisition.state === "finalizing" ? "Сохраняем запись…" : "Остановка выполняется…"
|
||||
: terminalPhysicalStopRequired ? "Остановить K1" : "Остановить устройство и запись"}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!physicalStopPresented && localReceiverStopAllowed && !stopping ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
disabled={pendingAction !== null}
|
||||
onClick={() => void stopLocalReceiver()}
|
||||
>
|
||||
<span className="xgrids-k1-spatial-controls__action-label xgrids-k1-spatial-controls__action-label--local">
|
||||
{pendingAction === "stop"
|
||||
? "Завершаем локальный приём…"
|
||||
: "Завершить локальный приём"}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function K1SpatialControls(_props: DevicePluginConnectionProps) {
|
||||
const controller = useXgridsK1Controller();
|
||||
return <K1SpatialControlsView controller={controller} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user